Skip to main content

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}