Skip to main content

impactsense_parser/store/
in_memory.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::Path;
3
4use crate::compress::{
5    compressor_language_from_ir_string, CompressorClient, CompressorConfig,
6};
7use crate::graph::{
8    class_body_spans_for_file, function_body_spans_for_file, property_body_spans_for_file,
9};
10use crate::scanner::FileScanConfig;
11use crate::scanner_incremental::scan_and_parse_incremental_vector;
12use crate::ir::{
13    api_endpoint_key, external_api_key, module_key, ApiEndpointIr, BehaviourIr, CallbackIr,
14    ClassIr, EdgeIr, EdgeKind, ExternalApiIr, FileIr, FunctionIr, ModuleIr, PropertyIr, ProjectIr,
15};
16use crate::schema::NodeLabel;
17
18/// Reference to a symbol in the graph.
19#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20pub struct SymbolRef {
21    pub label: String,
22    pub key: String,
23    pub name: Option<String>,
24    pub path: Option<String>,
25}
26
27/// Symbols declared in a single file.
28#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
29pub struct FileSymbols {
30    pub path: String,
31    pub classes: Vec<SymbolRef>,
32    pub functions: Vec<SymbolRef>,
33    pub modules: Vec<SymbolRef>,
34    pub api_endpoints: Vec<SymbolRef>,
35}
36
37/// Bounded impact analysis result.
38#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
39pub struct ImpactReport {
40    pub symbol: String,
41    pub depth: u32,
42    pub callers: Vec<String>,
43    pub affected_files: Vec<String>,
44    pub truncated: bool,
45}
46
47#[derive(Debug, Clone, Copy)]
48pub struct QueryLimits {
49    pub max_depth: u32,
50    pub max_results: usize,
51}
52
53impl Default for QueryLimits {
54    fn default() -> Self {
55        Self {
56            max_depth: 2,
57            max_results: 50,
58        }
59    }
60}
61
62/// How [`ExplainSymbolResult::source`] was obtained.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ExplainSourceOrigin {
66    Decompressed,
67    FileSpan,
68    Unavailable,
69}
70
71#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
72pub struct ExplainOptions {
73    #[serde(default)]
74    pub include_callers: bool,
75    #[serde(default)]
76    pub include_callees: bool,
77}
78
79#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
80pub struct ExplainSymbolResult {
81    pub symbol: SymbolRef,
82    pub source: Option<String>,
83    pub source_origin: ExplainSourceOrigin,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub error: Option<String>,
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub callers: Vec<SymbolRef>,
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub callees: Vec<SymbolRef>,
90}
91
92#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
93pub struct RefreshReport {
94    pub cleanup_targets: usize,
95    pub parse_targets: usize,
96    pub nodes_merged: usize,
97    pub edges_merged: usize,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101enum NodeKind {
102    File,
103    Module,
104    Class,
105    Property,
106    Function,
107    Behaviour,
108    Callback,
109    ApiEndpoint,
110    ExternalApi,
111}
112
113#[derive(Debug, Clone)]
114struct NodeRecord {
115    kind: NodeKind,
116    key: String,
117    label: String,
118    name: Option<String>,
119    path: Option<String>,
120    language: Option<String>,
121    code_bytes: Option<Vec<u8>>,
122}
123
124type AdjKey = (usize, EdgeKind);
125
126/// In-memory dependency graph with indexed lookups for IDE/MCP queries.
127#[derive(Debug, Default)]
128pub struct InMemoryGraph {
129    nodes: Vec<NodeRecord>,
130    nodes_by_key: HashMap<(NodeKind, String), usize>,
131    symbols_by_path: HashMap<String, HashSet<usize>>,
132    by_simple_name: HashMap<String, Vec<usize>>,
133    outgoing: HashMap<AdjKey, Vec<usize>>,
134    incoming: HashMap<AdjKey, Vec<usize>>,
135}
136
137pub trait GraphStore {
138    fn callers(&self, fqn: &str) -> Vec<SymbolRef>;
139    fn callees(&self, fqn: &str) -> Vec<SymbolRef>;
140    fn file_dependencies(&self, path: &str) -> Vec<String>;
141    fn impact(&self, fqn: &str, limits: QueryLimits) -> ImpactReport;
142    fn symbols_in_file(&self, path: &str) -> FileSymbols;
143    fn find_symbol(&self, query: &str) -> Vec<SymbolRef>;
144    fn node_count(&self) -> usize;
145    fn edge_count(&self) -> usize;
146}
147
148impl InMemoryGraph {
149    pub fn from_ir(ir: ProjectIr) -> Self {
150        let mut g = Self::default();
151        g.merge_ir(ir);
152        g
153    }
154
155    pub fn merge_ir(&mut self, delta: ProjectIr) {
156        for f in delta.files {
157            self.upsert_file(f);
158        }
159        for m in delta.modules {
160            self.upsert_module(m);
161        }
162        for c in delta.classes {
163            self.upsert_class(c);
164        }
165        for p in delta.properties {
166            self.upsert_property(p);
167        }
168        for f in delta.functions {
169            self.upsert_function(f);
170        }
171        for b in delta.behaviours {
172            self.upsert_behaviour(b);
173        }
174        for c in delta.callbacks {
175            self.upsert_callback(c);
176        }
177        for a in delta.api_endpoints {
178            self.upsert_api_endpoint(a);
179        }
180        for e in delta.external_apis {
181            self.upsert_external_api(e);
182        }
183        for edge in delta.edges {
184            self.add_edge(edge);
185        }
186    }
187
188    pub fn remove_file(&mut self, path: &str) {
189        let normalized = path.replace('\\', "/");
190        let to_remove: Vec<usize> = self
191            .nodes
192            .iter()
193            .enumerate()
194            .filter(|(_, n)| n.path.as_deref() == Some(normalized.as_str()))
195            .map(|(i, _)| i)
196            .collect();
197
198        if to_remove.is_empty() {
199            return;
200        }
201
202        let remove_set: HashSet<usize> = to_remove.into_iter().collect();
203        self.remove_nodes(&remove_set);
204    }
205
206    fn remove_nodes(&mut self, remove_set: &HashSet<usize>) {
207        let mut remap: HashMap<usize, Option<usize>> = HashMap::new();
208        let mut new_nodes: Vec<NodeRecord> = Vec::new();
209
210        for (old_id, node) in self.nodes.iter().enumerate() {
211            if remove_set.contains(&old_id) {
212                remap.insert(old_id, None);
213                self.nodes_by_key.remove(&(node.kind, node.key.clone()));
214                if let Some(p) = &node.path {
215                    if let Some(set) = self.symbols_by_path.get_mut(p) {
216                        set.remove(&old_id);
217                    }
218                }
219                if let Some(name) = &node.name {
220                    if let Some(ids) = self.by_simple_name.get_mut(name) {
221                        ids.retain(|id| *id != old_id);
222                    }
223                }
224            } else {
225                let new_id = new_nodes.len();
226                remap.insert(old_id, Some(new_id));
227                new_nodes.push(node.clone());
228            }
229        }
230
231        self.nodes = new_nodes;
232        self.rebuild_adjacency(&remap);
233        self.rebuild_indexes();
234    }
235
236    fn rebuild_adjacency(&mut self, remap: &HashMap<usize, Option<usize>>) {
237        let mut new_out: HashMap<AdjKey, Vec<usize>> = HashMap::new();
238        let mut new_in: HashMap<AdjKey, Vec<usize>> = HashMap::new();
239
240        for ((from, kind), targets) in &self.outgoing {
241            let Some(&Some(new_from)) = remap.get(from) else {
242                continue;
243            };
244            for to in targets {
245                if let Some(Some(new_to)) = remap.get(to) {
246                    new_out.entry((new_from, *kind)).or_default().push(*new_to);
247                    new_in.entry((*new_to, *kind)).or_default().push(new_from);
248                }
249            }
250        }
251        self.outgoing = new_out;
252        self.incoming = new_in;
253    }
254
255    fn rebuild_indexes(&mut self) {
256        self.nodes_by_key.clear();
257        self.symbols_by_path.clear();
258        self.by_simple_name.clear();
259        for (id, node) in self.nodes.iter().enumerate() {
260            self.nodes_by_key.insert((node.kind, node.key.clone()), id);
261            if let Some(p) = &node.path {
262                self.symbols_by_path.entry(p.clone()).or_default().insert(id);
263            }
264            if let Some(name) = &node.name {
265                self.by_simple_name.entry(name.clone()).or_default().push(id);
266            }
267        }
268    }
269
270    fn upsert_file(&mut self, f: FileIr) {
271        let _id = self.ensure_node(
272            NodeKind::File,
273            f.path.clone(),
274            NodeLabel::File.to_string(),
275            None,
276            Some(f.path),
277        );
278    }
279
280    fn upsert_module(&mut self, m: ModuleIr) {
281        let key = module_key(&m.name, &m.path);
282        let id = self.ensure_node(
283            NodeKind::Module,
284            key,
285            NodeLabel::Module.to_string(),
286            Some(m.name),
287            Some(m.path),
288        );
289        self.apply_symbol_metadata(id, Some(&m.language), m.code_bytes);
290    }
291
292    fn upsert_class(&mut self, c: ClassIr) {
293        let id = self.ensure_node(
294            NodeKind::Class,
295            c.fqn.clone(),
296            "Class".to_string(),
297            Some(c.name),
298            Some(c.path),
299        );
300        self.apply_symbol_metadata(id, Some(&c.language), c.code_bytes);
301    }
302
303    fn upsert_property(&mut self, p: PropertyIr) {
304        let id = self.ensure_node(
305            NodeKind::Property,
306            p.fqn.clone(),
307            "Property".to_string(),
308            Some(p.name),
309            Some(p.path),
310        );
311        self.apply_symbol_metadata(id, Some(&p.language), p.code_bytes);
312    }
313
314    fn upsert_function(&mut self, f: FunctionIr) {
315        let id = self.ensure_node(
316            NodeKind::Function,
317            f.fqn.clone(),
318            NodeLabel::Function.to_string(),
319            Some(f.name),
320            Some(f.path),
321        );
322        self.apply_symbol_metadata(id, Some(&f.language), f.code_bytes);
323    }
324
325    fn apply_symbol_metadata(
326        &mut self,
327        id: usize,
328        language: Option<&str>,
329        code_bytes: Option<Vec<u8>>,
330    ) {
331        let Some(n) = self.nodes.get_mut(id) else {
332            return;
333        };
334        if let Some(lang) = language {
335            n.language = Some(lang.to_string());
336        }
337        if let Some(bytes) = code_bytes {
338            n.code_bytes = Some(bytes);
339        }
340    }
341
342    fn upsert_behaviour(&mut self, b: BehaviourIr) {
343        let _id = self.ensure_node(
344            NodeKind::Behaviour,
345            b.name.clone(),
346            NodeLabel::Behaviour.to_string(),
347            Some(b.name.clone()),
348            b.path,
349        );
350    }
351
352    fn upsert_callback(&mut self, c: CallbackIr) {
353        let _id = self.ensure_node(
354            NodeKind::Callback,
355            c.fqn.clone(),
356            NodeLabel::Callback.to_string(),
357            Some(c.name),
358            None,
359        );
360    }
361
362    fn upsert_api_endpoint(&mut self, a: ApiEndpointIr) {
363        let key = api_endpoint_key(&a.methods, &a.path);
364        let _id = self.ensure_node(
365            NodeKind::ApiEndpoint,
366            key,
367            NodeLabel::ApiEndpoint.to_string(),
368            Some(a.path.clone()),
369            None,
370        );
371    }
372
373    fn upsert_external_api(&mut self, e: ExternalApiIr) {
374        let key = if let (Some(base), Some(norm)) = (&e.base_url, &e.norm_path) {
375            external_api_key(base, norm)
376        } else {
377            e.name.clone()
378        };
379        let _id = self.ensure_node(
380            NodeKind::ExternalApi,
381            key,
382            NodeLabel::ExternalApi.to_string(),
383            Some(e.name),
384            None,
385        );
386    }
387
388    fn ensure_node(
389        &mut self,
390        kind: NodeKind,
391        key: String,
392        label: String,
393        name: Option<String>,
394        path: Option<String>,
395    ) -> usize {
396        if let Some(&id) = self.nodes_by_key.get(&(kind, key.clone())) {
397            if let Some(n) = self.nodes.get_mut(id) {
398                if name.is_some() {
399                    n.name = name.clone();
400                }
401                if let Some(new_path) = path.clone() {
402                    if n.path.as_deref() != Some(new_path.as_str()) {
403                        if let Some(old_path) = n.path.take() {
404                            if let Some(set) = self.symbols_by_path.get_mut(&old_path) {
405                                set.remove(&id);
406                            }
407                        }
408                        n.path = Some(new_path.clone());
409                        self.symbols_by_path
410                            .entry(new_path)
411                            .or_default()
412                            .insert(id);
413                    }
414                }
415            }
416            return id;
417        }
418        let id = self.nodes.len();
419        let record = NodeRecord {
420            kind,
421            key: key.clone(),
422            label,
423            name: name.clone(),
424            path: path.clone(),
425            language: None,
426            code_bytes: None,
427        };
428        self.nodes.push(record);
429        self.nodes_by_key.insert((kind, key), id);
430        if let Some(p) = path {
431            self.symbols_by_path.entry(p).or_default().insert(id);
432        }
433        if let Some(n) = name {
434            self.by_simple_name.entry(n).or_default().push(id);
435        }
436        id
437    }
438
439    fn add_edge(&mut self, edge: EdgeIr) {
440        let Some(from_id) = self.lookup_id(&edge.from_label, &edge.from_key) else {
441            return;
442        };
443        let Some(to_id) = self.lookup_id(&edge.to_label, &edge.to_key) else {
444            return;
445        };
446        let out_list = self.outgoing.entry((from_id, edge.kind)).or_default();
447        if !out_list.contains(&to_id) {
448            out_list.push(to_id);
449        }
450        let in_list = self.incoming.entry((to_id, edge.kind)).or_default();
451        if !in_list.contains(&from_id) {
452            in_list.push(from_id);
453        }
454    }
455
456    fn lookup_id(&self, label: &str, key: &str) -> Option<usize> {
457        let kind = node_kind_from_label(label)?;
458        self.nodes_by_key.get(&(kind, key.to_string())).copied()
459    }
460
461    fn function_id(&self, fqn: &str) -> Option<usize> {
462        if let Some(&id) = self
463            .nodes_by_key
464            .get(&(NodeKind::Function, fqn.to_string()))
465        {
466            return Some(id);
467        }
468        let suffix = format!("::{fqn}");
469        let mut suffix_matches = self
470            .nodes
471            .iter()
472            .enumerate()
473            .filter(|(_, n)| n.kind == NodeKind::Function && n.key.ends_with(&suffix));
474        if let Some((id, _)) = suffix_matches.next() {
475            if suffix_matches.next().is_none() {
476                return Some(id);
477            }
478            return None;
479        }
480        if let Some(ids) = self.by_simple_name.get(fqn) {
481            let fn_ids: Vec<usize> = ids
482                .iter()
483                .copied()
484                .filter(|id| self.nodes.get(*id).is_some_and(|n| n.kind == NodeKind::Function))
485                .collect();
486            if fn_ids.len() == 1 {
487                return Some(fn_ids[0]);
488            }
489        }
490        None
491    }
492
493    fn node_to_symbol(&self, id: usize) -> Option<SymbolRef> {
494        self.nodes.get(id).map(|n| SymbolRef {
495            label: n.label.clone(),
496            key: n.key.clone(),
497            name: n.name.clone(),
498            path: n.path.clone(),
499        })
500    }
501
502    #[cfg(test)]
503    fn code_bytes_for_symbol(&self, query: &str) -> Option<Vec<u8>> {
504        let id = self.resolve_symbol(query).ok()?;
505        self.nodes[id].code_bytes.clone()
506    }
507
508    #[cfg(test)]
509    fn language_for_symbol(&self, query: &str) -> Option<String> {
510        let id = self.resolve_symbol(query).ok()?;
511        self.nodes[id].language.clone()
512    }
513
514    /// Resolve a symbol query to a single graph node id, if unambiguous.
515    fn resolve_symbol(&self, query: &str) -> Result<usize, String> {
516        let q = query.trim();
517        if q.is_empty() {
518            return Err("empty symbol query".into());
519        }
520
521        for kind in [
522            NodeKind::Function,
523            NodeKind::Class,
524            NodeKind::Property,
525            NodeKind::Module,
526        ] {
527            if let Some(&id) = self.nodes_by_key.get(&(kind, q.to_string())) {
528                return Ok(id);
529            }
530        }
531
532        if let Some(id) = self.function_id(q) {
533            return Ok(id);
534        }
535
536        let suffix = format!("::{q}");
537        let mut class_matches = self
538            .nodes
539            .iter()
540            .enumerate()
541            .filter(|(_, n)| n.kind == NodeKind::Class && n.key.ends_with(&suffix));
542        if let Some((id, _)) = class_matches.next() {
543            if class_matches.next().is_none() {
544                return Ok(id);
545            }
546            return Err(format!("ambiguous class match for `{q}`"));
547        }
548
549        if let Some(ids) = self.by_simple_name.get(q) {
550            let symbol_ids: Vec<usize> = ids
551                .iter()
552                .copied()
553                .filter(|id| {
554                    self.nodes.get(*id).is_some_and(|n| {
555                        matches!(
556                            n.kind,
557                            NodeKind::Function | NodeKind::Class | NodeKind::Property
558                        )
559                    })
560                })
561                .collect();
562            if symbol_ids.len() == 1 {
563                return Ok(symbol_ids[0]);
564            }
565            if symbol_ids.len() > 1 {
566                return Err(format!("ambiguous symbol match for `{q}`"));
567            }
568        }
569
570        if q.contains('@') {
571            if let Some(&id) = self.nodes_by_key.get(&(NodeKind::Module, q.to_string())) {
572                return Ok(id);
573            }
574        }
575
576        Err(format!("symbol not found: `{q}`"))
577    }
578
579    fn slice_source_span(source: &str, span: (usize, usize)) -> Option<String> {
580        let (lo, hi) = span;
581        let lo = lo.min(source.len());
582        let hi = hi.min(source.len());
583        if lo >= hi {
584            return None;
585        }
586        Some(source[lo..hi].to_string())
587    }
588
589    fn span_for_node_in_file(
590        node: &NodeRecord,
591        file: &crate::scanner::ParsedFile,
592        source: &str,
593    ) -> Option<(usize, usize)> {
594        let path = node.path.as_deref()?;
595        match node.kind {
596            NodeKind::Function => {
597                let spans = function_body_spans_for_file(file, path, source);
598                spans.get(&node.key).copied()
599            }
600            NodeKind::Class => {
601                let spans = class_body_spans_for_file(file, source);
602                spans.get(&node.key).copied()
603            }
604            NodeKind::Property => {
605                let spans = property_body_spans_for_file(file, source);
606                spans.get(&node.key).copied()
607            }
608            NodeKind::Module => Some((0, source.len())),
609            _ => None,
610        }
611    }
612
613    async fn source_from_file_span(
614        &self,
615        node: &NodeRecord,
616        root: &Path,
617    ) -> Result<String, String> {
618        let rel_path = node
619            .path
620            .as_deref()
621            .ok_or_else(|| "symbol has no file path".to_string())?;
622        let full_path = root.join(rel_path);
623        let source = std::fs::read_to_string(&full_path)
624            .map_err(|e| format!("failed to read {}: {e}", full_path.display()))?;
625
626        let config = FileScanConfig::new(root);
627        let paths = vec![std::path::PathBuf::from(rel_path)];
628        let files = scan_and_parse_incremental_vector(&config, &paths)
629            .map_err(|e| format!("failed to parse {rel_path}: {e}"))?;
630        let file = files
631            .first()
632            .ok_or_else(|| format!("no parse result for {rel_path}"))?;
633
634        let span = Self::span_for_node_in_file(node, file, &source).ok_or_else(|| {
635            format!(
636                "no AST span for {} `{}` in {rel_path}",
637                node.label, node.key
638            )
639        })?;
640        Self::slice_source_span(&source, span)
641            .ok_or_else(|| format!("empty source span for `{}`", node.key))
642    }
643
644    /// Return decompressed or file-sliced implementation source for a symbol.
645    pub async fn explain_symbol_logic(
646        &self,
647        fqn: &str,
648        root: &Path,
649        compressor: &CompressorConfig,
650        opts: ExplainOptions,
651    ) -> ExplainSymbolResult {
652        let empty_symbol = SymbolRef {
653            label: String::new(),
654            key: fqn.to_string(),
655            name: None,
656            path: None,
657        };
658
659        let node_id = match self.resolve_symbol(fqn) {
660            Ok(id) => id,
661            Err(e) => {
662                return ExplainSymbolResult {
663                    symbol: empty_symbol,
664                    source: None,
665                    source_origin: ExplainSourceOrigin::Unavailable,
666                    error: Some(e),
667                    callers: Vec::new(),
668                    callees: Vec::new(),
669                };
670            }
671        };
672
673        let node = &self.nodes[node_id];
674        let symbol = self.node_to_symbol(node_id).unwrap_or(empty_symbol);
675
676        let mut result = ExplainSymbolResult {
677            symbol,
678            source: None,
679            source_origin: ExplainSourceOrigin::Unavailable,
680            error: None,
681            callers: Vec::new(),
682            callees: Vec::new(),
683        };
684
685        if node.kind == NodeKind::Function {
686            if opts.include_callers {
687                result.callers = self.callers(&node.key);
688            }
689            if opts.include_callees {
690                result.callees = self.callees(&node.key);
691            }
692        }
693
694        if let (Some(blob), Some(lang_str)) = (&node.code_bytes, &node.language) {
695            if compressor.enabled {
696                if let Some(api_lang) = compressor_language_from_ir_string(lang_str) {
697                    if let Ok(client) = CompressorClient::from_config(compressor) {
698                        match client.decompress_code(blob, api_lang).await {
699                            Ok(code) => {
700                                result.source = Some(code);
701                                result.source_origin = ExplainSourceOrigin::Decompressed;
702                                return result;
703                            }
704                            Err(e) => {
705                                result.error = Some(format!("decompress failed: {e}"));
706                            }
707                        }
708                    }
709                }
710            }
711        }
712
713        match self.source_from_file_span(node, root).await {
714            Ok(source) => {
715                result.source = Some(source);
716                result.source_origin = ExplainSourceOrigin::FileSpan;
717                result.error = None;
718            }
719            Err(e) => {
720                if result.error.is_none() {
721                    result.error = Some(e);
722                } else {
723                    result.error = Some(format!("{}; {}", result.error.unwrap(), e));
724                }
725            }
726        }
727
728        result
729    }
730}
731
732impl GraphStore for InMemoryGraph {
733    fn callers(&self, fqn: &str) -> Vec<SymbolRef> {
734        let Some(fn_id) = self.function_id(fqn) else {
735            return Vec::new();
736        };
737        self.incoming
738            .get(&(fn_id, EdgeKind::CallsFunction))
739            .map(|ids| {
740                ids.iter()
741                    .filter_map(|id| self.node_to_symbol(*id))
742                    .collect()
743            })
744            .unwrap_or_default()
745    }
746
747    fn callees(&self, fqn: &str) -> Vec<SymbolRef> {
748        let Some(fn_id) = self.function_id(fqn) else {
749            return Vec::new();
750        };
751        self.outgoing
752            .get(&(fn_id, EdgeKind::CallsFunction))
753            .map(|ids| {
754                ids.iter()
755                    .filter_map(|id| self.node_to_symbol(*id))
756                    .collect()
757            })
758            .unwrap_or_default()
759    }
760
761    fn file_dependencies(&self, path: &str) -> Vec<String> {
762        let normalized = path.replace('\\', "/");
763        let Some(&file_id) = self.nodes_by_key.get(&(NodeKind::File, normalized.clone())) else {
764            return Vec::new();
765        };
766        self.outgoing
767            .get(&(file_id, EdgeKind::DependsOnFile))
768            .map(|ids| {
769                ids.iter()
770                    .filter_map(|id| self.nodes.get(*id).and_then(|n| n.path.clone()))
771                    .collect()
772            })
773            .unwrap_or_default()
774    }
775
776    fn impact(&self, fqn: &str, limits: QueryLimits) -> ImpactReport {
777        let mut report = ImpactReport {
778            symbol: fqn.to_string(),
779            depth: limits.max_depth,
780            ..Default::default()
781        };
782        let Some(start) = self.function_id(fqn) else {
783            return report;
784        };
785
786        let mut visited: HashSet<usize> = HashSet::new();
787        let mut queue: VecDeque<(usize, u32)> = VecDeque::new();
788        queue.push_back((start, 0));
789        visited.insert(start);
790
791        while let Some((node_id, depth)) = queue.pop_front() {
792            if depth >= limits.max_depth {
793                continue;
794            }
795            if let Some(callers) = self.incoming.get(&(node_id, EdgeKind::CallsFunction)) {
796                for caller_id in callers {
797                    if visited.contains(caller_id) {
798                        continue;
799                    }
800                    if report.callers.len() >= limits.max_results {
801                        report.truncated = true;
802                        return report;
803                    }
804                    visited.insert(*caller_id);
805                    if let Some(sym) = self.node_to_symbol(*caller_id) {
806                        if sym.label == NodeLabel::Function.to_string() {
807                            report.callers.push(sym.key.clone());
808                        }
809                        if let Some(p) = sym.path {
810                            if !report.affected_files.contains(&p) {
811                                report.affected_files.push(p);
812                            }
813                        }
814                    }
815                    queue.push_back((*caller_id, depth + 1));
816                }
817            }
818        }
819        report
820    }
821
822    fn symbols_in_file(&self, path: &str) -> FileSymbols {
823        let normalized = path.replace('\\', "/");
824        let mut out = FileSymbols {
825            path: normalized.clone(),
826            ..Default::default()
827        };
828        let Some(ids) = self.symbols_by_path.get(&normalized) else {
829            return out;
830        };
831        for id in ids {
832            let Some(n) = self.nodes.get(*id) else {
833                continue;
834            };
835            let sym = SymbolRef {
836                label: n.label.clone(),
837                key: n.key.clone(),
838                name: n.name.clone(),
839                path: n.path.clone(),
840            };
841            match n.kind {
842                NodeKind::Class => out.classes.push(sym),
843                NodeKind::Function => out.functions.push(sym),
844                NodeKind::Module => out.modules.push(sym),
845                NodeKind::ApiEndpoint => out.api_endpoints.push(sym),
846                _ => {}
847            }
848        }
849        out
850    }
851
852    fn find_symbol(&self, query: &str) -> Vec<SymbolRef> {
853        let q = query.trim();
854        if q.is_empty() {
855            return Vec::new();
856        }
857
858        if let Some(&id) = self.nodes_by_key.get(&(NodeKind::Function, q.to_string())) {
859            return vec![self.node_to_symbol(id).unwrap()];
860        }
861        if let Some(&id) = self.nodes_by_key.get(&(NodeKind::Class, q.to_string())) {
862            return vec![self.node_to_symbol(id).unwrap()];
863        }
864
865        let q_lower = q.to_lowercase();
866        let mut results = Vec::new();
867        for (id, node) in self.nodes.iter().enumerate() {
868            if node.key.to_lowercase().contains(&q_lower)
869                || node
870                    .name
871                    .as_ref()
872                    .is_some_and(|n| n.to_lowercase().contains(&q_lower))
873            {
874                if let Some(sym) = self.node_to_symbol(id) {
875                    results.push(sym);
876                }
877            }
878        }
879        results.sort_by(|a, b| a.key.cmp(&b.key));
880        results.truncate(50);
881        results
882    }
883
884    fn node_count(&self) -> usize {
885        self.nodes.len()
886    }
887
888    fn edge_count(&self) -> usize {
889        self.outgoing.values().map(|v| v.len()).sum()
890    }
891}
892
893fn node_kind_from_label(label: &str) -> Option<NodeKind> {
894    match label {
895        "File" => Some(NodeKind::File),
896        "Module" => Some(NodeKind::Module),
897        "Class" => Some(NodeKind::Class),
898        "Property" => Some(NodeKind::Property),
899        "Function" => Some(NodeKind::Function),
900        "Behaviour" => Some(NodeKind::Behaviour),
901        "Callback" => Some(NodeKind::Callback),
902        "ApiEndpoint" => Some(NodeKind::ApiEndpoint),
903        "ExternalApi" => Some(NodeKind::ExternalApi),
904        _ => None,
905    }
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911    use crate::ir::{EdgeKind, ProjectIr};
912
913    fn sample_ir() -> ProjectIr {
914        let mut ir = ProjectIr::empty();
915        ir.files.push(FileIr {
916            path: "src/a.rs".into(),
917            language: "rust".into(),
918            framework: None,
919            project_name: None,
920        });
921        ir.functions.push(FunctionIr {
922            name: "main".into(),
923            fqn: "src/a.rs::main".into(),
924            path: "src/a.rs".into(),
925            language: "rust".into(),
926            framework: None,
927            project_name: None,
928            arity: None,
929            return_type: None,
930            param_count: None,
931            param_types: vec![],
932            code_bytes: None,
933        });
934        ir.functions.push(FunctionIr {
935            name: "helper".into(),
936            fqn: "src/a.rs::helper".into(),
937            path: "src/a.rs".into(),
938            language: "rust".into(),
939            framework: None,
940            project_name: None,
941            arity: None,
942            return_type: None,
943            param_count: None,
944            param_types: vec![],
945            code_bytes: None,
946        });
947        ir.edges.push(EdgeIr {
948            kind: EdgeKind::DeclaresFunction,
949            from_label: "File".into(),
950            from_key: "src/a.rs".into(),
951            to_label: "Function".into(),
952            to_key: "src/a.rs::main".into(),
953        });
954        ir.edges.push(EdgeIr {
955            kind: EdgeKind::CallsFunction,
956            from_label: "Function".into(),
957            from_key: "src/a.rs::main".into(),
958            to_label: "Function".into(),
959            to_key: "src/a.rs::helper".into(),
960        });
961        ir
962    }
963
964    #[test]
965    fn in_memory_graph_queries_callers_and_callees() {
966        let graph = InMemoryGraph::from_ir(sample_ir());
967        let callees = graph.callees("src/a.rs::main");
968        assert_eq!(callees.len(), 1);
969        assert_eq!(callees[0].key, "src/a.rs::helper");
970        let callers = graph.callers("src/a.rs::helper");
971        assert_eq!(callers.len(), 1);
972        assert_eq!(callers[0].key, "src/a.rs::main");
973    }
974
975    #[test]
976    fn in_memory_graph_callers_by_short_name_when_unique() {
977        let graph = InMemoryGraph::from_ir(sample_ir());
978        let callers = graph.callers("helper");
979        assert_eq!(callers.len(), 1);
980        assert_eq!(callers[0].key, "src/a.rs::main");
981    }
982
983    #[test]
984    fn in_memory_graph_callees_by_short_name_when_unique() {
985        let graph = InMemoryGraph::from_ir(sample_ir());
986        let callees = graph.callees("main");
987        assert_eq!(callees.len(), 1);
988        assert_eq!(callees[0].key, "src/a.rs::helper");
989    }
990
991    #[test]
992    fn ensure_node_reindexes_symbols_by_path_on_path_change() {
993        fn fn_ir(path: &str) -> FunctionIr {
994            FunctionIr {
995                name: "dup".into(),
996                fqn: "dup".into(),
997                path: path.into(),
998                language: "rust".into(),
999                framework: None,
1000                project_name: None,
1001                arity: None,
1002                return_type: None,
1003                param_count: None,
1004                param_types: vec![],
1005                code_bytes: None,
1006            }
1007        }
1008        let mut graph = InMemoryGraph::default();
1009        let mut ir = ProjectIr::empty();
1010        ir.functions.push(fn_ir("src/a.rs"));
1011        graph.merge_ir(ir);
1012        assert_eq!(graph.symbols_in_file("src/a.rs").functions.len(), 1);
1013        let mut ir2 = ProjectIr::empty();
1014        ir2.functions.push(fn_ir("src/b.rs"));
1015        graph.merge_ir(ir2);
1016        assert!(graph.symbols_in_file("src/a.rs").functions.is_empty());
1017        assert_eq!(graph.symbols_in_file("src/b.rs").functions.len(), 1);
1018    }
1019
1020    #[test]
1021    fn remove_file_strips_symbols() {
1022        let mut graph = InMemoryGraph::from_ir(sample_ir());
1023        graph.remove_file("src/a.rs");
1024        assert!(graph.find_symbol("main").is_empty());
1025    }
1026
1027    #[test]
1028    fn merge_ir_preserves_code_bytes_on_function() {
1029        let mut ir = sample_ir();
1030        ir.functions[0].code_bytes = Some(vec![1, 2, 3]);
1031        let graph = InMemoryGraph::from_ir(ir);
1032        assert_eq!(
1033            graph.code_bytes_for_symbol("src/a.rs::main").as_deref(),
1034            Some(&[1, 2, 3][..])
1035        );
1036        assert_eq!(graph.language_for_symbol("src/a.rs::main").as_deref(), Some("rust"));
1037    }
1038
1039    #[test]
1040    fn merge_ir_coalesces_code_bytes_when_delta_omits_blob() {
1041        let mut ir = sample_ir();
1042        ir.functions[0].code_bytes = Some(vec![9, 8, 7]);
1043        let mut graph = InMemoryGraph::from_ir(ir);
1044        let mut ir2 = sample_ir();
1045        ir2.functions[0].code_bytes = None;
1046        graph.merge_ir(ir2);
1047        assert_eq!(
1048            graph.code_bytes_for_symbol("src/a.rs::main").as_deref(),
1049            Some(&[9, 8, 7][..])
1050        );
1051    }
1052
1053    #[tokio::test]
1054    async fn explain_symbol_logic_file_span_fallback() {
1055        let dir = tempfile::tempdir().unwrap();
1056        let root = dir.path();
1057        std::fs::create_dir_all(root.join("src")).unwrap();
1058        std::fs::write(
1059            root.join("src/a.rs"),
1060            "fn main() {\n    helper();\n}\nfn helper() {}\n",
1061        )
1062        .unwrap();
1063
1064        let mut ir = ProjectIr::empty();
1065        ir.files.push(FileIr {
1066            path: "src/a.rs".into(),
1067            language: "rust".into(),
1068            framework: None,
1069            project_name: None,
1070        });
1071        ir.functions.push(FunctionIr {
1072            name: "main".into(),
1073            fqn: "src/a.rs::main".into(),
1074            path: "src/a.rs".into(),
1075            language: "rust".into(),
1076            framework: None,
1077            project_name: None,
1078            arity: None,
1079            return_type: None,
1080            param_count: None,
1081            param_types: vec![],
1082            code_bytes: None,
1083        });
1084
1085        let graph = InMemoryGraph::from_ir(ir);
1086        let compressor = CompressorConfig {
1087            enabled: false,
1088            ..Default::default()
1089        };
1090        let result = graph
1091            .explain_symbol_logic(
1092                "src/a.rs::main",
1093                root,
1094                &compressor,
1095                ExplainOptions::default(),
1096            )
1097            .await;
1098        assert_eq!(result.source_origin, ExplainSourceOrigin::FileSpan);
1099        let source = result.source.expect("source");
1100        assert!(
1101            source.contains("helper()"),
1102            "expected function body span, got: {source:?}"
1103        );
1104    }
1105
1106}