| 1 | using System.Text; |
| 2 | using AgentNotes.Core; |
| 3 | using CascadeIDE.Models; |
| 4 | using CascadeIDE.Services; |
| 5 | using Xunit; |
| 6 | |
| 7 | namespace CascadeIDE.Tests; |
| 8 | |
| 9 | public sealed class McpAgentNotesServiceMultiRootTests |
| 10 | { |
| 11 | [Fact] |
| 12 | public void ReadKnowledgeFile_UsesReadOnlyRoot_ByKnowledgeRootId() |
| 13 | { |
| 14 | using var primary = CreatePrimaryRoot(); |
| 15 | using var group = CreateGroupKbRoot(); |
| 16 | using var scope = InstallRuntime(primary.Path, group.Path, out var settings); |
| 17 | var svc = new McpAgentNotesService(() => settings); |
| 18 | |
| 19 | var text = svc.ReadKnowledgeFile("group/smoke-test-v1.md", knowledgeRootId: "group"); |
| 20 | Assert.Contains("group-kb smoke", text, StringComparison.Ordinal); |
| 21 | } |
| 22 | |
| 23 | [Fact] |
| 24 | public void WriteKnowledgeFile_ToReadOnlyRoot_ReturnsError() |
| 25 | { |
| 26 | using var primary = CreatePrimaryRoot(); |
| 27 | using var group = CreateGroupKbRoot(); |
| 28 | using var scope = InstallRuntime(primary.Path, group.Path, out var settings); |
| 29 | var svc = new McpAgentNotesService(() => settings); |
| 30 | |
| 31 | var result = svc.WriteKnowledgeFile("group/evil.md", "nope", knowledgePath: null, saveRevision: false, knowledgeRootId: "group"); |
| 32 | Assert.Contains("read-only", result, StringComparison.OrdinalIgnoreCase); |
| 33 | } |
| 34 | |
| 35 | private static PrimaryRoot CreatePrimaryRoot() |
| 36 | { |
| 37 | var path = Path.Combine(Path.GetTempPath(), "CascadeIdeAnTests", "primary", Guid.NewGuid().ToString("N")); |
| 38 | Directory.CreateDirectory(Path.Combine(path, "knowledge", "work", "local")); |
| 39 | File.WriteAllText(Path.Combine(path, "agent-notes.md"), "# hot\n", Encoding.UTF8); |
| 40 | File.WriteAllText(Path.Combine(path, "knowledge", "work", "local", "workspace-scope-map-v1.md"), "# map\n", Encoding.UTF8); |
| 41 | return new PrimaryRoot(path); |
| 42 | } |
| 43 | |
| 44 | private static GroupKbRoot CreateGroupKbRoot() |
| 45 | { |
| 46 | var path = Path.Combine(Path.GetTempPath(), "CascadeIdeAnTests", "group", Guid.NewGuid().ToString("N")); |
| 47 | Directory.CreateDirectory(Path.Combine(path, "knowledge", "group")); |
| 48 | File.WriteAllText( |
| 49 | Path.Combine(path, "knowledge", "group", "smoke-test-v1.md"), |
| 50 | "# Group KB smoke\n\ngroup-kb smoke test content.\n", |
| 51 | Encoding.UTF8); |
| 52 | File.WriteAllText(Path.Combine(path, "agent-notes.md"), "# Group hot (stub)\n", Encoding.UTF8); |
| 53 | return new GroupKbRoot(path); |
| 54 | } |
| 55 | |
| 56 | private static RuntimeScope InstallRuntime(string primaryPath, string groupPath, out CascadeIdeSettings settings) |
| 57 | { |
| 58 | var tomlPath = Path.Combine(Path.GetTempPath(), "CascadeIdeAnTests", $"cfg-{Guid.NewGuid():N}.toml"); |
| 59 | Directory.CreateDirectory(Path.GetDirectoryName(tomlPath)!); |
| 60 | File.WriteAllText( |
| 61 | tomlPath, |
| 62 | $""" |
| 63 | version = 1 |
| 64 | |
| 65 | [knowledge] |
| 66 | primary = "test" |
| 67 | |
| 68 | [knowledge.roots] |
| 69 | test = "{primaryPath.Replace('\\', '/')}" |
| 70 | |
| 71 | [[knowledge.read_only]] |
| 72 | id = "group" |
| 73 | path = "{groupPath.Replace('\\', '/')}" |
| 74 | |
| 75 | [workspace] |
| 76 | default_scope = "door-to-singularity" |
| 77 | scope_map = "work/local/workspace-scope-map-v1.md" |
| 78 | scope_aliases = "work/local/scope-alias-map-v1.md" |
| 79 | |
| 80 | [status] |
| 81 | enabled = false |
| 82 | port = 17341 |
| 83 | bind = "127.0.0.1" |
| 84 | """, |
| 85 | Encoding.UTF8); |
| 86 | |
| 87 | AgentNotesRuntimeLoader.Reset(); |
| 88 | settings = new CascadeIdeSettings { AgentNotes = new AgentNotesSettings { ConfigPath = tomlPath } }; |
| 89 | Assert.True(AgentNotesRuntimeLoader.EnsureInitialized(settings)); |
| 90 | return new RuntimeScope(tomlPath); |
| 91 | } |
| 92 | |
| 93 | private sealed class PrimaryRoot(string path) : IDisposable |
| 94 | { |
| 95 | internal string Path { get; } = path; |
| 96 | |
| 97 | public void Dispose() => TryDelete(Path); |
| 98 | } |
| 99 | |
| 100 | private sealed class GroupKbRoot(string path) : IDisposable |
| 101 | { |
| 102 | internal string Path { get; } = path; |
| 103 | |
| 104 | public void Dispose() => TryDelete(Path); |
| 105 | } |
| 106 | |
| 107 | private sealed class RuntimeScope(string tomlPath) : IDisposable |
| 108 | { |
| 109 | public void Dispose() |
| 110 | { |
| 111 | AgentNotesRuntimeLoader.Reset(); |
| 112 | TryDelete(tomlPath); |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | private static void TryDelete(string path) |
| 117 | { |
| 118 | try |
| 119 | { |
| 120 | if (File.Exists(path)) |
| 121 | File.Delete(path); |
| 122 | else if (Directory.Exists(path)) |
| 123 | Directory.Delete(path, recursive: true); |
| 124 | } |
| 125 | catch |
| 126 | { |
| 127 | // best-effort temp cleanup |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | |