| 1 | namespace AgentNotes.Core.Configuration; |
| 2 | |
| 3 | /// <summary>Корневой документ локального TOML (<c>--config</c>), schema version 1.</summary> |
| 4 | internal sealed class AgentNotesMcpConfigDocument |
| 5 | { |
| 6 | public int Version { get; set; } = 1; |
| 7 | |
| 8 | public KnowledgeSection? Knowledge { get; set; } |
| 9 | |
| 10 | public WorkspaceSection? Workspace { get; set; } |
| 11 | |
| 12 | public StatusSection? Status { get; set; } |
| 13 | |
| 14 | public LocalSettings Materialize(AgentNotesMcpConfigDocument embeddedDefaults) |
| 15 | { |
| 16 | var schemaVersion = Version > 0 ? Version : embeddedDefaults.Version; |
| 17 | if (schemaVersion != 1) |
| 18 | throw new InvalidOperationException($"Unsupported config schema version {schemaVersion}; expected 1."); |
| 19 | |
| 20 | var knowledge = Knowledge?.Resolve() |
| 21 | ?? throw new InvalidOperationException("[knowledge] is required in config."); |
| 22 | |
| 23 | var workspace = WorkspaceSection.Resolve(embeddedDefaults.Workspace, Workspace); |
| 24 | |
| 25 | var status = StatusSection.Resolve(embeddedDefaults.Status, Status); |
| 26 | |
| 27 | return new LocalSettings |
| 28 | { |
| 29 | SchemaVersion = schemaVersion, |
| 30 | PrimaryKnowledgeRoot = knowledge.PrimaryRoot, |
| 31 | KnowledgeRoots = knowledge.NamedRoots, |
| 32 | ReadOnlyKnowledgeRoots = knowledge.ReadOnlyRoots, |
| 33 | Workspace = workspace, |
| 34 | Status = status |
| 35 | }; |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | internal sealed class KnowledgeSection |
| 40 | { |
| 41 | public string? Primary { get; set; } |
| 42 | |
| 43 | public Dictionary<string, string>? Roots { get; set; } |
| 44 | |
| 45 | public List<ReadOnlyKnowledgeEntry>? ReadOnly { get; set; } |
| 46 | |
| 47 | public ResolvedKnowledge Resolve() |
| 48 | { |
| 49 | if (string.IsNullOrWhiteSpace(Primary)) |
| 50 | throw new InvalidOperationException("[knowledge].primary is required in config."); |
| 51 | |
| 52 | var namedRoots = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| 53 | if (Roots is not null) |
| 54 | { |
| 55 | foreach (var (key, path) in Roots) |
| 56 | { |
| 57 | if (string.IsNullOrWhiteSpace(path)) |
| 58 | continue; |
| 59 | namedRoots[key] = Path.GetFullPath(path.Trim()); |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | var primaryRoot = KnowledgeRootResolver.Resolve(Primary, namedRoots); |
| 64 | var readOnly = ReadOnlyKnowledgeEntry.ToRuntimeList(ReadOnly); |
| 65 | foreach (var entry in readOnly) |
| 66 | { |
| 67 | if (namedRoots.ContainsKey(entry.Id)) |
| 68 | throw new InvalidOperationException( |
| 69 | $"[[knowledge.read_only]] id '{entry.Id}' conflicts with [knowledge.roots]."); |
| 70 | } |
| 71 | |
| 72 | return new ResolvedKnowledge(primaryRoot, namedRoots, readOnly); |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | internal sealed class ReadOnlyKnowledgeEntry |
| 77 | { |
| 78 | public string? Id { get; set; } |
| 79 | |
| 80 | public string? Path { get; set; } |
| 81 | |
| 82 | internal static IReadOnlyList<ReadOnlyKnowledgeRoot> ToRuntimeList(List<ReadOnlyKnowledgeEntry>? entries) |
| 83 | { |
| 84 | if (entries is null || entries.Count == 0) |
| 85 | return []; |
| 86 | |
| 87 | var list = new List<ReadOnlyKnowledgeRoot>(entries.Count); |
| 88 | foreach (var entry in entries) |
| 89 | { |
| 90 | if (string.IsNullOrWhiteSpace(entry.Id) || string.IsNullOrWhiteSpace(entry.Path)) |
| 91 | throw new InvalidOperationException("Each [[knowledge.read_only]] entry requires id and path."); |
| 92 | list.Add(new ReadOnlyKnowledgeRoot |
| 93 | { |
| 94 | Id = entry.Id.Trim(), |
| 95 | Path = System.IO.Path.GetFullPath(entry.Path.Trim()) |
| 96 | }); |
| 97 | } |
| 98 | |
| 99 | return list; |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | internal sealed class WorkspaceSection |
| 104 | { |
| 105 | public static WorkspaceSection Example { get; } = new() |
| 106 | { |
| 107 | DefaultScope = "example", |
| 108 | ScopeMap = "example/workspace-scope-map-v1.md", |
| 109 | ScopeAliases = "example/scope-alias-map-v1.md" |
| 110 | }; |
| 111 | |
| 112 | public string? DefaultScope { get; set; } |
| 113 | |
| 114 | public string? ScopeMap { get; set; } |
| 115 | |
| 116 | public string? ScopeAliases { get; set; } |
| 117 | |
| 118 | /// <summary>Слияние: embedded defaults (нейтральный example) → секция <c>[workspace]</c> в --config.</summary> |
| 119 | public static WorkspaceSettings Resolve(WorkspaceSection? embeddedDefaults, WorkspaceSection? user) |
| 120 | { |
| 121 | var merged = user is null |
| 122 | ? embeddedDefaults ?? Example |
| 123 | : (embeddedDefaults ?? Example).Overlay(user); |
| 124 | return merged.ToRuntimeSettings(); |
| 125 | } |
| 126 | |
| 127 | private WorkspaceSection Overlay(WorkspaceSection higher) |
| 128 | { |
| 129 | return new WorkspaceSection |
| 130 | { |
| 131 | DefaultScope = higher.DefaultScope ?? DefaultScope, |
| 132 | ScopeMap = higher.ScopeMap ?? ScopeMap, |
| 133 | ScopeAliases = higher.ScopeAliases ?? ScopeAliases |
| 134 | }; |
| 135 | } |
| 136 | |
| 137 | private WorkspaceSettings ToRuntimeSettings() => |
| 138 | new() |
| 139 | { |
| 140 | DefaultScope = (DefaultScope ?? Example.DefaultScope)!.Trim(), |
| 141 | ScopeMapRelative = KnowledgeRelativePath.Normalize(ScopeMap ?? Example.ScopeMap!), |
| 142 | ScopeAliasMapRelative = KnowledgeRelativePath.Normalize(ScopeAliases ?? Example.ScopeAliases!) |
| 143 | }; |
| 144 | } |
| 145 | |
| 146 | internal sealed class StatusSection |
| 147 | { |
| 148 | public bool Enabled { get; set; } |
| 149 | |
| 150 | public int Port { get; set; } |
| 151 | |
| 152 | public string? Bind { get; set; } |
| 153 | |
| 154 | public StatusPreviewSection? Preview { get; set; } |
| 155 | |
| 156 | public static StatusSettings Resolve(StatusSection? embeddedDefaults, StatusSection? user) |
| 157 | { |
| 158 | var enabled = user?.Enabled ?? embeddedDefaults?.Enabled ?? false; |
| 159 | var port = user?.Port > 0 ? user.Port |
| 160 | : embeddedDefaults?.Port > 0 ? embeddedDefaults.Port |
| 161 | : 17341; |
| 162 | var bind = user?.Bind ?? embeddedDefaults?.Bind ?? "127.0.0.1"; |
| 163 | var previewPath = user?.Preview?.Workspace ?? embeddedDefaults?.Preview?.Workspace; |
| 164 | string? previewWorkspace = string.IsNullOrWhiteSpace(previewPath) |
| 165 | ? null |
| 166 | : Path.GetFullPath(previewPath.Trim()); |
| 167 | |
| 168 | return new StatusSettings |
| 169 | { |
| 170 | Enabled = enabled, |
| 171 | Port = port, |
| 172 | Bind = bind.Trim(), |
| 173 | PreviewWorkspace = previewWorkspace |
| 174 | }; |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | internal sealed class StatusPreviewSection |
| 179 | { |
| 180 | public string? Workspace { get; set; } |
| 181 | } |
| 182 | |
| 183 | internal readonly record struct ResolvedKnowledge( |
| 184 | string PrimaryRoot, |
| 185 | IReadOnlyDictionary<string, string> NamedRoots, |
| 186 | IReadOnlyList<ReadOnlyKnowledgeRoot> ReadOnlyRoots); |
| 187 | |
| 188 | internal static class KnowledgeRootResolver |
| 189 | { |
| 190 | internal static string Resolve(string primarySpec, IReadOnlyDictionary<string, string> namedRoots) |
| 191 | { |
| 192 | var spec = primarySpec.Trim(); |
| 193 | if (namedRoots.TryGetValue(spec, out var fromKey)) |
| 194 | return fromKey; |
| 195 | if (LooksLikeFilesystemPath(spec)) |
| 196 | return Path.GetFullPath(spec); |
| 197 | throw new InvalidOperationException( |
| 198 | $"[knowledge].primary = \"{spec}\" is not an absolute path and was not found in [knowledge.roots]."); |
| 199 | } |
| 200 | |
| 201 | private static bool LooksLikeFilesystemPath(string spec) => |
| 202 | Path.IsPathRooted(spec) |
| 203 | || spec.StartsWith("\\\\", StringComparison.Ordinal) |
| 204 | || (spec.Length >= 2 && spec[1] == ':' && char.IsAsciiLetter(spec[0])); |
| 205 | } |
| 206 | |
| 207 | internal static class KnowledgeRelativePath |
| 208 | { |
| 209 | internal static string Normalize(string? path) |
| 210 | { |
| 211 | if (string.IsNullOrWhiteSpace(path)) |
| 212 | throw new InvalidOperationException("Workspace path under knowledge/ is required."); |
| 213 | var normalized = path.Replace('\\', '/').TrimStart('/'); |
| 214 | if (normalized.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(normalized)) |
| 215 | throw new InvalidOperationException($"Workspace path must be relative under knowledge/: {path}"); |
| 216 | return normalized; |
| 217 | } |
| 218 | } |
| 219 | |