diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs index 9eba6c1d3..0bfff8f08 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs @@ -295,7 +295,49 @@ public string GetRelativePath(string relativeTo, string path) relativeTo = fileSystem.Execute.Path.GetFullPath(relativeTo); path = fileSystem.Execute.Path.GetFullPath(path); - return System.IO.Path.GetRelativePath(relativeTo, path); + // Need to check if the roots are different- if they are we need to return the "to" path. + if (!AreRootsEqual(relativeTo, path, fileSystem.Execute.StringComparisonMode)) + { + return path; + } + + Func charComparer = (c1, c2) => c1 == c2; + if (fileSystem.Execute.StringComparisonMode == StringComparison.OrdinalIgnoreCase) + { + charComparer = (c1, c2) => char.ToUpperInvariant(c1) == char.ToUpperInvariant(c2); + } + + int commonLength = GetCommonPathLength(relativeTo, path, charComparer); + + // If there is nothing in common they can't share the same root, return the "to" path as is. + if (commonLength == 0) + { + return path; + } + + // Trailing separators aren't significant for comparison + int relativeToLength = relativeTo.Length; + if (IsDirectorySeparator(relativeTo[relativeToLength - 1])) + { + relativeToLength--; + } + + int pathLength = path.Length; + bool pathEndsInSeparator = IsDirectorySeparator(path[pathLength - 1]); + if (pathEndsInSeparator) + { + pathLength--; + } + + // If we have effectively the same path, return "." + if (relativeToLength == pathLength && commonLength >= relativeToLength) + { + return "."; + } + + return CreateRelativePath(relativeTo, path, + commonLength, relativeToLength, pathLength, + pathEndsInSeparator); } #endif @@ -324,7 +366,7 @@ public bool HasExtension([NotNullWhen(true)] string? path) return false; } - return TryGetExtensionIndex(path, out var dotIndex) + return TryGetExtensionIndex(path, out int? dotIndex) && dotIndex < path.Length - 1; } @@ -463,6 +505,24 @@ public bool TryJoin(ReadOnlySpan path1, #endregion + /// + /// Returns true if the two paths have the same root + /// + private bool AreRootsEqual(string first, string second, StringComparison comparisonType) + { + int firstRootLength = GetRootLength(first); + int secondRootLength = GetRootLength(second); + + return firstRootLength == secondRootLength + && string.Compare( + strA: first, + indexA: 0, + strB: second, + indexB: 0, + length: firstRootLength, + comparisonType: comparisonType) == 0; + } + private string CombineInternal(string[] paths) { string NormalizePath(string path, bool ignoreStartingSeparator) @@ -524,6 +584,110 @@ string NormalizePath(string path, bool ignoreStartingSeparator) return sb.ToString(); } + /// + /// We have the same root, we need to calculate the difference now using the + /// common Length and Segment count past the length. + /// + /// + /// Some examples: + /// + /// C:\Foo C:\Bar L3, S1 -> ..\Bar
+ /// C:\Foo C:\Foo\Bar L6, S0 -> Bar
+ /// C:\Foo\Bar C:\Bar\Bar L3, S2 -> ..\..\Bar\Bar
+ /// C:\Foo\Foo C:\Foo\Bar L7, S1 -> ..\Bar
+ ///
+ private string CreateRelativePath(string relativeTo, string path, int commonLength, + int relativeToLength, int pathLength, bool pathEndsInSeparator) + { + StringBuilder sb = new(); + + // Add parent segments for segments past the common on the "from" path + if (commonLength < relativeToLength) + { + sb.Append(".."); + + for (int i = commonLength + 1; i < relativeToLength; i++) + { + if (IsDirectorySeparator(relativeTo[i])) + { + sb.Append(DirectorySeparatorChar); + sb.Append(".."); + } + } + } + else if (IsDirectorySeparator(path[commonLength])) + { + // No parent segments, and we need to eat the initial separator + commonLength++; + } + + // Now add the rest of the "to" path, adding back the trailing separator + int differenceLength = pathLength - commonLength; + if (pathEndsInSeparator) + { + differenceLength++; + } + + if (differenceLength > 0) + { + if (sb.Length > 0) + { + sb.Append(DirectorySeparatorChar); + } + + sb.Append(path.Substring(commonLength, differenceLength)); + } + + return sb.ToString(); + } + + /// + /// Get the common path length from the start of the string. + /// + private int GetCommonPathLength(string first, string second, + Func charComparer) + { + int commonChars = 0; + for (; commonChars < first.Length; commonChars++) + { + if (second.Length < commonChars) + { + break; + } + + if (!charComparer(first[commonChars], second[commonChars])) + { + break; + } + } + + // If nothing matches + if (commonChars == 0) + { + return commonChars; + } + + // Or we're a full string and equal length or match to a separator + if (commonChars == first.Length + && (commonChars == second.Length || IsDirectorySeparator(second[commonChars]))) + { + return commonChars; + } + + if (commonChars == second.Length && IsDirectorySeparator(first[commonChars])) + { + return commonChars; + } + + // It's possible we matched somewhere in the middle of a segment e.g. C:\Foodie and C:\Foobar. + while (commonChars > 0 && !IsDirectorySeparator(first[commonChars - 1])) + { + commonChars--; + } + + return commonChars; + } + protected abstract int GetRootLength(string path); protected abstract bool IsDirectorySeparator(char c); protected abstract bool IsEffectivelyEmpty(string path); diff --git a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs index fea21b4e1..1e1ea7684 100644 --- a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs +++ b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs @@ -272,6 +272,7 @@ private bool IncludeSimulatedTests(ClassModel @class) "GetFullPathTests", "GetPathRootTests", "GetRandomFileNameTests", + "GetRelativePathTests", "GetTempFileNameTests", "GetTempPathTests", "HasExtensionTests", diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetRelativePathTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetRelativePathTests.cs index 8411587ba..45f605465 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetRelativePathTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetRelativePathTests.cs @@ -35,6 +35,33 @@ public void GetRelativePath_DifferentDrives_ShouldReturnAbsolutePath( result.Should().Be(path2); } + [SkippableTheory] + [InlineData(@"C:\FOO", @"C:\foo", ".", TestOS.Windows)] + [InlineData("/FOO", "/foo", "../foo", TestOS.Linux)] + [InlineData("/FOO", "/foo", ".", TestOS.Mac)] + [InlineData("foo", "foo/", ".", TestOS.All)] + [InlineData(@"C:\Foo", @"C:\Bar", @"..\Bar", TestOS.Windows)] + [InlineData(@"C:\Foo", @"C:\Foo\Bar", "Bar", TestOS.Windows)] + [InlineData(@"C:\Foo\Bar", @"C:\Bar\Bar", @"..\..\Bar\Bar", TestOS.Windows)] + [InlineData(@"C:\Foo\Foo", @"C:\Foo\Bar", @"..\Bar", TestOS.Windows)] + [InlineData("/Foo", "/Bar", "../Bar", TestOS.Linux | TestOS.Mac)] + [InlineData("/Foo", "/Foo/Bar", "Bar", TestOS.Linux | TestOS.Mac)] + [InlineData("/Foo/Bar", "/Bar/Bar", "../../Bar/Bar", TestOS.Linux | TestOS.Mac)] + [InlineData("/Foo/Foo", "/Foo/Bar", "../Bar", TestOS.Linux | TestOS.Mac)] + public void GetRelativePath_EdgeCases_ShouldReturnExpectedValue(string relativeTo, string path, + string expected, TestOS operatingSystem) + { + Skip.IfNot(Test.RunsOn(operatingSystem)); + if (operatingSystem == TestOS.All) + { + expected = expected.Replace('/', FileSystem.Path.DirectorySeparatorChar); + } + + string result = FileSystem.Path.GetRelativePath(relativeTo, path); + + result.Should().Be(expected); + } + [SkippableFact] public void GetRelativePath_FromAbsolutePathInCurrentDirectory_ShouldReturnRelativePath() {