diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ee3ea5..606ed38 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- `LocalPath.IsPrefixOf` now treats an empty path — the normalized form of `""`, `"."` and `"a/.."`, and the parent of any single-segment relative path — as the current directory, so it is a prefix of every relative path that does not begin with a `..` reference.
### Fixed
+- [#225](https://github.com/ForNeVeR/TruePath/issues/225): Make path prefix checks use the same platform-default case sensitivity as path equality.
- `LocalPath.IsPrefixOf` and `StartsWith` now compare path strings ordinally. Previously they used the current culture, which ignores collation-ignorable characters, so a path could be reported as a prefix of an unrelated one.
## [1.12.0] - 2026-03-14
diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs
index 95dd2f4..fa1cea3 100644
--- a/TruePath.Tests/AbsolutePathTests.cs
+++ b/TruePath.Tests/AbsolutePathTests.cs
@@ -144,7 +144,6 @@ public void ParentIsCalculatedCorrectly(string relativePath, string? expectedRel
[Theory]
[InlineData("/home/user", "/home/user/documents", true)]
- [InlineData("/home/usEr", "/home/User/documents", false)]
[InlineData("/home/user/documents", "/home/user/documents", true)]
[InlineData("/home/user/documents", "/home/user", false)]
public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB, bool expected)
@@ -158,6 +157,40 @@ public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB,
Assert.Equal(expected, b.StartsWith(a));
}
+ [Fact]
+ public void IsPrefixOfFollowsPlatformCaseSensitivityForSameName()
+ {
+ var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/");
+ var a = root / "Foo";
+ var b = root / "foo";
+
+ Assert.Equal(Utils.IsPlatformCaseInsensitive(), a.IsPrefixOf(b));
+ Assert.Equal(Utils.IsPlatformCaseInsensitive(), b.StartsWith(a));
+ Assert.Equal(a == b, a.IsPrefixOf(b));
+ }
+
+ [Fact]
+ public void IsPrefixOfFollowsPlatformCaseSensitivityForDescendant()
+ {
+ var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/");
+ var a = root / "Foo";
+ var b = root / "foo/file.txt";
+
+ Assert.Equal(Utils.IsPlatformCaseInsensitive(), a.IsPrefixOf(b));
+ Assert.Equal(Utils.IsPlatformCaseInsensitive(), b.StartsWith(a));
+ }
+
+ [Fact]
+ public void IsPrefixOfRequiresWholeSegmentRegardlessOfCase()
+ {
+ var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/");
+ var a = root / "Foo";
+ var b = root / "foobar";
+
+ Assert.False(a.IsPrefixOf(b));
+ Assert.False(b.StartsWith(a));
+ }
+
[Theory]
[InlineData("sub", "sub/a.txt", true)]
[InlineData("sub", "sub", true)]
diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs
index d5c877b..ff07080 100644
--- a/TruePath.Tests/LocalPathTests.cs
+++ b/TruePath.Tests/LocalPathTests.cs
@@ -70,7 +70,7 @@ public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB)
// Act
var isPrefix = a.IsPrefixOf(b);
- var startsWith = b.Value.StartsWith(a.Value);
+ var startsWith = b.StartsWith(a);
// Assert
Assert.Equal(isPrefix, startsWith);
@@ -115,6 +115,37 @@ public void IsPrefixOfAndStartsWith(string prefix, string other, bool result)
Assert.Equal(result, b.StartsWith(a));
}
+ [Fact]
+ public void IsPrefixOfFollowsPlatformCaseSensitivityForSameName()
+ {
+ var a = new LocalPath("Foo");
+ var b = new LocalPath("foo");
+
+ Assert.Equal(Utils.IsPlatformCaseInsensitive(), a.IsPrefixOf(b));
+ Assert.Equal(Utils.IsPlatformCaseInsensitive(), b.StartsWith(a));
+ Assert.Equal(a == b, a.IsPrefixOf(b));
+ }
+
+ [Fact]
+ public void IsPrefixOfFollowsPlatformCaseSensitivityForDescendant()
+ {
+ var a = new LocalPath("Foo");
+ var b = new LocalPath("foo/bar");
+
+ Assert.Equal(Utils.IsPlatformCaseInsensitive(), a.IsPrefixOf(b));
+ Assert.Equal(Utils.IsPlatformCaseInsensitive(), b.StartsWith(a));
+ }
+
+ [Fact]
+ public void IsPrefixOfRequiresWholeSegmentRegardlessOfCase()
+ {
+ var a = new LocalPath("Foo");
+ var b = new LocalPath("foobar");
+
+ Assert.False(a.IsPrefixOf(b));
+ Assert.False(b.StartsWith(a));
+ }
+
[Fact]
public void EmptyParentIsPrefixOfDescendantPath()
{
diff --git a/TruePath.Tests/Utils.cs b/TruePath.Tests/Utils.cs
index ccc8111..1535e33 100644
--- a/TruePath.Tests/Utils.cs
+++ b/TruePath.Tests/Utils.cs
@@ -23,6 +23,9 @@ internal static string ToNonCanonicalCase(this string path)
return nonCanonicalPath;
}
+ internal static bool IsPlatformCaseInsensitive() =>
+ OperatingSystem.IsWindows() || OperatingSystem.IsMacOS();
+
internal static bool RunsOnCi()
{
return Environment.GetEnvironmentVariable("CI") is not null;
diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs
index 4632b78..cbbb336 100644
--- a/TruePath/AbsolutePath.cs
+++ b/TruePath/AbsolutePath.cs
@@ -137,7 +137,7 @@ public static AbsolutePath CurrentWorkingDirectory
public override string ToString() => Value;
/// Compares the path with another.
- /// Note that currently this comparison is case-sensitive.
+ /// Uses for platform-default case sensitivity.
public bool Equals(AbsolutePath other) => Equals(other, PlatformDefaultComparer);
///
diff --git a/TruePath/Comparers/PlatformDefaultPathComparer.cs b/TruePath/Comparers/PlatformDefaultPathComparer.cs
index e5bb5af..b751c6a 100644
--- a/TruePath/Comparers/PlatformDefaultPathComparer.cs
+++ b/TruePath/Comparers/PlatformDefaultPathComparer.cs
@@ -21,19 +21,14 @@ namespace TruePath.Comparers;
///
internal class PlatformDefaultPathComparer : IPathComparer where TPath : IPath
{
- private readonly StringComparer _stringComparer;
+ internal static readonly StringComparison DefaultStringComparison =
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
- public PlatformDefaultPathComparer()
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
- {
- _stringComparer = StringComparer.OrdinalIgnoreCase;
- }
- else
- {
- _stringComparer = StringComparer.Ordinal;
- }
- }
+ private readonly StringComparer _stringComparer = DefaultStringComparison == StringComparison.OrdinalIgnoreCase
+ ? StringComparer.OrdinalIgnoreCase
+ : StringComparer.Ordinal;
public bool Equals(TPath? x, TPath? y)
{
diff --git a/TruePath/IPath.cs b/TruePath/IPath.cs
index 6a61d03..99cfa69 100644
--- a/TruePath/IPath.cs
+++ b/TruePath/IPath.cs
@@ -49,7 +49,7 @@ public interface IPath where TPath : IPath
/// This is the exact inverse of : a.IsPrefixOf(b) means the same as
/// b.StartsWith(a).
///
- /// Note that currently this comparison is case-sensitive.
+ /// Case sensitivity follows the path type's platform-default comparer.
bool IsPrefixOf(TPath other);
///
@@ -68,7 +68,7 @@ public interface IPath where TPath : IPath
/// This is the exact inverse of : a.StartsWith(b) means the same as
/// b.IsPrefixOf(a).
///
- /// Note that currently this comparison is case-sensitive.
+ /// Case sensitivity follows the path type's platform-default comparer.
bool StartsWith(TPath other);
#if NET8_0_OR_GREATER
diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs
index 350b0a1..8d4496b 100644
--- a/TruePath/LocalPath.cs
+++ b/TruePath/LocalPath.cs
@@ -88,7 +88,7 @@ public LocalPath? Parent
public override string ToString() => Value;
/// Compares the path with another.
- /// Note that currently this comparison is case-sensitive.
+ /// Uses for platform-default case sensitivity.
public bool Equals(LocalPath other) => Equals(other, PlatformDefaultComparer);
///
@@ -165,9 +165,8 @@ public bool IsPrefixOf(LocalPath other)
// very start of a path, so testing the first segment is enough.
if (Value.Length == 0) return !StartsWithParentDirectoryReference(other.Value);
- // TODO[#225]: this comparison is ordinal, while Equals follows the platform default, so on Windows and macOS
- // two paths may be equal and yet not prefixes of each other.
- if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value, StringComparison.Ordinal)))
+ if (!(Value.Length <= other.Value.Length &&
+ other.Value.StartsWith(Value, PlatformDefaultPathComparer.DefaultStringComparison)))
return false;
return other.Value.Length == Value.Length ||
Value[Value.Length - 1] == Separator ||