< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Workspace.InMemoryWorkspace
Assembly: NexusLabs.Needlr.AgentFramework
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework/Workspace/InMemoryWorkspace.cs
Line coverage
100%
Covered lines: 66
Uncovered lines: 0
Coverable lines: 66
Total lines: 168
Line coverage: 100%
Branch coverage
95%
Covered branches: 40
Total branches: 42
Branch coverage: 95.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%
TryReadFile(...)100%22100%
TryWriteFile(...)100%11100%
FileExists(...)100%11100%
GetFilePaths()100%11100%
ReadFileAsMemory(...)100%22100%
ListDirectory(...)91.66%1212100%
BuildTree(...)100%1010100%
RenderTree(...)100%1010100%
TryCompareExchange(...)83.33%66100%
SeedFile(...)100%11100%

File(s)

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

#LineLine coverage
 1using System.Collections.Concurrent;
 2
 3namespace NexusLabs.Needlr.AgentFramework.Workspace;
 4
 5/// <summary>
 6/// Thread-safe in-memory <see cref="IWorkspace"/> backed by a <see cref="ConcurrentDictionary{TKey, TValue}"/>.
 7/// Suitable for testing, sandboxed agent runs, and scenarios where persistence is not needed.
 8/// </summary>
 9/// <remarks>
 10/// <para>
 11/// Marked <see cref="DoNotAutoRegisterAttribute"/> to prevent Needlr from registering it as a
 12/// singleton — workspaces have per-orchestration lifecycle and must be explicitly constructed.
 13/// </para>
 14/// <para>
 15/// Path canonicalization is delegated to <see cref="WorkspacePath.Canonicalize"/> and
 16/// <see cref="WorkspacePath.CanonicalizeDirectory"/>; path equality uses
 17/// <see cref="WorkspacePath.PathComparer"/>. See the <see cref="IWorkspace"/> remarks
 18/// and <see cref="WorkspacePath"/> for the full contract.
 19/// </para>
 20/// </remarks>
 21[DoNotAutoRegister]
 22public sealed class InMemoryWorkspace : IWorkspace
 23{
 39224    private readonly ConcurrentDictionary<string, string> _files =
 39225        new(WorkspacePath.PathComparer);
 26
 27    /// <inheritdoc />
 28    public WorkspaceResult<ReadFileResult> TryReadFile(string path)
 29    {
 2930        var normalized = WorkspacePath.Canonicalize(path);
 2531        return _files.TryGetValue(normalized, out var content)
 2532            ? WorkspaceResult<ReadFileResult>.Ok(new ReadFileResult(normalized, content))
 2533            : WorkspaceResult<ReadFileResult>.Fail(new FileNotFoundException($"File not found: {normalized}", normalized
 34    }
 35
 36    /// <inheritdoc />
 37    public WorkspaceResult<WriteFileResult> TryWriteFile(string path, string content)
 38    {
 14639        var normalized = WorkspacePath.Canonicalize(path);
 14040        _files[normalized] = content;
 14041        return WorkspaceResult<WriteFileResult>.Ok(new WriteFileResult(normalized, content.Length));
 42    }
 43
 44    /// <inheritdoc />
 45    public bool FileExists(string path) =>
 4246        _files.ContainsKey(WorkspacePath.Canonicalize(path));
 47
 48    /// <inheritdoc />
 49    public IEnumerable<string> GetFilePaths() =>
 650        _files.Keys;
 51
 52    /// <inheritdoc />
 53    public ReadOnlyMemory<char> ReadFileAsMemory(string path)
 54    {
 455        var result = TryReadFile(path);
 356        return result.Success
 357            ? result.Value.Content.AsMemory()
 358            : throw result.Exception!;
 59    }
 60
 61    /// <inheritdoc />
 62    public string ListDirectory(string directory, int maxDepth = 2)
 63    {
 1964        var root = WorkspacePath.CanonicalizeDirectory(directory);
 1865        var prefix = root.Length > 0 ? root + "/" : "";
 66
 1867        var entries = _files.Keys
 4268            .Where(k => prefix.Length == 0 || k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
 3569            .Select(k => prefix.Length > 0 ? k[prefix.Length..] : k)
 3570            .OrderBy(k => k, WorkspacePath.PathComparer)
 1871            .ToList();
 72
 1873        if (entries.Count == 0)
 174            return root.Length > 0 ? root + "/" : "./";
 75
 1776        var sb = new System.Text.StringBuilder();
 1777        sb.AppendLine(root.Length > 0 ? root + "/" : "./");
 78
 1779        var tree = BuildTree(entries);
 1780        RenderTree(sb, tree, indent: "", maxDepth: maxDepth, currentDepth: 0);
 81
 1782        return sb.ToString().TrimEnd();
 83    }
 84
 85    private static SortedDictionary<string, object?> BuildTree(List<string> paths)
 86    {
 1787        var root = new SortedDictionary<string, object?>(WorkspacePath.PathComparer);
 10488        foreach (var path in paths)
 89        {
 3590            var parts = path.Split('/');
 3591            var current = root;
 16892            for (var i = 0; i < parts.Length; i++)
 93            {
 4994                var part = parts[i];
 4995                if (i == parts.Length - 1)
 96                {
 3597                    current.TryAdd(part, null);
 98                }
 99                else
 100                {
 14101                    if (!current.TryGetValue(part, out var child) || child is not SortedDictionary<string, object?> dict
 102                    {
 12103                        dict = new SortedDictionary<string, object?>(WorkspacePath.PathComparer);
 12104                        current[part] = dict;
 105                    }
 14106                    current = dict;
 107                }
 108            }
 109        }
 17110        return root;
 111    }
 112
 113    private static void RenderTree(
 114        System.Text.StringBuilder sb,
 115        SortedDictionary<string, object?> node,
 116        string indent,
 117        int maxDepth,
 118        int currentDepth)
 119    {
 27120        if (currentDepth >= maxDepth)
 2121            return;
 122
 25123        var entries = node.ToList();
 134124        for (var i = 0; i < entries.Count; i++)
 125        {
 42126            var isLast = i == entries.Count - 1;
 42127            var connector = isLast ? "└── " : "├── ";
 42128            var childIndent = indent + (isLast ? "    " : "│   ");
 129
 42130            var (name, child) = entries[i];
 42131            if (child is SortedDictionary<string, object?> dict)
 132            {
 10133                sb.AppendLine($"{indent}{connector}{name}/");
 10134                RenderTree(sb, dict, childIndent, maxDepth, currentDepth + 1);
 135            }
 136            else
 137            {
 32138                sb.AppendLine($"{indent}{connector}{name}");
 139            }
 140        }
 25141    }
 142
 143    /// <inheritdoc />
 144    public WorkspaceResult<CompareExchangeResult> TryCompareExchange(string path, string expectedContent, string newCont
 145    {
 8146        var normalized = WorkspacePath.Canonicalize(path);
 147
 7148        if (!_files.TryGetValue(normalized, out var current))
 2149            return WorkspaceResult<CompareExchangeResult>.Fail(
 2150                new FileNotFoundException($"File not found: {normalized}", normalized));
 151
 5152        if (!string.Equals(current, expectedContent, StringComparison.Ordinal))
 2153            return WorkspaceResult<CompareExchangeResult>.Ok(
 2154                new CompareExchangeResult(false, "Content mismatch — file was modified since last read."));
 155
 3156        var exchanged = _files.TryUpdate(normalized, newContent, current);
 3157        return WorkspaceResult<CompareExchangeResult>.Ok(
 3158            new CompareExchangeResult(exchanged, exchanged ? null : "Concurrent modification during exchange."));
 159    }
 160
 161    /// <summary>
 162    /// Seeds the workspace with a file before agent execution begins.
 163    /// Convenience method for test setup and scenario harnesses.
 164    /// </summary>
 165    public void SeedFile(string path, string content) =>
 65166        _files[WorkspacePath.Canonicalize(path)] = content;
 167}
 168