Forge
csharp72c0d4fb
1using HybridCodebaseIndex.Core.Indexing;
2
3namespace HybridCodebaseIndex.Core;
4
5/// <summary>Сервис гибридного индекса (слой B по ADR 0105 CascadeIDE; v0: FTS5 по тексту файлов).</summary>
6public sealed class CodebaseIndexService
7{
8 private readonly string _indexDirectoryRelative;
9
10 public CodebaseIndexService(string indexDirectoryRelative = ".hybrid-codebase-index")
11 {
12 _indexDirectoryRelative = string.IsNullOrWhiteSpace(indexDirectoryRelative)
13 ? ".hybrid-codebase-index"
14 : indexDirectoryRelative;
15 }
16
17 public string GetDatabasePath(string workspaceRoot)
18 => GetDatabasePath(workspaceRoot, solutionPath: null);
19
20 public string GetDatabasePath(string workspaceRoot, string? solutionPath)
21 {
22 var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar));
23 var indexDir = ResolveIndexDirectoryRelative(root, solutionPath);
24 return SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir);
25 }
26
27 public Task<ReindexSummary> FullReindexAsync(string workspaceRoot, CancellationToken cancellationToken = default)
28 => FullReindexAsync(workspaceRoot, solutionPath: null, reindexObservers: null, cancellationToken);
29
30 public Task<ReindexSummary> FullReindexAsync(
31 string workspaceRoot,
32 string? solutionPath,
33 CancellationToken cancellationToken = default)
34 => FullReindexAsync(workspaceRoot, solutionPath, reindexObservers: null, cancellationToken);
35
36 public Task<ReindexSummary> FullReindexAsync(
37 string workspaceRoot,
38 string? solutionPath,
39 IReadOnlyList<ICodebaseIndexReindexObserver>? reindexObservers,
40 CancellationToken cancellationToken = default)
41 {
42 var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar));
43 var indexDir = ResolveIndexDirectoryRelative(root, solutionPath);
44 var db = SqliteFtsIndex.ResolveDatabasePathForWrite(root, indexDir);
45 return SqliteFtsIndex.ReindexIncrementalAsync(root, db, reindexObservers, cancellationToken);
46 }
47
48 public Task<ReindexSummary> FullRebuildAsync(string workspaceRoot, CancellationToken cancellationToken = default)
49 => FullRebuildAsync(workspaceRoot, solutionPath: null, reindexObservers: null, cancellationToken);
50
51 public Task<ReindexSummary> FullRebuildAsync(
52 string workspaceRoot,
53 string? solutionPath,
54 CancellationToken cancellationToken = default)
55 => FullRebuildAsync(workspaceRoot, solutionPath, reindexObservers: null, cancellationToken);
56
57 public Task<ReindexSummary> FullRebuildAsync(
58 string workspaceRoot,
59 string? solutionPath,
60 IReadOnlyList<ICodebaseIndexReindexObserver>? reindexObservers,
61 CancellationToken cancellationToken = default)
62 {
63 var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar));
64 var indexDir = ResolveIndexDirectoryRelative(root, solutionPath);
65 var db = SqliteFtsIndex.ResolveDatabasePathForWrite(root, indexDir);
66 return SqliteFtsIndex.FullRebuildAsync(root, db, reindexObservers, cancellationToken);
67 }
68
69 public Task<ExplainHitResponse> ExplainHitAsync(
70 string workspaceRoot,
71 long hitId,
72 CancellationToken cancellationToken = default)
73 => ExplainHitAsync(workspaceRoot, solutionPath: null, hitId, cancellationToken);
74
75 public Task<ExplainHitResponse> ExplainHitAsync(
76 string workspaceRoot,
77 string? solutionPath,
78 long hitId,
79 CancellationToken cancellationToken = default)
80 {
81 var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar));
82 var indexDir = ResolveIndexDirectoryRelative(root, solutionPath);
83 var db = SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir);
84 return SqliteFtsIndex.ExplainHitAsync(root, db, hitId, cancellationToken);
85 }
86
87 public Task<(SearchResponse response, string? error)> SearchAsync(
88 string workspaceRoot,
89 string query,
90 int topN = 15,
91 string? pathPrefix = null,
92 IReadOnlyList<string>? excludePathPrefixes = null,
93 IReadOnlyList<string>? extensions = null,
94 CancellationToken cancellationToken = default)
95 => SearchAsync(workspaceRoot, solutionPath: null, query, topN, pathPrefix, excludePathPrefixes, extensions, cancellationToken);
96
97 public Task<(SearchResponse response, string? error)> SearchAsync(
98 string workspaceRoot,
99 string? solutionPath,
100 string query,
101 int topN = 15,
102 string? pathPrefix = null,
103 IReadOnlyList<string>? excludePathPrefixes = null,
104 IReadOnlyList<string>? extensions = null,
105 CancellationToken cancellationToken = default)
106 {
107 var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar));
108 var indexDir = ResolveIndexDirectoryRelative(root, solutionPath);
109 var db = SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir);
110 return SqliteFtsIndex.SearchAsync(root, db, query, topN, pathPrefix, excludePathPrefixes, extensions, cancellationToken);
111 }
112
113 public Task<(SearchResponse response, string? error)> SearchHybridAsync(
114 string workspaceRoot,
115 string? solutionPath,
116 string query,
117 int topN,
118 string? pathPrefix,
119 IReadOnlyList<string>? excludePathPrefixes,
120 IReadOnlyList<string>? extensions,
121 bool semantic,
122 double alpha,
123 double beta,
124 int vecTopK,
125 CancellationToken cancellationToken = default)
126 {
127 var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar));
128 var indexDir = ResolveIndexDirectoryRelative(root, solutionPath);
129 var db = SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir);
130 return SqliteFtsIndex.SearchHybridAsync(root, db, query, topN, pathPrefix, excludePathPrefixes, extensions, semantic, alpha, beta, vecTopK, cancellationToken);
131 }
132
133 public Task<IndexStatus> GetStatusAsync(string workspaceRoot, CancellationToken cancellationToken = default)
134 => GetStatusAsync(workspaceRoot, solutionPath: null, cancellationToken);
135
136 public Task<IndexStatus> GetStatusAsync(string workspaceRoot, string? solutionPath, CancellationToken cancellationToken = default)
137 {
138 var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar));
139 var indexDir = ResolveIndexDirectoryRelative(root, solutionPath);
140 var db = SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir);
141 return SqliteFtsIndex.GetStatusAsync(root, db, cancellationToken);
142 }
143
144 public Task<DocDraftResponse> DraftDocAsync(
145 string workspaceRoot,
146 string? solutionPath,
147 string title,
148 IReadOnlyList<string> changedPaths,
149 CancellationToken cancellationToken = default)
150 {
151 var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar));
152 var indexDir = ResolveIndexDirectoryRelative(root, solutionPath);
153 var db = SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir);
154 return SqliteFtsIndex.DraftDocAsync(root, db, title, changedPaths, cancellationToken);
155 }
156
157 public Task<(int vectorsUpserted, string? err)> ReindexVectorsAsync(
158 string workspaceRoot,
159 string? solutionPath,
160 CancellationToken cancellationToken = default)
161 {
162 var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar));
163 var indexDir = ResolveIndexDirectoryRelative(root, solutionPath);
164 var db = SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir);
165 return SqliteFtsIndex.ReindexVectorsAsync(root, db, cancellationToken);
166 }
167
168 private string ResolveIndexDirectoryRelative(string workspaceRootNormalized, string? solutionPath)
169 {
170 if (string.IsNullOrWhiteSpace(solutionPath))
171 return _indexDirectoryRelative;
172
173 var resolved = solutionPath.Trim();
174 if (!Path.IsPathRooted(resolved))
175 resolved = Path.Combine(workspaceRootNormalized, resolved);
176
177 try
178 {
179 resolved = Path.GetFullPath(resolved);
180 }
181 catch
182 {
183 // best-effort: fall back to raw string (still scopes DB away from root)
184 }
185
186 if (OperatingSystem.IsWindows())
187 resolved = resolved.ToLowerInvariant();
188
189 var safeName = SanitizeSegment(Path.GetFileNameWithoutExtension(resolved));
190 if (string.IsNullOrWhiteSpace(safeName))
191 safeName = "solution";
192
193 var hash12 = ComputeHash12(resolved);
194 var dir = $"{safeName}--{hash12}";
195 return Path.Combine(_indexDirectoryRelative, dir);
196 }
197
198 private static string SanitizeSegment(string? s)
199 {
200 if (string.IsNullOrWhiteSpace(s))
201 return "";
202
203 Span<char> buffer = stackalloc char[s.Length];
204 var n = 0;
205 foreach (var ch in s)
206 {
207 if (char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.')
208 buffer[n++] = ch;
209 else
210 buffer[n++] = '-';
211 }
212
213 return new string(buffer[..n]).Trim('-');
214 }
215
216 private static string ComputeHash12(string input)
217 {
218 var bytes = System.Text.Encoding.UTF8.GetBytes(input);
219 var hash = System.Security.Cryptography.SHA256.HashData(bytes);
220 return Convert.ToHexString(hash)[..12].ToLowerInvariant();
221 }
222}
223
View only · write via MCP/CIDE