< 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: 67
Uncovered lines: 0
Coverable lines: 67
Total lines: 170
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%
NormalizePath(...)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/// Paths are normalized: backslashes are replaced with forward slashes, leading slashes are trimmed,
 16/// and comparison is case-insensitive (matching Windows file system behavior by default).
 17/// </para>
 18/// </remarks>
 19[DoNotAutoRegister]
 20public sealed class InMemoryWorkspace : IWorkspace
 21{
 32222    private readonly ConcurrentDictionary<string, string> _files =
 32223        new(StringComparer.OrdinalIgnoreCase);
 24
 25    /// <inheritdoc />
 26    public WorkspaceResult<ReadFileResult> TryReadFile(string path)
 27    {
 2128        var normalized = NormalizePath(path);
 2129        return _files.TryGetValue(normalized, out var content)
 2130            ? WorkspaceResult<ReadFileResult>.Ok(new ReadFileResult(normalized, content))
 2131            : WorkspaceResult<ReadFileResult>.Fail(new FileNotFoundException($"File not found: {normalized}", normalized
 32    }
 33
 34    /// <inheritdoc />
 35    public WorkspaceResult<WriteFileResult> TryWriteFile(string path, string content)
 36    {
 12537        var normalized = NormalizePath(path);
 12538        _files[normalized] = content;
 12539        return WorkspaceResult<WriteFileResult>.Ok(new WriteFileResult(normalized, content.Length));
 40    }
 41
 42    /// <inheritdoc />
 43    public bool FileExists(string path) =>
 3944        _files.ContainsKey(NormalizePath(path));
 45
 46    /// <inheritdoc />
 47    public IEnumerable<string> GetFilePaths() =>
 348        _files.Keys;
 49
 50    /// <inheritdoc />
 51    public ReadOnlyMemory<char> ReadFileAsMemory(string path)
 52    {
 353        var result = TryReadFile(path);
 354        return result.Success
 355            ? result.Value.Content.AsMemory()
 356            : throw result.Exception!;
 57    }
 58
 59    /// <inheritdoc />
 60    public string ListDirectory(string directory, int maxDepth = 2)
 61    {
 562        var root = NormalizePath(directory).TrimEnd('/');
 563        var prefix = root.Length > 0 ? root + "/" : "";
 64
 565        var entries = _files.Keys
 1066            .Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || prefix.Length == 0)
 967            .Select(k => prefix.Length > 0 ? k[prefix.Length..] : k)
 968            .OrderBy(k => k, StringComparer.OrdinalIgnoreCase)
 569            .ToList();
 70
 571        if (entries.Count == 0)
 172            return root.Length > 0 ? root + "/" : "./";
 73
 474        var sb = new System.Text.StringBuilder();
 475        sb.AppendLine(root.Length > 0 ? root + "/" : "./");
 76
 477        var tree = BuildTree(entries);
 478        RenderTree(sb, tree, indent: "", maxDepth: maxDepth, currentDepth: 0);
 79
 480        return sb.ToString().TrimEnd();
 81    }
 82
 83    private static SortedDictionary<string, object?> BuildTree(List<string> paths)
 84    {
 485        var root = new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
 2686        foreach (var path in paths)
 87        {
 988            var parts = path.Split('/');
 989            var current = root;
 5290            for (var i = 0; i < parts.Length; i++)
 91            {
 1792                var part = parts[i];
 1793                if (i == parts.Length - 1)
 94                {
 95                    // File leaf
 996                    current.TryAdd(part, null);
 97                }
 98                else
 99                {
 100                    // Directory node
 8101                    if (!current.TryGetValue(part, out var child) || child is not SortedDictionary<string, object?> dict
 102                    {
 6103                        dict = new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
 6104                        current[part] = dict;
 105                    }
 8106                    current = dict;
 107                }
 108            }
 109        }
 4110        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    {
 8120        if (currentDepth >= maxDepth)
 2121            return;
 122
 6123        var entries = node.ToList();
 32124        for (var i = 0; i < entries.Count; i++)
 125        {
 10126            var isLast = i == entries.Count - 1;
 10127            var connector = isLast ? "└── " : "├── ";
 10128            var childIndent = indent + (isLast ? "    " : "│   ");
 129
 10130            var (name, child) = entries[i];
 10131            if (child is SortedDictionary<string, object?> dict)
 132            {
 4133                sb.AppendLine($"{indent}{connector}{name}/");
 4134                RenderTree(sb, dict, childIndent, maxDepth, currentDepth + 1);
 135            }
 136            else
 137            {
 6138                sb.AppendLine($"{indent}{connector}{name}");
 139            }
 140        }
 6141    }
 142
 143    /// <inheritdoc />
 144    public WorkspaceResult<CompareExchangeResult> TryCompareExchange(string path, string expectedContent, string newCont
 145    {
 6146        var normalized = NormalizePath(path);
 147
 6148        if (!_files.TryGetValue(normalized, out var current))
 2149            return WorkspaceResult<CompareExchangeResult>.Fail(
 2150                new FileNotFoundException($"File not found: {normalized}", normalized));
 151
 4152        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
 2156        var exchanged = _files.TryUpdate(normalized, newContent, current);
 2157        return WorkspaceResult<CompareExchangeResult>.Ok(
 2158            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) =>
 30166        _files[NormalizePath(path)] = content;
 167
 168    private static string NormalizePath(string path) =>
 226169        path.Replace('\\', '/').TrimStart('/');
 170}