agentstatedeveloper_core/index.rs
1use std::collections::HashMap;
2
3use agentstategraph::{CommitOptions, Repository};
4use agentstategraph_core::IntentCategory;
5
6use crate::engine::Engine;
7use crate::error::{AsdError, Result};
8use crate::paths;
9use crate::schema::Symbol;
10use crate::search_fts::SearchFtsDb;
11
12pub trait IndexStore {
13 fn put_symbol(&self, ref_name: &str, symbol: &Symbol, agent_id: &str) -> Result<()>;
14 fn get_symbol_by_qname(&self, ref_name: &str, qname: &str) -> Result<Option<Symbol>>;
15
16 /// Plan J t-009: cross-language qname resolution.
17 ///
18 /// When two adapters produce the same `qname` (e.g. `auth.User`
19 /// from a Python module and a Swift struct with the same fully
20 /// qualified name), the primary qname index at
21 /// `/asd/v1/index/by-qname/{qname}` holds **whichever was
22 /// written last** — every prior language's symbol at that qname
23 /// is overwritten in that secondary index. The authoritative
24 /// per-language code tree at `/asd/v1/code/{lang}/{file}/...`
25 /// still has both, but lookups by qname alone silently pick the
26 /// winner of an arbitrary write order.
27 ///
28 /// This method takes an explicit language hint. If the primary
29 /// qname-index entry's language matches the hint, return it
30 /// (fast path). Otherwise walk the `code/{hint}/` tree for a
31 /// symbol with the requested qname; return it if found, else
32 /// fall back to the primary entry (better to return *something*
33 /// matching the qname than nothing — the caller can still see
34 /// the language mismatch on the returned Symbol).
35 ///
36 /// Default impl forwards to the language-agnostic
37 /// `get_symbol_by_qname`. Adapters that can resolve polyglot
38 /// collisions override this.
39 fn get_symbol_by_qname_lang(
40 &self,
41 ref_name: &str,
42 qname: &str,
43 _language_hint: Option<&str>,
44 ) -> Result<Option<Symbol>> {
45 self.get_symbol_by_qname(ref_name, qname)
46 }
47
48 /// Read the callees list previously written for `symbol_id`. Returns an
49 /// empty Vec if no edges have been recorded.
50 fn get_callees(&self, _ref_name: &str, _symbol_id: &str) -> Result<Vec<String>> {
51 Ok(Vec::new())
52 }
53
54 /// Read the callers list previously written for `symbol_id`. Returns an
55 /// empty Vec if no edges have been recorded.
56 fn get_callers(&self, _ref_name: &str, _symbol_id: &str) -> Result<Vec<String>> {
57 Ok(Vec::new())
58 }
59}
60
61/// ASD index reader/writer.
62///
63/// Hot-path read methods (`get_symbol_by_qname`, `get_callers`, `get_callees`,
64/// `build_id_map`) hit the SQLite cache populated at `asd index` time. Git is
65/// only consulted on cache miss.
66///
67/// The FTS connection is **borrowed** from the owning `Engine` (opened once in
68/// `Engine::open_sqlite`) so no `Connection::open` occurs at store construction
69/// or on any method call.
70pub struct AsgIndexStore<'a> {
71 pub repo: &'a Repository,
72 /// Borrowed FTS connection from the owning `Engine`.
73 fts: Option<&'a SearchFtsDb>,
74}
75
76impl<'a> AsgIndexStore<'a> {
77 /// Construct without SQLite caching (tests, one-off internal calls).
78 pub fn new(repo: &'a Repository) -> Self {
79 Self { repo, fts: None }
80 }
81
82 /// Convenience: borrow the FTS connection already open in `engine`.
83 pub fn from_engine(engine: &'a Engine) -> Self {
84 Self {
85 repo: &engine.repo,
86 fts: engine.fts.as_ref(),
87 }
88 }
89
90 /// Build the full `symbol_id → Symbol` map using the borrowed FTS connection.
91 /// Falls back to the git by-qname tree walk on cache miss.
92 pub fn build_id_map(&self, engine: &Engine) -> HashMap<String, Symbol> {
93 if let Some(fts) = &self.fts {
94 if fts.symbols_cached_for(&engine.ref_name) {
95 let map = fts.build_id_map_cached(&engine.ref_name);
96 if !map.is_empty() {
97 return map;
98 }
99 }
100 }
101 // Git fallback (cold cache or first run).
102 let tree = engine
103 .repo
104 .get_tree(&engine.ref_name, "/asd/v1/index/by-qname")
105 .unwrap_or(serde_json::Value::Object(Default::default()));
106 tree.as_object()
107 .map(|m| {
108 m.values()
109 .filter_map(|v| serde_json::from_value::<Symbol>(v.clone()).ok())
110 .map(|s| (s.symbol_id.clone(), s))
111 .collect()
112 })
113 .unwrap_or_default()
114 }
115
116 /// Build both full edge maps — `(callers_of, callees_of)`, each
117 /// `symbol_id → [neighbor_id, …]` — using the borrowed FTS connection,
118 /// falling back to one git tree walk per direction on cache miss.
119 ///
120 /// The bulk analog of `get_callers`/`get_callees`: callers that touch
121 /// many nodes per request (e.g. the `/graph` BFS) use this instead of one
122 /// per-node git read. Same cache guard as the per-symbol getters
123 /// (`symbols_cached_for`), so cached and per-symbol reads always agree.
124 pub fn build_edge_maps(
125 &self,
126 engine: &Engine,
127 ) -> (HashMap<String, Vec<String>>, HashMap<String, Vec<String>>) {
128 if let Some(fts) = &self.fts {
129 if fts.symbols_cached_for(&engine.ref_name) {
130 return fts.build_edge_maps_cached(&engine.ref_name);
131 }
132 }
133 // Git fallback (cold cache or first run). Tree shape per direction:
134 // /asd/v1/index/callers/{symbol_id} = {"callers": [id, …]}
135 // /asd/v1/index/callees/{symbol_id} = {"callees": [id, …]}
136 let read_direction = |dir: &str, field: &str| -> HashMap<String, Vec<String>> {
137 let prefix = format!("{}/index/{}", paths::ASD_ROOT, dir);
138 match engine.repo.get_tree(&engine.ref_name, &prefix) {
139 Ok(serde_json::Value::Object(map)) => map
140 .into_iter()
141 .map(|(symbol_id, v)| (symbol_id, extract_string_array(&v, field)))
142 .collect(),
143 _ => HashMap::new(),
144 }
145 };
146 (
147 read_direction("callers", "callers"),
148 read_direction("callees", "callees"),
149 )
150 }
151}
152
153impl<'a> IndexStore for AsgIndexStore<'a> {
154 fn put_symbol(&self, ref_name: &str, symbol: &Symbol, agent_id: &str) -> Result<()> {
155 let code = paths::code_path(&symbol.language, &symbol.file, &symbol.symbol_fp);
156 let qname = paths::qname_index_path(&symbol.qname);
157 let value = serde_json::to_value(symbol)?;
158
159 let opts1 = CommitOptions::new(
160 agent_id,
161 IntentCategory::Refine,
162 format!("index symbol {}", symbol.qname),
163 );
164 self.repo.set_json(ref_name, &code, &value, opts1)?;
165
166 // Secondary index: duplicate symbol under qname for O(1) lookup.
167 // Content-addressing dedups the storage.
168 let opts2 = CommitOptions::new(
169 agent_id,
170 IntentCategory::Refine,
171 format!("qname index {}", symbol.qname),
172 );
173 self.repo.set_json(ref_name, &qname, &value, opts2)?;
174 Ok(())
175 }
176
177 fn get_symbol_by_qname(&self, ref_name: &str, qname: &str) -> Result<Option<Symbol>> {
178 // Fast path: reuse the open FTS connection — no extra open().
179 if let Some(fts) = &self.fts {
180 if fts.symbols_cached_for(ref_name) {
181 return Ok(fts.get_symbol_by_qname_cached(qname, ref_name));
182 }
183 }
184 // Authoritative git path.
185 let path = paths::qname_index_path(qname);
186 match self.repo.get_json(ref_name, &path) {
187 Ok(v) => Ok(Some(serde_json::from_value(v)?)),
188 Err(agentstategraph::RepoError::Tree(_)) => Ok(None),
189 Err(e) => Err(AsdError::Repo(e)),
190 }
191 }
192
193 /// Plan J t-009: cross-language qname resolution. See trait
194 /// docs. The lookup is two-step:
195 ///
196 /// 1. Try the primary qname index. If its language matches the
197 /// hint (or no hint was given), return it — no extra reads.
198 /// 2. Otherwise walk `/asd/v1/code/{lang_hint}/` for a symbol
199 /// whose qname equals `qname`. Return the first match.
200 /// 3. If the per-language tree has no match, fall back to the
201 /// primary entry. Returning the qname-index hit (even
202 /// language-mismatched) is more useful than `None` —
203 /// downstream code can inspect `Symbol.language` and decide.
204 fn get_symbol_by_qname_lang(
205 &self,
206 ref_name: &str,
207 qname: &str,
208 language_hint: Option<&str>,
209 ) -> Result<Option<Symbol>> {
210 let primary = self.get_symbol_by_qname(ref_name, qname)?;
211 let hint = match language_hint {
212 Some(h) if !h.is_empty() => h,
213 _ => return Ok(primary), // No hint → nothing to disambiguate.
214 };
215 if let Some(ref sym) = primary {
216 if sym.language == hint {
217 return Ok(primary);
218 }
219 }
220 // Walk the per-language code tree. Path shape:
221 // /asd/v1/code/{lang}/{file}/{symbol_fp}
222 // We don't know the file or fp, so list and filter.
223 let lang_root = format!("{}/code/{}", paths::ASD_ROOT, hint);
224 if let Ok(tree) = self.repo.get_tree(ref_name, &lang_root) {
225 for_each_symbol(&tree, &mut |sym_value| {
226 if let Ok(sym) = serde_json::from_value::<Symbol>(sym_value.clone()) {
227 if sym.qname == qname && sym.language == hint {
228 return Some(sym);
229 }
230 }
231 None
232 })
233 .map(|found| Ok(Some(found)))
234 .unwrap_or(Ok(primary))
235 } else {
236 Ok(primary)
237 }
238 }
239
240 fn get_callees(&self, ref_name: &str, symbol_id: &str) -> Result<Vec<String>> {
241 // Fast path: reuse open FTS connection.
242 if let Some(fts) = &self.fts {
243 if fts.symbols_cached_for(ref_name) {
244 return Ok(fts.get_neighbors_cached(symbol_id, "callee", ref_name));
245 }
246 }
247 // Authoritative git path.
248 let path = paths::callees_path(symbol_id);
249 match self.repo.get_json(ref_name, &path) {
250 Ok(v) => Ok(extract_string_array(&v, "callees")),
251 Err(_) => Ok(Vec::new()),
252 }
253 }
254
255 fn get_callers(&self, ref_name: &str, symbol_id: &str) -> Result<Vec<String>> {
256 // Fast path: reuse open FTS connection.
257 if let Some(fts) = &self.fts {
258 if fts.symbols_cached_for(ref_name) {
259 return Ok(fts.get_neighbors_cached(symbol_id, "caller", ref_name));
260 }
261 }
262 // Authoritative git path.
263 let path = paths::callers_path(symbol_id);
264 match self.repo.get_json(ref_name, &path) {
265 Ok(v) => Ok(extract_string_array(&v, "callers")),
266 Err(_) => Ok(Vec::new()),
267 }
268 }
269}
270
271/// Plan J t-009: walk a `code/{lang}/` subtree depth-first looking
272/// for a Symbol JSON leaf. Returns the first leaf for which `f`
273/// produces `Some(_)`. The tree shape under `code/{lang}/` is
274/// `{file_segment}/.../ {symbol_fp}: Symbol`, but `get_tree` flattens
275/// it into a nested object so we just recurse over Object children
276/// until we hit a leaf whose value parses as a Symbol.
277fn for_each_symbol<R>(
278 tree: &serde_json::Value,
279 f: &mut dyn FnMut(&serde_json::Value) -> Option<R>,
280) -> Option<R> {
281 // Try this node first — leaves (Symbol JSONs) are objects with
282 // the fields f's closure cares about; if it matches, return.
283 if let Some(found) = f(tree) {
284 return Some(found);
285 }
286 if let serde_json::Value::Object(map) = tree {
287 for child in map.values() {
288 if let Some(found) = for_each_symbol(child, f) {
289 return Some(found);
290 }
291 }
292 }
293 None
294}
295
296fn extract_string_array(v: &serde_json::Value, field: &str) -> Vec<String> {
297 v.get(field)
298 .and_then(|f| f.as_array())
299 .map(|arr| {
300 arr.iter()
301 .filter_map(|x| x.as_str().map(|s| s.to_string()))
302 .collect()
303 })
304 .unwrap_or_default()
305}