< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Workspace.WorkspacePath
Assembly: NexusLabs.Needlr.AgentFramework
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework/Workspace/WorkspacePath.cs
Line coverage
100%
Covered lines: 31
Uncovered lines: 0
Coverable lines: 31
Total lines: 173
Line coverage: 100%
Branch coverage
100%
Covered branches: 18
Total branches: 18
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_PathComparer()100%11100%
Canonicalize(...)100%22100%
CanonicalizeDirectory(...)100%11100%
CanonicalizeCore(...)100%1616100%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework/Workspace/WorkspacePath.cs

#LineLine coverage
 1namespace NexusLabs.Needlr.AgentFramework.Workspace;
 2
 3/// <summary>
 4/// Canonicalization helpers for <see cref="IWorkspace"/> paths.
 5/// </summary>
 6/// <remarks>
 7/// <para>
 8/// <see cref="IWorkspace"/> is a logical, rooted key/value store: a path identifies a
 9/// single file regardless of how the caller spells it. Without canonicalization, the
 10/// strings <c>kb/foo.md</c>, <c>./kb/foo.md</c>, <c>kb//foo.md</c>, <c>kb/./foo.md</c>,
 11/// <c>/kb/foo.md</c>, and <c>kb/foo.md/</c> would all be distinct keys despite
 12/// referring to the same logical file. This class defines the canonical form every
 13/// implementation MUST produce before keying or comparing paths.
 14/// </para>
 15/// <para>
 16/// The canonical form is structural only — it does NOT lower-case or re-encode the
 17/// string. Path equality is performed by <see cref="PathComparer"/>, which is
 18/// case-insensitive (<see cref="StringComparer.OrdinalIgnoreCase"/>). Implementations
 19/// MUST use this comparer (or one with equivalent semantics) when storing or comparing
 20/// canonicalized paths.
 21/// </para>
 22/// <para>
 23/// Use <see cref="Canonicalize"/> for paths that identify a file (the common case). Use
 24/// <see cref="CanonicalizeDirectory"/> for paths that identify a directory (e.g., the
 25/// <c>directory</c> argument to <see cref="IWorkspace.ListDirectory"/>) — the directory
 26/// variant accepts root-equivalent inputs (<c>""</c>, <c>"."</c>, <c>"/"</c>, etc.) and
 27/// returns the workspace-root sentinel (<c>""</c>), whereas the file variant rejects
 28/// them.
 29/// </para>
 30/// <para>
 31/// Invalid paths throw <see cref="ArgumentNullException"/> or
 32/// <see cref="ArgumentException"/> directly. They are NOT wrapped in
 33/// <see cref="WorkspaceResult{T}.Fail"/>. <see cref="WorkspaceResult{T}.Fail"/> is
 34/// reserved for valid paths where the workspace operation legitimately fails (file
 35/// missing, compare-exchange mismatch, etc.).
 36/// </para>
 37/// </remarks>
 38public static class WorkspacePath
 39{
 40    /// <summary>
 41    /// The <see cref="StringComparer"/> implementations MUST use for path equality.
 42    /// Case-insensitive, ordinal — matches the dominant file-system convention on
 43    /// Windows and macOS, and aligns with the historical
 44    /// <see cref="InMemoryWorkspace"/> behavior.
 45    /// </summary>
 44846    public static StringComparer PathComparer { get; } = StringComparer.OrdinalIgnoreCase;
 47
 48    /// <summary>
 49    /// Canonicalizes a workspace file path.
 50    /// </summary>
 51    /// <remarks>
 52    /// <para>Rules applied, in order:</para>
 53    /// <list type="number">
 54    ///   <item><description>Outer whitespace is trimmed (segment-internal whitespace is preserved).</description></item
 55    ///   <item><description>Backslashes (<c>\</c>) are replaced with forward slashes (<c>/</c>).</description></item>
 56    ///   <item><description><c>.</c> segments are dropped (so <c>./kb/foo</c> becomes <c>kb/foo</c>).</description></it
 57    ///   <item><description>Empty segments are collapsed (so <c>kb//foo</c> becomes <c>kb/foo</c>).</description></item
 58    ///   <item><description>Leading and trailing slashes are stripped (so <c>/kb/foo/</c> becomes <c>kb/foo</c>).</desc
 59    /// </list>
 60    /// <para>The following inputs are rejected:</para>
 61    /// <list type="bullet">
 62    ///   <item><description><see langword="null"/> → <see cref="ArgumentNullException"/>.</description></item>
 63    ///   <item><description>Empty or whitespace-only string → <see cref="ArgumentException"/>.</description></item>
 64    ///   <item><description>Any segment exactly equal to <c>..</c> (parent traversal) → <see cref="ArgumentException"/>
 65    ///   <item><description>Inputs that canonicalize to the empty string (<c>"/"</c>, <c>"//"</c>, <c>"."</c>, <c>"./"<
 66    /// </list>
 67    /// </remarks>
 68    /// <param name="path">The path to canonicalize.</param>
 69    /// <returns>The canonical form of <paramref name="path"/>.</returns>
 70    /// <exception cref="ArgumentNullException"><paramref name="path"/> is <see langword="null"/>.</exception>
 71    /// <exception cref="ArgumentException"><paramref name="path"/> is empty, whitespace-only, contains a <c>..</c> segm
 72    /// <example>
 73    /// <code>
 74    /// WorkspacePath.Canonicalize("kb/foo.md")        // → "kb/foo.md"
 75    /// WorkspacePath.Canonicalize("./kb/foo.md")      // → "kb/foo.md"
 76    /// WorkspacePath.Canonicalize("kb//foo.md")       // → "kb/foo.md"
 77    /// WorkspacePath.Canonicalize(@"kb\foo.md")       // → "kb/foo.md"
 78    /// WorkspacePath.Canonicalize("/kb/foo.md/")      // → "kb/foo.md"
 79    /// WorkspacePath.Canonicalize("kb/../foo.md")     // throws ArgumentException
 80    /// WorkspacePath.Canonicalize("/")                // throws ArgumentException
 81    /// </code>
 82    /// </example>
 83    public static string Canonicalize(string path)
 84    {
 34385        var canonical = CanonicalizeCore(path, allowRoot: false);
 31986        if (canonical.Length == 0)
 87        {
 1488            throw new ArgumentException(
 1489                $"Workspace file path cannot be empty after canonicalization (input: '{path}'). Use WorkspacePath.Canoni
 1490                nameof(path));
 91        }
 92
 30593        return canonical;
 94    }
 95
 96    /// <summary>
 97    /// Canonicalizes a workspace directory path. Use this for the <c>directory</c>
 98    /// argument to <see cref="IWorkspace.ListDirectory"/> and any other API that
 99    /// accepts a directory.
 100    /// </summary>
 101    /// <remarks>
 102    /// <para>Same rules as <see cref="Canonicalize"/>, with one difference:
 103    /// root-equivalent inputs (<c>""</c>, whitespace-only, <c>"."</c>, <c>"./"</c>,
 104    /// <c>"/"</c>, <c>"//"</c>, <c>"/./"</c>, …) all return the empty string,
 105    /// representing the workspace root. <see cref="Canonicalize"/> rejects these.
 106    /// </para>
 107    /// <para>
 108    /// <see langword="null"/> still throws <see cref="ArgumentNullException"/>, and
 109    /// segment-exact <c>..</c> still throws <see cref="ArgumentException"/>.
 110    /// </para>
 111    /// </remarks>
 112    /// <param name="directory">The directory path to canonicalize.</param>
 113    /// <returns>
 114    /// The canonical form of <paramref name="directory"/>, or the empty string for
 115    /// root-equivalent inputs.
 116    /// </returns>
 117    /// <exception cref="ArgumentNullException"><paramref name="directory"/> is <see langword="null"/>.</exception>
 118    /// <exception cref="ArgumentException"><paramref name="directory"/> contains a <c>..</c> segment.</exception>
 119    /// <example>
 120    /// <code>
 121    /// WorkspacePath.CanonicalizeDirectory("")        // → ""
 122    /// WorkspacePath.CanonicalizeDirectory(".")       // → ""
 123    /// WorkspacePath.CanonicalizeDirectory("/")       // → ""
 124    /// WorkspacePath.CanonicalizeDirectory("./src")   // → "src"
 125    /// WorkspacePath.CanonicalizeDirectory("src/")    // → "src"
 126    /// WorkspacePath.CanonicalizeDirectory("src//")   // → "src"
 127    /// WorkspacePath.CanonicalizeDirectory("../src")  // throws ArgumentException
 128    /// </code>
 129    /// </example>
 130    public static string CanonicalizeDirectory(string directory) =>
 47131        CanonicalizeCore(directory, allowRoot: true);
 132
 133    private static string CanonicalizeCore(string path, bool allowRoot)
 134    {
 390135        if (path is null)
 3136            throw new ArgumentNullException(nameof(path));
 137
 387138        var trimmed = path.Trim();
 387139        if (trimmed.Length == 0)
 140        {
 16141            if (allowRoot)
 10142                return string.Empty;
 6143            throw new ArgumentException(
 6144                "Workspace file path cannot be empty or whitespace.",
 6145                nameof(path));
 146        }
 147
 371148        var slashed = trimmed.Replace('\\', '/');
 149
 371150        var segments = slashed.Split('/');
 371151        var canonical = new System.Text.StringBuilder(slashed.Length);
 1838152        for (var i = 0; i < segments.Length; i++)
 153        {
 569154            var segment = segments[i];
 155
 569156            if (segment.Length == 0 || segment == ".")
 157                continue;
 158
 454159            if (segment == "..")
 160            {
 21161                throw new ArgumentException(
 21162                    $"Workspace path may not contain '..' segments (input: '{path}'). Workspace paths are rooted at the 
 21163                    nameof(path));
 164            }
 165
 433166            if (canonical.Length > 0)
 103167                canonical.Append('/');
 433168            canonical.Append(segment);
 169        }
 170
 350171        return canonical.ToString();
 172    }
 173}