diff --git a/src/NexusMods.Paths/AbsolutePath.cs b/src/NexusMods.Paths/AbsolutePath.cs index ed806f4..edacc8b 100644 --- a/src/NexusMods.Paths/AbsolutePath.cs +++ b/src/NexusMods.Paths/AbsolutePath.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Runtime.CompilerServices; using JetBrains.Annotations; using NexusMods.Paths.Extensions; @@ -11,7 +13,7 @@ namespace NexusMods.Paths; /// A path that represents a full path to a file or directory. /// [PublicAPI] -public readonly partial struct AbsolutePath : IEquatable, IPath +public readonly partial struct AbsolutePath : IEquatable, IPath { /// /// The directory component of the path. @@ -33,6 +35,9 @@ namespace NexusMods.Paths; /// README.md public readonly string FileName; + /// + RelativePath IPath.FileName => Name; + /// /// The implementation used by the IO methods. /// @@ -48,11 +53,17 @@ public AbsolutePath WithFileSystem(IFileSystem fileSystem) return new AbsolutePath(Directory, FileName, fileSystem); } + /// + /// Returns the FileName as a . + /// + /// + /// If this is a root directory, returns . + /// + public RelativePath Name => string.IsNullOrEmpty(FileName) ? RelativePath.Empty : new RelativePath(FileName); + /// public Extension Extension => string.IsNullOrEmpty(FileName) ? Extension.None : Extension.FromPath(FileName); - /// - RelativePath IPath.FileName => FileName; /// /// Gets the parent directory, i.e. navigates one folder up. @@ -67,6 +78,40 @@ public AbsolutePath Parent } } + /// + /// Returns the root folder of this path. + /// + public AbsolutePath GetRootComponent => GetRootDirectory(); + + /// + public IEnumerable Parts => + GetNonRootPart().Parts; + + /// + public IEnumerable GetAllParents() + { + var currentPath = this; + var root = GetRootDirectory(); + + while (currentPath != root) + { + yield return currentPath; + currentPath = currentPath.Parent; + } + yield return root; + } + + /// + /// Returns the non-root part of this path. + /// + public RelativePath GetNonRootPart() + { + return RelativeTo(GetRootDirectory()); + } + + /// + public bool IsRooted => true; + private AbsolutePath(string directory, string fileName, IFileSystem fileSystem) { Directory = directory; @@ -90,10 +135,9 @@ internal static AbsolutePath FromSanitizedFullPath(ReadOnlySpan fullPath, /// /// /// - internal static AbsolutePath FromUnsanitizedFullPath(ReadOnlySpan fullPath, IFileSystem fileSystem) + internal static AbsolutePath FromUnsanitizedFullPath(string fullPath, IFileSystem fileSystem) { - var sanitizedPath = PathHelpers.Sanitize(fullPath, fileSystem.OS); - return FromSanitizedFullPath(sanitizedPath, fileSystem); + return fileSystem.FromUnsanitizedFullPath(fullPath); } /// @@ -203,14 +247,21 @@ public AbsolutePath Combine(RelativePath path) /// /// Gets a path relative to another absolute path. /// + /// + /// Returns if is the same as this path. + /// /// The path from which the relative path should be made. + /// if the paths are not in the same folder. public RelativePath RelativeTo(AbsolutePath other) { var childLength = GetFullPathLength(); + var parentLength = other.GetFullPathLength(); + + if (childLength == parentLength && Equals(other)) return RelativePath.Empty; + var child = childLength <= 512 ? stackalloc char[childLength] : GC.AllocateUninitializedArray(childLength); GetFullPath(child); - var parentLength = other.GetFullPathLength(); var parent = parentLength <= 512 ? stackalloc char[parentLength] : GC.AllocateUninitializedArray(parentLength); other.GetFullPath(parent); @@ -221,11 +272,7 @@ public RelativePath RelativeTo(AbsolutePath other) return default; } - /// - /// Returns true if this path is a child of the specified path. - /// - /// The path to verify. - /// True if this is a child path of the parent path; else false. + /// public bool InFolder(AbsolutePath parent) { var parentLength = parent.GetFullPathLength(); @@ -238,6 +285,31 @@ public bool InFolder(AbsolutePath parent) return PathHelpers.InFolder(Directory, parentSpan, FileSystem.OS); } + /// + public bool StartsWith(AbsolutePath other) + { + var fullPath = GetFullPath(); + var prefix = other.GetFullPath(); + + if (fullPath.Length < prefix.Length) return false; + if (fullPath.Length == prefix.Length) return Equals(other); + if (!fullPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // If the other path is a parent of this path, then the next character must be a directory separator. + return fullPath[prefix.Length] == PathHelpers.DirectorySeparatorChar || + // unless the prefix is a root directory + PathHelpers.IsRootDirectory(prefix, FileSystem.OS); + } + + /// + public bool EndsWith(RelativePath other) + { + return GetNonRootPart().EndsWith(other); + } + /// public static bool operator ==(AbsolutePath lhs, AbsolutePath rhs) => lhs.Equals(rhs); diff --git a/src/NexusMods.Paths/FileTree/FileTreeNode.cs b/src/NexusMods.Paths/FileTree/FileTreeNode.cs new file mode 100644 index 0000000..9acf4ec --- /dev/null +++ b/src/NexusMods.Paths/FileTree/FileTreeNode.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; + +namespace NexusMods.Paths.FileTree; + +/// +/// Represents a generic tree of files with some associated value. +/// +/// The path type used in the tree +/// +[PublicAPI] +public class FileTreeNode : IFileTree> + where TPath : struct, IPath, IEquatable +{ + private readonly bool _isFile; + + private readonly Dictionary> _children; + private FileTreeNode? _parent; + + /// + /// Constructs a new with the given path, name, parent and value. + /// + /// If the value is null, this node is assumed to be a directory, a file otherwise. + /// The complete path for the node with respect to the root of the tree + /// The file name for the node + /// Whether this is a file node. + /// The associated value to be stored along a file entry. Should be null or default for directories. + public FileTreeNode(TPath path, RelativePath name, bool isFile, TValue? value) + { + Path = path; + Name = name; + _isFile = isFile; + Value = value; + _children = new Dictionary>(); + } + + /// + /// The complete path of the node with respect to the root of the tree. + /// + public TPath Path { get; } + + /// + public RelativePath Name { get; } + + /// + public bool IsFile => _isFile; + + /// + public bool IsDirectory => !_isFile; + + /// + public bool HasParent => _parent != null; + + /// + public bool IsTreeRoot => !HasParent; + + /// + /// A value associated with a File entry, null for directories. + /// + public TValue? Value { get; } + + /// + public IDictionary> Children => _children; + + /// + public FileTreeNode Parent + { + get + { + if (_parent == null) + { + throw new InvalidOperationException("Root node has no parent"); + } + + return _parent; + } + } + + /// + public FileTreeNode Root + { + get + { + var current = this; + while (current.HasParent) + { + current = current.Parent; + } + + return current; + } + } + + /// + public IEnumerable> GetSiblings() + { + if (IsTreeRoot) return Enumerable.Empty>(); + + return Parent.Children.Values.Where(x => x != this); + } + + /// + public IEnumerable> GetAllDescendentFiles() + { + if (IsFile) return Enumerable.Empty>(); + if (!Children.Any()) return Enumerable.Empty>(); + + return Children.Values.SelectMany(x => { return x.IsFile ? new[] { x } : x.GetAllDescendentFiles(); }); + } + + /// + /// Returns a dictionary of all the file entries under the current node. + /// + /// A dictionary of with as keys and as values. + public Dictionary GetAllDescendentFilesDictionary() + { + return GetAllDescendentFiles().ToDictionary(file => file.Path, file => file.Value!); + } + + /// + /// Recursively searches the subtree for a node with the given path. + /// + /// A complete path with respect to the tree root + /// null if not found + public FileTreeNode? FindNode(TPath searchedPath) + { + if (Path.Equals(searchedPath)) return this; + + return searchedPath.StartsWith(Path) + ? Children.Values.Select(child => child.FindNode(searchedPath)).FirstOrDefault(node => node != null) + : null; + } + + /// + /// Adds a collection of nodes as children to this node. + /// + /// + /// Sets the parent of the children to this node. + /// + /// The collection of nodes to be added + public void AddChildren(IEnumerable> children) + { + foreach (var child in children) + { + child._parent = this; + AddChild(child); + } + } + + /// + /// Adds a node as a child to this node. + /// + /// + /// Sets the parent of the child to this node. + /// + /// The node to be added + public void AddChild(FileTreeNode child) + { + child._parent = this; + _children.Add(child.Name, child); + } + + + /// + /// Creates a tree structure from a collection of file entries. + /// + /// + /// If the file paths are rooted, they all need to be sharing the same root component. + /// If the paths are not rooted, they are assumed to be relative to the same unknown root. + /// + /// A non empty collection of unique file entries in the form of TPath,TValue pairs. + /// The root node of the generated tree. + /// if the collection is empty. + public static FileTreeNode CreateTree(IEnumerable> files) + { + if (!files.Any()) throw new ArgumentException("Collection of files cannot be empty"); + + var fileArray = files.ToArray(); + var rootComponent = fileArray.First().Key.GetRootComponent; + + // If paths are rooted, we assume all the passed paths share the same root + // If paths are not rooted, we assume they are all relative to the same unknown root (RelativePath.Empty) + var rootNode = new FileTreeNode(rootComponent, RelativePath.Empty, false, default); + + rootNode.PopulateTree(fileArray); + + return rootNode; + } + + /// + /// Populates the tree with the given collection of file entries. + /// + /// + /// The current node is assumed to be the root of the tree. + /// All file paths are assumed to be relative to the root of the tree. + /// + /// A collection of unique files in the form of TPath,TValue pairs. + /// If there are duplicate file entries. + protected void PopulateTree(IEnumerable> files) + { + foreach (var fileEntry in files) + { + var parentNode = this; + var currentFullPath = fileEntry.Key; + + var parentPaths = currentFullPath.GetAllParents().Reverse().ToArray(); + + // traverse the path from root to leaf and add missing nodes + for (var i = 0; i < parentPaths.Length; i++) + { + // if the path is rooted, skip the first path as it is the root component, which we already have + if (Path.IsRooted && i == 0) continue; + + var subPath = parentPaths[i]; + var subPathName = subPath.Name; + + if (parentNode.Children.TryGetValue(subPathName, out var existing)) + { + // if we are at the last path, this is the file + if (i == parentPaths.Length - 1) + { + throw new InvalidOperationException($"Duplicate path found for file: {subPath}"); + } + + parentNode = existing; + continue; + } + + // if we are at the last path, this is the file + var isFile = i == parentPaths.Length - 1; + var value = isFile ? fileEntry.Value : default; + + var node = new FileTreeNode(subPath, subPathName, isFile, value); + + parentNode.AddChild(node); + + parentNode = node; + } + } + } +} diff --git a/src/NexusMods.Paths/FileTree/IFileTree.cs b/src/NexusMods.Paths/FileTree/IFileTree.cs new file mode 100644 index 0000000..b73b15a --- /dev/null +++ b/src/NexusMods.Paths/FileTree/IFileTree.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; + +namespace NexusMods.Paths.FileTree; + +/// +/// Represents a generic tree of files. +/// +/// Allows implementations to return concrete types +[PublicAPI] +public interface IFileTree where TFileTree : IFileTree +{ + + /// + /// The file name for this node. + /// + public RelativePath Name { get; } + + /// + /// Returns true if node is assumed to be a file. + /// + public bool IsFile { get; } + + /// + /// Returns true if node is assumed to be a directory. + /// + public bool IsDirectory { get; } + + /// + /// Returns true if node is the root of the tree. + /// + public bool IsTreeRoot { get; } + + /// + /// Returns tre if node has a parent. + /// + public bool HasParent { get; } + + /// + /// A Dictionary containing all the children of this node, both files and directories. + /// + /// + /// The key is the of the child. + /// + public IDictionary Children { get; } + + /// + /// Returns the parent node if it exists, throws InvalidOperationException otherwise. + /// + public TFileTree Parent { get; } + + /// + /// Returns the root node of the tree. + /// + public TFileTree Root { get; } + + /// + /// A collection of all sibling nodes, excluding this one. + /// + /// + /// Returns an empty collection if this node is the root. + /// + public IEnumerable GetSiblings(); + + /// + /// A collection of all File nodes that descend from this one. + /// + /// + /// Returns an empty collection if this node is a file. + /// + public IEnumerable GetAllDescendentFiles(); +} diff --git a/src/NexusMods.Paths/IPath.cs b/src/NexusMods.Paths/IPath.cs index b4ae0e1..122bd72 100644 --- a/src/NexusMods.Paths/IPath.cs +++ b/src/NexusMods.Paths/IPath.cs @@ -1,3 +1,7 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; + namespace NexusMods.Paths; /// @@ -15,3 +19,81 @@ public interface IPath /// RelativePath FileName { get; } } + +/// +/// Abstracts an individual path. +/// Allows methods to return specific path types. +/// +/// Concrete path type returned by method implementations +[PublicAPI] +public interface IPath : IPath where TConcretePath : struct, IPath, IEquatable +{ + /// + /// The file name of this path. + /// + /// + /// Returns an empty if this path is a root component. + /// + RelativePath Name { get; } + + /// + /// Traverses one directory up, returning the path of the parent. + /// + /// + /// If the path is a root component, returns the root component. + /// If path is not rooted and there are no parent directories, returns an empty path. + /// + TConcretePath Parent { get; } + + /// + /// If this path is rooted, returns the root component, an empty path otherwise. + /// + TConcretePath GetRootComponent { get; } + + /// + /// Returns a collection of parts that make up this path, excluding root components. + /// + /// + /// Root components like `C:/` are excluded and should be handled separately. + /// + IEnumerable Parts { get; } + + /// + /// Returns a collection of all parent paths, including this path. + /// + /// + /// Order is from this path to the root. + /// + IEnumerable GetAllParents(); + + /// + /// Returns a of the non-root part of this path. + /// + RelativePath GetNonRootPart(); + + /// + /// Returns whether this path is rooted. + /// + bool IsRooted { get; } + + /// + /// Returns true if this path is a child of the specified path. + /// + /// The potential parent path + /// The child path needs to have greater depth than the parent. + /// True if this is a child path of the parent path; else false. + bool InFolder(TConcretePath parent); + + /// + /// Returns true if this path starts with the specified path. + /// + /// The prefix path + bool StartsWith(TConcretePath other); + + /// + /// Returns true if this path ends with the specified RelativePath. + /// + /// Since RelativePaths can't contain Root components, this check won't consider root folders + /// The relative path with which this path is supposed to end + bool EndsWith(RelativePath other); +} diff --git a/src/NexusMods.Paths/RelativePath.cs b/src/NexusMods.Paths/RelativePath.cs index f04e5d2..5e761c1 100644 --- a/src/NexusMods.Paths/RelativePath.cs +++ b/src/NexusMods.Paths/RelativePath.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Runtime.CompilerServices; using JetBrains.Annotations; using NexusMods.Paths.Extensions; @@ -11,7 +12,7 @@ namespace NexusMods.Paths; /// A path that represents a partial path to a file or directory. /// [PublicAPI] -public readonly struct RelativePath : IEquatable, IPath, IComparable +public readonly struct RelativePath : IPath, IEquatable, IComparable { // NOTE(erri120): since relative paths are not rooted, the operating system // shouldn't matter. The OS is usually only relevant to determine the root part @@ -36,8 +37,13 @@ namespace NexusMods.Paths; /// public Extension Extension => Extension.FromPath(Path); + /// + /// Returns the file name of this path. + /// + public RelativePath FileName => Name; + /// - public RelativePath FileName => new(PathHelpers.GetFileName(Path, OS).ToString()); + public RelativePath Name => new(PathHelpers.GetFileName(Path, OS).ToString()); /// /// Amount of directories contained within this relative path. @@ -56,11 +62,54 @@ public RelativePath Parent } } + /// + /// Always returns an empty path as relative paths are not rooted. + /// + public RelativePath GetRootComponent => RelativePath.Empty; + + /// + public IEnumerable Parts + { + get + { + var currentPath = this; + while (currentPath.Path != Empty) + { + yield return currentPath.Name; + currentPath = currentPath.Parent; + } + } + } + + /// + public IEnumerable GetAllParents() + { + var parentPath = this; + while (parentPath != Empty) + { + yield return parentPath; + parentPath = parentPath.Parent; + } + } + + /// + /// Always returns itself as relative paths are not rooted. + /// + /// + public RelativePath GetNonRootPart() + { + return this; + } + + /// + public bool IsRooted => false; + + /// /// Obtains the name of the first folder stored in this path. /// /// - /// This will return empty string if there are no child directories. + /// This will return empty string if there are no child directories. /// public RelativePath TopParent { @@ -80,17 +129,16 @@ public RelativePath(string path) PathHelpers.DebugAssertIsSanitized(path, OS, isRelative: true); Path = path; } - + /// /// Creates a new from a . /// /// - /// public static RelativePath FromUnsanitizedInput(ReadOnlySpan path) { return new RelativePath(PathHelpers.Sanitize(path, OS)); } - + /// /// Returns the path with the directory separators native to the passed operating system. /// @@ -132,7 +180,22 @@ public bool StartsWith(ReadOnlySpan other) { return Path.AsSpan().StartsWith(other, StringComparison.OrdinalIgnoreCase); } - + + /// + public bool StartsWith(RelativePath other) + { + if (other.Path.Length == 0) return true; + if (other.Path.Length > Path.Length) return false; + if (other.Path.Length == Path.Length) return Equals(other); + if (!Path.AsSpan().StartsWith(other.Path, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // If the other path is a parent of this path, then the next character must be a directory separator. + return Path[other.Path.Length] == PathHelpers.DirectorySeparatorChar; + } + /// /// Returns true if the relative path ends with a given string. /// @@ -141,11 +204,22 @@ public bool EndsWith(ReadOnlySpan other) return Path.AsSpan().EndsWith(other, StringComparison.OrdinalIgnoreCase); } - /// - /// Returns true if this path is a child of this path. - /// - /// The path to verify. - /// True if this is a child path of the parent path; else false. + /// + public bool EndsWith(RelativePath other) + { + if (other.Path.Length == 0) return true; + if (other.Path.Length > Path.Length) return false; + if (other.Path.Length == Path.Length) return Equals(other); + if (!Path.AsSpan().EndsWith(other.Path, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // If this path ends with other but is longer, then the character before the other path must be a directory separator. + return Path[Path.Length - other.Path.Length - 1] == PathHelpers.DirectorySeparatorChar; + } + + /// public bool InFolder(RelativePath other) { return PathHelpers.InFolder(Path, other.Path, OS); @@ -164,11 +238,16 @@ public RelativePath DropFirst(int numDirectories = 1) /// /// Returns a path relative to the sub-path specified. /// + /// + /// Returns an empty path if matches this path. + /// /// The sub-path specified. + /// if is not a parent of this path. public RelativePath RelativeTo(RelativePath basePath) { var other = basePath.Path; if (other.Length == 0) return this; + if (basePath.Path == Path) return Empty; var res = PathHelpers.RelativeTo(Path, other, OS); if (!res.IsEmpty) return new RelativePath(res.ToString()); @@ -197,6 +276,7 @@ public override int GetHashCode() { return Path.AsSpan().GetNonRandomizedHashCode32(); } + #endregion /// diff --git a/tests/NexusMods.Paths.Tests/AbsolutePathTests.cs b/tests/NexusMods.Paths.Tests/AbsolutePathTests.cs index 12ce6f5..5fe3c3e 100644 --- a/tests/NexusMods.Paths.Tests/AbsolutePathTests.cs +++ b/tests/NexusMods.Paths.Tests/AbsolutePathTests.cs @@ -42,7 +42,21 @@ public void Test_FromUnsanitizedDirectoryAndFileName( actualPath.FileName.Should().Be(expectedFileName); actualPath.GetFullPath().Should().Be(expectedFullPath); } - + + [Theory] + [InlineData(true, "/", "")] + [InlineData(true, "/foo", "foo")] + [InlineData(true, "/foo/bar", "bar")] + [InlineData(false, "C:/", "")] + [InlineData(false, "C:/foo", "foo")] + [InlineData(false, "C:/foo/bar", "bar")] + public void Test_Name(bool isUnix, string input, string expected) + { + var path = CreatePath(input, isUnix); + var actual = path.Name; + actual.Should().Be(expected); + } + [Theory] [InlineData(true, "", "")] [InlineData(false, "", "")] @@ -52,7 +66,7 @@ public void Test_ToNativeSeparators(bool isUnix, string input, string expected) { var os = CreateOSInformation(isUnix); var fs = new InMemoryFileSystem(os); - + var absolutePath = AbsolutePath.FromUnsanitizedFullPath(input, fs); var actual = absolutePath.ToNativeSeparators(os); actual.Should().Be(expected); @@ -69,6 +83,55 @@ public void Test_Extension(string input, string expectedExtension) actualExtension.ToString().Should().Be(expectedExtension); } + [Theory] + [InlineData(true, "/", new string[] { })] + [InlineData(true, "/foo", new string[] { "foo" })] + [InlineData(true, "/foo/bar", new string[] { "foo", "bar" })] + [InlineData(false, "C:/", new string[] { })] + [InlineData(false, "C:/foo", new string[] { "foo" })] + [InlineData(false, "C:/foo/bar", new string[] { "foo", "bar" })] + public void Test_Parts(bool isUnix, string input, string[] expectedParts) + { + var path = CreatePath(input, isUnix); + var actualParts = path.Parts; + actualParts.Should().BeEquivalentTo(expectedParts.Select(p => new RelativePath(p))); + } + + [Theory] + [InlineData(true, "/", new string[] { "/" })] + [InlineData(true, "/foo", new string[] { "/foo", "/" })] + [InlineData(true, "/foo/bar", new string[] { "/foo/bar", "/foo", "/" })] + [InlineData(false, "C:/", new string[] { "C:/" })] + [InlineData(false, "C:/foo", new string[] { "C:/foo", "C:/" })] + [InlineData(false, "C:/foo/bar", new string[] { "C:/foo/bar", "C:/foo", "C:/" })] + public void Test_GetAllParents(bool isUnix, string input, string[] expectedParts) + { + var path = CreatePath(input, isUnix); + var actualParents = path.GetAllParents(); + actualParents.Should().BeEquivalentTo(expectedParts.Select(p => CreatePath(p, isUnix))); + } + + [Theory] + [InlineData(true, "/", "")] + [InlineData(true, "/foo", "foo")] + [InlineData(true, "/foo/bar", "foo/bar")] + [InlineData(false, "C:/", "")] + [InlineData(false, "C:/foo", "foo")] + [InlineData(false, "C:/foo/bar", "foo/bar")] + public void TestGetNonRootPart(bool isUnix, string input, string expected) + { + var path = CreatePath(input, isUnix); + var actual = path.GetNonRootPart(); + actual.Should().Be(expected); + } + + [Fact] + public void Test_IsRooted() + { + var path = CreatePath("/"); + path.IsRooted.Should().BeTrue(); + } + [Theory] [InlineData(true, "/", "/", "", "/")] [InlineData(true, "/foo", "/", "", "/")] @@ -76,7 +139,8 @@ public void Test_Extension(string input, string expectedExtension) [InlineData(false, "C:/", "C:/", "", "C:/")] [InlineData(false, "C:/foo", "C:/", "", "C:/")] [InlineData(false, "C:/foo/bar", "C:/", "foo", "C:/foo")] - public void Test_Parent(bool isUnix, string input, string expectedDirectory, string expectedFileName, string expectedFullPath) + public void Test_Parent(bool isUnix, string input, string expectedDirectory, string expectedFileName, + string expectedFullPath) { var path = CreatePath(input, isUnix); var actualParent = path.Parent; @@ -127,6 +191,7 @@ public void Test_GetRootDirectory(bool isUnix, string input, string expectedRoot } [Theory] + [InlineData(true, "/foo", "/foo", "")] [InlineData(true, "/foo", "/", "foo")] [InlineData(true, "/foo/bar/baz", "/", "foo/bar/baz")] [InlineData(true, "/foo/bar/baz", "/foo", "bar/baz")] @@ -153,17 +218,13 @@ public void Test_RelativeTo_PathException(string child, string parent) } [Theory] - [InlineData(true, "", "", true)] - [InlineData(true, "foo", "", true)] - [InlineData(true, "", "foo", false)] - [InlineData(true, "foo/bar", "foo", true)] - [InlineData(true, "foo", "bar", false)] [InlineData(true, "/", "/", true)] [InlineData(true, "/foo", "/", true)] [InlineData(true, "/foo/bar/baz", "/", true)] [InlineData(true, "/foo/bar/baz", "/foo", true)] [InlineData(true, "/foo/bar/baz", "/foo/bar", true)] [InlineData(true, "/foobar", "/foo", false)] + [InlineData(true, "/foo/bar/baz", "/foo/baz", false)] [InlineData(false, "C:/", "C:/", true)] [InlineData(false, "C:/foo", "C:/", true)] [InlineData(false, "C:/foo/bar/baz", "C:/", true)] @@ -178,6 +239,57 @@ public void Test_InFolder(bool isUnix, string child, string parent, bool expecte actual.Should().Be(expected); } + [Theory] + [InlineData(true, "/", "/", true)] + [InlineData(true, "/", "/foo", false)] + [InlineData(true, "/foo", "/bar", false)] + [InlineData(true, "/foo", "/", true)] + [InlineData(true, "/foo/bar/baz", "/", true)] + [InlineData(true, "/foo/bar/baz", "/foo", true)] + [InlineData(true, "/foo/bar/baz", "/foo/bar", true)] + [InlineData(true, "/foobar", "/foo", false)] + [InlineData(true, "/foo/bar/baz", "/foo/baz", false)] + [InlineData(false, "C:/", "C:/", true)] + [InlineData(false, "C:/foo", "C:/", true)] + [InlineData(false, "C:/foo/bar/baz", "C:/", true)] + [InlineData(false, "C:/foo/bar/baz", "C:/foo", true)] + [InlineData(false, "C:/foo/bar/baz", "C:/foo/bar", true)] + [InlineData(false, "C:/foobar", "C:/foo", false)] + public void Test_StartsWith(bool isUnix, string child, string parent, bool expected) + { + var childPath = CreatePath(child, isUnix); + var parentPath = CreatePath(parent, isUnix); + var actual = childPath.StartsWith(parentPath); + actual.Should().Be(expected); + } + + [Theory] + [InlineData(true, "/", "", true)] + [InlineData(true, "/", "foo", false)] + [InlineData(true, "/foo", "bar", false)] + [InlineData(true, "/foo", "", true)] + [InlineData(true, "/foo/bar/baz", "", true)] + [InlineData(true, "/foo/bar/baz", "bar/baz", true)] + [InlineData(true, "/foo/bar/baz", "foo/bar/baz", true)] + [InlineData(true, "/foobar", "bar", false)] + [InlineData(true, "/foo/bar/baz", "foo/baz", false)] + [InlineData(false, "C:/", "", true)] + [InlineData(false, "C:/", "foo", false)] + [InlineData(false, "C:/foo", "bar", false)] + [InlineData(false, "C:/foo", "", true)] + [InlineData(false, "C:/foo/bar/baz", "", true)] + [InlineData(false, "C:/foo/bar/baz", "bar/baz", true)] + [InlineData(false, "C:/foo/bar/baz", "foo/bar/baz", true)] + [InlineData(false, "C:/foobar", "bar", false)] + [InlineData(false, "C:/foo/bar/baz", "foo/baz", false)] + public void Test_EndsWith(bool isUnix, string path, string end, bool expected) + { + var startingPath = CreatePath(path, isUnix); + var endPath = (RelativePath)end; + var actual = startingPath.EndsWith(endPath); + actual.Should().Be(expected); + } + private static AbsolutePath CreatePath(string input, bool isUnix = true) { var os = CreateOSInformation(isUnix); diff --git a/tests/NexusMods.Paths.Tests/FileTree/AbsoluteFileTreeTests.cs b/tests/NexusMods.Paths.Tests/FileTree/AbsoluteFileTreeTests.cs new file mode 100644 index 0000000..d357147 --- /dev/null +++ b/tests/NexusMods.Paths.Tests/FileTree/AbsoluteFileTreeTests.cs @@ -0,0 +1,250 @@ +using System.Runtime.InteropServices; +using NexusMods.Paths.FileTree; + +namespace NexusMods.Paths.Tests.FileTree; + +public class AbsoluteFileTreeTests +{ + [Theory] + [InlineData("/file1.txt", true, true, 1)] + [InlineData("/file2.txt", true, false, 2)] + [InlineData("/foo/file2.txt", true, true, 2)] + [InlineData("/foo/file3.txt", true, true, 3)] + [InlineData("/foo/bar/file4.txt", true, true, 4)] + [InlineData("/baz/bazer/file5.txt", true, true, 5)] + [InlineData("/bazer/file5.txt", true, false, 5)] + [InlineData("C:/file1.txt", false, true, 1)] + [InlineData("C:/file2.txt", false, false, 2)] + [InlineData("C:/foo/file2.txt", false, true, 2)] + [InlineData("C:/foo/file3.txt", false, true, 3)] + [InlineData("C:/foo/bar/file4.txt", false, true, 4)] + [InlineData("C:/baz/bazer/file5.txt", false, true, 5)] + [InlineData("C:/bazer/file5.txt", false, false, 5)] + public void Test_FindNode(string path, bool isUnix, bool found, int value) + { + var tree = MakeTestTree(isUnix); + + var node = tree.FindNode(CreateAbsPath(path, isUnix)); + if (found) + { + node.Should().NotBeNull(); + node!.Path.Should().Be(CreateAbsPath(path, isUnix)); + node!.Value.Should().Be(value); + } + else + { + node.Should().BeNull(); + } + } + + [Theory] + [InlineData("/file1.txt", true, "file1.txt")] + [InlineData("/foo", true, "foo")] + [InlineData("/foo/file2.txt", true, "file2.txt")] + [InlineData("/foo/file3.txt", true, "file3.txt")] + [InlineData("/foo/bar", true, "bar")] + [InlineData("/foo/bar/file4.txt", true, "file4.txt")] + [InlineData("/baz/bazer", true, "bazer")] + [InlineData("/baz/bazer/file5.txt", true, "file5.txt")] + [InlineData("C:/file1.txt", false, "file1.txt")] + [InlineData("C:/foo", false, "foo")] + [InlineData("C:/foo/file2.txt", false, "file2.txt")] + [InlineData("C:/foo/file3.txt", false, "file3.txt")] + [InlineData("C:/foo/bar", false, "bar")] + public void Test_Name(string path, bool isUnix, string name) + { + var tree = MakeTestTree(isUnix); + + var node = tree.FindNode(CreateAbsPath(path, isUnix)); + node.Should().NotBeNull(); + node!.Name.Should().Be((RelativePath)name); + } + + [Theory] + [InlineData("/file1.txt", true, true)] + [InlineData("/foo", true, false)] + [InlineData("/foo/file2.txt", true, true)] + [InlineData("/foo/file3.txt", true, true)] + [InlineData("/foo/bar", true, false)] + [InlineData("/foo/bar/file4.txt", true, true)] + [InlineData("/baz/bazer", true, false)] + [InlineData("/baz/bazer/file5.txt", true, true)] + [InlineData("C:/file1.txt", false, true)] + [InlineData("C:/foo", false, false)] + [InlineData("C:/foo/file2.txt", false, true)] + [InlineData("C:/foo/file3.txt", false, true)] + [InlineData("C:/foo/bar", false, false)] + public void Test_IsFile(string path, bool isUnix, bool isFile) + { + var tree = MakeTestTree(isUnix); + + var node = tree.FindNode(CreateAbsPath(path, isUnix)); + node.Should().NotBeNull(); + node!.IsFile.Should().Be(isFile); + } + + + + [Theory] + [InlineData("/", true, false)] + [InlineData("/file1.txt", true, true)] + [InlineData("/foo", true, true)] + [InlineData("/foo/file2.txt", true, true)] + [InlineData("C:/", false, false)] + [InlineData("C:/file1.txt", false, true)] + [InlineData("C:/foo", false, true)] + [InlineData("C:/foo/file2.txt", false, true)] + public void Test_HasParent(string path, bool isUnix, bool hasParent) + { + var tree = MakeTestTree(isUnix); + + var node = tree.FindNode(CreateAbsPath(path, isUnix)); + node.Should().NotBeNull(); + node!.HasParent.Should().Be(hasParent); + if (hasParent) + { + node!.Parent.Should().NotBeNull(); + } + } + + [Theory] + [InlineData("/", true, true)] + [InlineData("/file1.txt", true, false)] + [InlineData("/foo", true, false)] + [InlineData("/foo/file2.txt", true, false)] + [InlineData("C:/", false, true)] + [InlineData("C:/file1.txt", false, false)] + [InlineData("C:/foo", false, false)] + [InlineData("C:/foo/file2.txt", false, false)] + public void Test_IsTreeRoot(string path, bool isUnix, bool isRoot) + { + var tree = MakeTestTree(isUnix); + + var node = tree.FindNode(CreateAbsPath(path, isUnix)); + node.Should().NotBeNull(); + node!.IsTreeRoot.Should().Be(isRoot); + if (isRoot) + { + var act = () => node!.Parent; + act.Should().Throw(); + } + + node!.Root.Path.Should().Be(isUnix ? CreateAbsPath("/", isUnix) : CreateAbsPath("C:/", isUnix)); + } + + [Theory] + [InlineData("/", true, new string[] { })] + [InlineData("/file1.txt", true, new string[] { "/foo", "/baz" })] + [InlineData("/foo", true, new string[] { "/file1.txt", "/baz" })] + [InlineData("/foo/bar/file4.txt", true, new string[] { })] + [InlineData("C:/", false, new string[] { })] + [InlineData("C:/file1.txt", false, new string[] { "C:/foo", "C:/baz" })] + [InlineData("C:/foo", false, new string[] { "C:/file1.txt", "C:/baz" })] + [InlineData("C:/foo/bar/file4.txt", false, new string[] { })] + public void Test_GetSiblings(string path, bool isUnix, string[] expectedSiblingPaths) + { + var tree = MakeTestTree(isUnix); + + var node = tree.FindNode(CreateAbsPath(path, isUnix)); + node.Should().NotBeNull(); + node!.GetSiblings().Select(x => x.Path).Should() + .BeEquivalentTo(expectedSiblingPaths.Select(x => CreateAbsPath(x, isUnix))); + } + + [Theory] + [InlineData("/", true, + new string[] + { "/file1.txt", "/foo/file2.txt", "/foo/file3.txt", "/foo/bar/file4.txt", "/baz/bazer/file5.txt" })] + [InlineData("/file1.txt", true, new string[] { })] + [InlineData("/foo", true, new string[] { "/foo/file2.txt", "/foo/file3.txt", "/foo/bar/file4.txt" })] + [InlineData("/foo/file2.txt", true, new string[] { })] + [InlineData("/foo/bar", true, new string[] { "/foo/bar/file4.txt" })] + [InlineData("/baz", true, new string[] { "/baz/bazer/file5.txt" })] + [InlineData("C:/", false, + new string[] + { "C:/file1.txt", "C:/foo/file2.txt", "C:/foo/file3.txt", "C:/foo/bar/file4.txt", "C:/baz/bazer/file5.txt" })] + [InlineData("C:/file1.txt", false, new string[] { })] + [InlineData("C:/foo", false, new string[] { "C:/foo/file2.txt", "C:/foo/file3.txt", "C:/foo/bar/file4.txt" })] + [InlineData("C:/foo/file2.txt", false, new string[] { })] + [InlineData("C:/foo/bar", false, new string[] { "C:/foo/bar/file4.txt" })] + [InlineData("C:/baz", false, new string[] { "C:/baz/bazer/file5.txt" })] + public void Test_GetAllDescendentFiles(string path, bool isUnix, string[] expectedDescendentPaths) + { + var tree = MakeTestTree(isUnix); + + var node = tree.FindNode(CreateAbsPath(path, isUnix)); + node.Should().NotBeNull(); + node!.GetAllDescendentFiles().Select(x => x.Path).Should() + .BeEquivalentTo(expectedDescendentPaths.Select(x => CreateAbsPath(x, isUnix))); + } + + + [Theory] + [InlineData("/", true, + new string[] + { "/file1.txt", "/foo/file2.txt", "/foo/file3.txt", "/foo/bar/file4.txt", "/baz/bazer/file5.txt" })] + [InlineData("/file1.txt", true, new string[] { })] + [InlineData("/foo", true, new string[] { "/foo/file2.txt", "/foo/file3.txt", "/foo/bar/file4.txt" })] + [InlineData("/foo/file2.txt", true, new string[] { })] + [InlineData("/foo/bar", true, new string[] { "/foo/bar/file4.txt" })] + [InlineData("/baz", true, new string[] { "/baz/bazer/file5.txt" })] + [InlineData("C:/", false, + new string[] + { "C:/file1.txt", "C:/foo/file2.txt", "C:/foo/file3.txt", "C:/foo/bar/file4.txt", "C:/baz/bazer/file5.txt" })] + [InlineData("C:/file1.txt", false, new string[] { })] + [InlineData("C:/foo", false, new string[] { "C:/foo/file2.txt", "C:/foo/file3.txt", "C:/foo/bar/file4.txt" })] + [InlineData("C:/foo/file2.txt", false, new string[] { })] + [InlineData("C:/foo/bar", false, new string[] { "C:/foo/bar/file4.txt" })] + [InlineData("C:/baz", false, new string[] { "C:/baz/bazer/file5.txt" })] + public void Test_GetAllDescendentFilesDictionary(string path, bool isUnix, string[] expectedDescendentPaths) + { + var tree = MakeTestTree(isUnix); + + var node = tree.FindNode(CreateAbsPath(path, isUnix)); + node.Should().NotBeNull(); + node!.GetAllDescendentFilesDictionary().Select(x => x.Key).Should() + .BeEquivalentTo(expectedDescendentPaths.Select(x => CreateAbsPath(x, isUnix))); + } + + private static FileTreeNode MakeTestTree(bool isUnix = true) + { + Dictionary fileEntries; + if (isUnix) + { + fileEntries = new Dictionary + { + { CreateAbsPath("/file1.txt", isUnix), 1 }, + { CreateAbsPath("/foo/file2.txt", isUnix), 2 }, + { CreateAbsPath("/foo/file3.txt", isUnix), 3 }, + { CreateAbsPath("/foo/bar/file4.txt", isUnix), 4 }, + { CreateAbsPath("/baz/bazer/file5.txt", isUnix), 5 }, + }; + } + else + { + fileEntries = new Dictionary + { + { CreateAbsPath("c:/file1.txt", isUnix), 1 }, + { CreateAbsPath("c:/foo/file2.txt", isUnix), 2 }, + { CreateAbsPath("c:/foo/file3.txt", isUnix), 3 }, + { CreateAbsPath("c:/foo/bar/file4.txt", isUnix), 4 }, + { CreateAbsPath("c:/baz/bazer/file5.txt", isUnix), 5 }, + }; + } + + return FileTreeNode.CreateTree(fileEntries); + } + + private static AbsolutePath CreateAbsPath(string input, bool isUnix = true) + { + var os = CreateOSInformation(isUnix); + var fs = new InMemoryFileSystem(os); + var path = AbsolutePath.FromSanitizedFullPath(input, fs); + return path; + } + + private static IOSInformation CreateOSInformation(bool isUnix) + { + return isUnix ? new OSInformation(OSPlatform.Linux) : new OSInformation(OSPlatform.Windows); + } +} diff --git a/tests/NexusMods.Paths.Tests/FileTree/RelativeFileTreeTests.cs b/tests/NexusMods.Paths.Tests/FileTree/RelativeFileTreeTests.cs new file mode 100644 index 0000000..66f3464 --- /dev/null +++ b/tests/NexusMods.Paths.Tests/FileTree/RelativeFileTreeTests.cs @@ -0,0 +1,176 @@ +using NexusMods.Paths.FileTree; + +namespace NexusMods.Paths.Tests.FileTree; + +public class RelativeFileTreeTests +{ + [Theory] + [InlineData("file1.txt", true, 1)] + [InlineData("file2.txt", false, 2)] + [InlineData("foo/file2.txt", true, 2)] + [InlineData("foo/file3.txt", true, 3)] + [InlineData("foo/bar/file4.txt", true, 4)] + [InlineData("baz/bazer/file5.txt", true, 5)] + public void Test_FindNode(string path, bool found, int value) + { + var tree = MakeTestTree(); + + var node = tree.FindNode((RelativePath)path); + if (found) + { + node.Should().NotBeNull(); + node!.Path.Should().Be((RelativePath)path); + node!.Value.Should().Be(value); + } + else + { + node.Should().BeNull(); + } + } + + [Theory] + [InlineData("file1.txt", "file1.txt")] + [InlineData("foo", "foo")] + [InlineData("foo/file2.txt", "file2.txt")] + [InlineData("foo/file3.txt", "file3.txt")] + [InlineData("foo/bar", "bar")] + [InlineData("foo/bar/file4.txt", "file4.txt")] + [InlineData("baz/bazer", "bazer")] + [InlineData("baz/bazer/file5.txt", "file5.txt")] + public void Test_Name(string path, string name) + { + var tree = MakeTestTree(); + + var node = tree.FindNode((RelativePath)path); + node.Should().NotBeNull(); + node!.Name.Should().Be((RelativePath)name); + } + + [Theory] + [InlineData("file1.txt", true)] + [InlineData("foo", false)] + [InlineData("foo/file2.txt", true)] + [InlineData("foo/file3.txt", true)] + [InlineData("foo/bar", false)] + [InlineData("foo/bar/file4.txt", true)] + [InlineData("baz/bazer", false)] + [InlineData("baz/bazer/file5.txt", true)] + public void Test_IsFile(string path, bool isFile) + { + var tree = MakeTestTree(); + + var node = tree.FindNode((RelativePath)path); + node.Should().NotBeNull(); + node!.IsFile.Should().Be(isFile); + } + + [Theory] + [InlineData("", false)] + [InlineData("file1.txt", true)] + [InlineData("foo", true)] + [InlineData("foo/file2.txt", true)] + public void Test_HasParent(string path, bool hasParent) + { + var tree = MakeTestTree(); + + var node = tree.FindNode((RelativePath)path); + node.Should().NotBeNull(); + node!.HasParent.Should().Be(hasParent); + if (hasParent) + { + node!.Parent.Should().NotBeNull(); + } + } + + [Theory] + [InlineData("", true)] + [InlineData("file1.txt", false)] + [InlineData("foo", false)] + [InlineData("foo/file2.txt", false)] + public void Test_IsTreeRoot(string path, bool isRoot) + { + var tree = MakeTestTree(); + + var node = tree.FindNode((RelativePath)path); + node.Should().NotBeNull(); + node!.IsTreeRoot.Should().Be(isRoot); + if (isRoot) + { + var act = () => node!.Parent; + act.Should().Throw(); + } + + node!.Root.Path.Should().Be(""); + } + + + [Theory] + [InlineData("", new string[] { })] + [InlineData("file1.txt", new string[] { "foo", "baz" })] + [InlineData("foo", new string[] { "file1.txt", "baz" })] + [InlineData("foo/bar/file4.txt", new string[] { })] + public void Test_GetSiblings(string path, string[] expectedSiblingPaths) + { + var tree = MakeTestTree(); + + var node = tree.FindNode((RelativePath)path); + node.Should().NotBeNull(); + node!.GetSiblings().Select(x => x.Path).Should() + .BeEquivalentTo(expectedSiblingPaths.Select(x => (RelativePath)x)); + } + + [Theory] + [InlineData("", + new string[] + { "file1.txt", "foo/file2.txt", "foo/file3.txt", "foo/bar/file4.txt", "baz/bazer/file5.txt" })] + [InlineData("file1.txt", new string[] { })] + [InlineData("foo", new string[] { "foo/file2.txt", "foo/file3.txt", "foo/bar/file4.txt" })] + [InlineData("foo/file2.txt", new string[] { })] + [InlineData("foo/bar", new string[] { "foo/bar/file4.txt" })] + [InlineData("baz", new string[] { "baz/bazer/file5.txt" })] + public void Test_GetAllDescendentFiles(string path, string[] expectedDescendentPaths) + { + var tree = MakeTestTree(); + + var node = tree.FindNode((RelativePath)path); + node.Should().NotBeNull(); + node!.GetAllDescendentFiles().Select(x => x.Path).Should() + .BeEquivalentTo(expectedDescendentPaths.Select(x => (RelativePath)x)); + } + + + [Theory] + [InlineData("", + new string[] + { "file1.txt", "foo/file2.txt", "foo/file3.txt", "foo/bar/file4.txt", "baz/bazer/file5.txt" })] + [InlineData("file1.txt", new string[] { })] + [InlineData("foo", new string[] { "foo/file2.txt", "foo/file3.txt", "foo/bar/file4.txt" })] + [InlineData("foo/file2.txt", new string[] { })] + [InlineData("foo/bar", new string[] { "foo/bar/file4.txt" })] + [InlineData("baz", new string[] { "baz/bazer/file5.txt" })] + public void Test_GetAllDescendentFilesDictionary(string path, string[] expectedDescendentPaths) + { + var tree = MakeTestTree(); + + var node = tree.FindNode((RelativePath)path); + node.Should().NotBeNull(); + node!.GetAllDescendentFilesDictionary().Select(x => x.Key).Should() + .BeEquivalentTo(expectedDescendentPaths.Select(x => (RelativePath)x)); + } + + private static FileTreeNode MakeTestTree() + { + Dictionary fileEntries; + + fileEntries = new Dictionary + { + { new RelativePath("file1.txt"), 1 }, + { new RelativePath("foo/file2.txt"), 2 }, + { new RelativePath("foo/file3.txt"), 3 }, + { new RelativePath("foo/bar/file4.txt"), 4 }, + { new RelativePath("baz/bazer/file5.txt"), 5 }, + }; + + return FileTreeNode.CreateTree(fileEntries); + } +} diff --git a/tests/NexusMods.Paths.Tests/RelativePathTests.cs b/tests/NexusMods.Paths.Tests/RelativePathTests.cs index 32b72f3..e8bde9c 100644 --- a/tests/NexusMods.Paths.Tests/RelativePathTests.cs +++ b/tests/NexusMods.Paths.Tests/RelativePathTests.cs @@ -8,7 +8,7 @@ private static IOSInformation CreateOSInformation(bool isUnix) { return isUnix ? OSInformation.FakeUnix : OSInformation.FakeWindows; } - + [Theory] [InlineData("a", "a")] [InlineData("a/b", "a/b")] @@ -32,8 +32,8 @@ public void Test_FromStringImplicitCast(string input, string expected) var path = basePath.Combine(input).RelativeTo(basePath); path.ToString().Should().Be(expected); } - - + + [Theory] [InlineData("a", "a")] [InlineData("a/", "a")] @@ -45,11 +45,10 @@ public void Test_FromUnsanitizedInput( string inputPath, string expectedRelativePath) { - var sanitizedPath = RelativePath.FromUnsanitizedInput(inputPath); sanitizedPath.Should().Be(expectedRelativePath); } - + [Theory] [InlineData(true, "", "")] [InlineData(false, "", "")] @@ -58,11 +57,21 @@ public void Test_FromUnsanitizedInput( public void Test_ToNativeSeparators(bool isUnix, string input, string expected) { var os = CreateOSInformation(isUnix); - + var path = new RelativePath(input); path.ToNativeSeparators(os).Should().Be(expected); } + [Theory] + [InlineData("", "")] + [InlineData("foo", "foo")] + [InlineData("foo/bar", "bar")] + public void Test_Name(string input, string expected) + { + var path = new RelativePath(input); + path.Name.Should().Be(expected); + } + [Theory] [InlineData("", "")] [InlineData("foo", "")] @@ -116,6 +125,60 @@ public void Test_TopParent(string input, string expectedParent) path.TopParent.Should().Be(expectedParent); } + [Theory] + [InlineData("", "")] + [InlineData("foo", "")] + [InlineData("foo/bar", "")] + [InlineData("foo/bar/baz", "")] + public void Test_GetRootComponent(string input, string expectedRootComponent) + { + var path = new RelativePath(input); + path.GetRootComponent.Should().Be(expectedRootComponent); + } + + [Theory] + [InlineData("", new string[] { })] + [InlineData("foo", new string[] { "foo" })] + [InlineData("foo/bar", new string[] { "foo", "bar" })] + [InlineData("foo/bar/baz", new string[] { "foo", "bar", "baz" })] + public void Test_Parts(string input, string[] expectedParts) + { + var path = new RelativePath(input); + path.Parts.Should().BeEquivalentTo(expectedParts.Select(x => new RelativePath(x))); + } + + [Theory] + [InlineData("", new string[] { })] + [InlineData("foo", new string[] { "foo" })] + [InlineData("foo/bar", new string[] { "foo/bar", "foo" })] + [InlineData("foo/bar/baz", new string[] { "foo/bar/baz", "foo/bar", "foo" })] + public void Test_GetAllParents(string input, string[] expectedParts) + { + var path = new RelativePath(input); + path.GetAllParents().Should().BeEquivalentTo(expectedParts.Select(x => new RelativePath(x))); + } + + [Theory] + [InlineData("", "")] + [InlineData("foo", "foo")] + [InlineData("foo/bar", "foo/bar")] + [InlineData("foo/bar/baz", "foo/bar/baz")] + public void Test_GetNonRootPart(string input, string expected) + { + var path = new RelativePath(input); + path.GetNonRootPart().Should().Be(expected); + } + + [Theory] + [InlineData("", false)] + [InlineData("foo", false)] + [InlineData("foo/bar", false)] + public void Test_IsRooted(string input, bool expected) + { + var path = new RelativePath(input); + path.IsRooted.Should().Be(expected); + } + [Theory] [InlineData("foo", ".txt", "foo.txt")] [InlineData("foo.txt", ".md", "foo.md")] @@ -161,7 +224,7 @@ public void Test_StartsWith(string left, string right, bool expected) var actual = path.StartsWith(right); actual.Should().Be(expected); } - + [Theory] [InlineData("foo", "bar", false)] [InlineData("foo", "foo", true)] @@ -188,6 +251,42 @@ public void Test_InFolder(string left, string right, bool expected) actual.Should().Be(expected); } + [Theory] + [InlineData("", "", true)] + [InlineData("", "foo", false)] + [InlineData("foo", "bar", false)] + [InlineData("foo", "", true)] + [InlineData("foo/bar/baz", "", true)] + [InlineData("foo/bar/baz", "foo", true)] + [InlineData("foo/bar/baz", "foo/bar", true)] + [InlineData("foobar", "foo", false)] + [InlineData("foo/bar/baz", "foo/baz", false)] + public void Test_StartsWithRelative(string child, string parent, bool expected) + { + var childPath = (RelativePath)child; + var parentPath = (RelativePath)parent; + var actual = childPath.StartsWith(parentPath); + actual.Should().Be(expected); + } + + [Theory] + [InlineData("", "", true)] + [InlineData("", "foo", false)] + [InlineData("foo", "bar", false)] + [InlineData("foo", "", true)] + [InlineData("foo/bar/baz", "", true)] + [InlineData("foo/bar/baz", "bar/baz", true)] + [InlineData("foo/bar/baz", "foo/bar/baz", true)] + [InlineData("foobar", "bar", false)] + [InlineData("foo/bar/baz", "foo/baz", false)] + public void Test_EndsWithRelative(string child, string parent, bool expected) + { + var childPath = (RelativePath)child; + var parentPath = (RelativePath)parent; + var actual = childPath.EndsWith(parentPath); + actual.Should().Be(expected); + } + [Theory] [InlineData("foo/bar/baz", 0, "foo/bar/baz")] [InlineData("foo/bar/baz", 1, "bar/baz")] @@ -203,6 +302,7 @@ public void Test_DropFirst(string input, int count, string expectedOutput) [Theory] [InlineData("foo/bar/baz", "foo", "bar/baz")] [InlineData("foo/bar/baz", "foo/bar", "baz")] + [InlineData("foo/bar/baz", "foo/bar/baz", "")] public void Test_RelativeTo(string left, string right, string expectedOutput) { var leftPath = new RelativePath(left);