Skip to main content

infigraph_core/graph/
queries.rs

1use anyhow::Result;
2use kuzu::Connection;
3use serde::Serialize;
4
5/// High-level graph query interface for analysis.
6pub struct GraphQuery<'a, 'db> {
7    conn: &'a Connection<'db>,
8}
9
10impl<'a, 'db> GraphQuery<'a, 'db> {
11    pub fn new(conn: &'a Connection<'db>) -> Self {
12        Self { conn }
13    }
14
15    /// Find all symbols in a file.
16    pub fn symbols_in_file(&self, file: &str) -> Result<Vec<SymbolRow>> {
17        let query = format!(
18            "MATCH (s:Symbol) WHERE s.file = '{}' RETURN s.id, s.name, s.kind, s.start_line, s.end_line ORDER BY s.start_line",
19            file.replace('\'', "\\'")
20        );
21        let result = self
22            .conn
23            .query(&query)
24            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
25
26        let mut rows = Vec::new();
27        for row in result {
28            if row.len() >= 5 {
29                rows.push(SymbolRow {
30                    id: row[0].to_string(),
31                    name: row[1].to_string(),
32                    kind: row[2].to_string(),
33                    start_line: row[3].to_string().parse().unwrap_or(0),
34                    end_line: row[4].to_string().parse().unwrap_or(0),
35                });
36            }
37        }
38        Ok(rows)
39    }
40
41    /// Find direct callers of a symbol.
42    pub fn callers_of(&self, symbol_id: &str) -> Result<Vec<String>> {
43        let query = format!(
44            "MATCH (caller:Symbol)-[:CALLS]->(target:Symbol) WHERE target.id = '{}' RETURN caller.id",
45            symbol_id.replace('\'', "\\'")
46        );
47        self.collect_strings(&query)
48    }
49
50    /// Find what a symbol calls.
51    pub fn callees_of(&self, symbol_id: &str) -> Result<Vec<String>> {
52        let query = format!(
53            "MATCH (source:Symbol)-[:CALLS]->(callee:Symbol) WHERE source.id = '{}' RETURN callee.id",
54            symbol_id.replace('\'', "\\'")
55        );
56        self.collect_strings(&query)
57    }
58
59    pub fn branches_of(&self, symbol_id: &str) -> Result<Vec<BranchInfo>> {
60        let query = format!(
61            "MATCH (s:Symbol)-[:HAS_STATEMENT]->(st:Statement) WHERE s.id = '{}' \
62             RETURN st.kind, st.condition, st.start_line, st.depth ORDER BY st.start_line",
63            symbol_id.replace('\'', "\\'")
64        );
65        let mut result = self
66            .conn
67            .query(&query)
68            .map_err(|e| anyhow::anyhow!("branches_of failed: {e}"))?;
69        let mut branches = Vec::new();
70        while let Some(row) = result.next() {
71            if row.len() >= 4 {
72                branches.push(BranchInfo {
73                    kind: row[0].to_string(),
74                    condition: row[1].to_string(),
75                    line: row[2].to_string().parse().unwrap_or(0),
76                    depth: row[3].to_string().parse().unwrap_or(0),
77                });
78            }
79        }
80        Ok(branches)
81    }
82
83    /// Transitive impact: all symbols affected by a change to the given symbol.
84    /// Follows CALLS edges in reverse (who calls this, who calls those, etc.).
85    pub fn transitive_impact(&self, symbol_id: &str, max_depth: u32) -> Result<Vec<ImpactRow>> {
86        let query = format!(
87            "MATCH (changed:Symbol)<-[:CALLS* 1..{}]-(affected:Symbol) WHERE changed.id = '{}' RETURN DISTINCT affected.id, affected.name, affected.file, affected.kind",
88            max_depth,
89            symbol_id.replace('\'', "\\'")
90        );
91        let result = self
92            .conn
93            .query(&query)
94            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
95
96        let mut rows = Vec::new();
97        for row in result {
98            if row.len() >= 4 {
99                rows.push(ImpactRow {
100                    id: row[0].to_string(),
101                    name: row[1].to_string(),
102                    file: row[2].to_string(),
103                    kind: row[3].to_string(),
104                });
105            }
106        }
107        Ok(rows)
108    }
109
110    /// Find symbols in a file whose line range overlaps [start, end].
111    pub fn symbols_in_range(&self, file: &str, start: u32, end: u32) -> Result<Vec<SymbolDetail>> {
112        let query = format!(
113            "MATCH (s:Symbol) WHERE s.file = '{}' AND s.start_line <= {} AND s.end_line >= {} RETURN s.id, s.name, s.kind, s.file, s.start_line, s.end_line ORDER BY s.start_line",
114            file.replace('\'', "\\'"),
115            end,
116            start
117        );
118        let result = self
119            .conn
120            .query(&query)
121            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
122
123        let mut rows = Vec::new();
124        for row in result {
125            if row.len() >= 6 {
126                rows.push(SymbolDetail {
127                    id: row[0].to_string(),
128                    name: row[1].to_string(),
129                    kind: row[2].to_string(),
130                    file: row[3].to_string(),
131                    start_line: row[4].to_string().parse().unwrap_or(0),
132                    end_line: row[5].to_string().parse().unwrap_or(0),
133                });
134            }
135        }
136        Ok(rows)
137    }
138
139    /// Look up a symbol by its ID and return its file, start_line, end_line.
140    pub fn find_symbol_by_id(&self, symbol_id: &str) -> Result<Option<SymbolDetail>> {
141        let query = format!(
142            "MATCH (s:Symbol) WHERE s.id = '{}' RETURN s.id, s.name, s.kind, s.file, s.start_line, s.end_line",
143            symbol_id.replace('\'', "\\'")
144        );
145        let mut result = self
146            .conn
147            .query(&query)
148            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
149
150        if let Some(row) = result.next() {
151            if row.len() >= 6 {
152                return Ok(Some(SymbolDetail {
153                    id: row[0].to_string(),
154                    name: row[1].to_string(),
155                    kind: row[2].to_string(),
156                    file: row[3].to_string(),
157                    start_line: row[4].to_string().parse().unwrap_or(0),
158                    end_line: row[5].to_string().parse().unwrap_or(0),
159                }));
160            }
161        }
162        Ok(None)
163    }
164
165    /// Find all reference locations for a symbol — file, line, column, and calling symbol.
166    /// Returns every place the symbol is called/used, for rename/refactor workflows.
167    pub fn find_all_references(&self, symbol_id: &str) -> Result<Vec<ReferenceRow>> {
168        let q = format!(
169            "MATCH (caller:Symbol)-[:CALLS]->(target:Symbol) \
170             WHERE target.id = '{}' \
171             RETURN caller.id, caller.name, caller.file, caller.start_line, target.id",
172            symbol_id.replace('\'', "\\'")
173        );
174        let result = self
175            .conn
176            .query(&q)
177            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
178        let mut rows = Vec::new();
179        for row in result {
180            if row.len() >= 5 {
181                rows.push(ReferenceRow {
182                    caller_id: row[0].to_string(),
183                    caller_name: row[1].to_string(),
184                    file: row[2].to_string(),
185                    line: row[3].to_string().parse().unwrap_or(0),
186                    target_id: row[4].to_string(),
187                });
188            }
189        }
190        Ok(rows)
191    }
192
193    /// Get the public API surface: all public symbols + all routes.
194    pub fn get_api_surface(&self) -> Result<Vec<ApiSymbol>> {
195        let q = "MATCH (s:Symbol) \
196                 WHERE s.visibility = 'public' OR s.kind = 'Route' \
197                 RETURN s.id, s.name, s.kind, s.file, s.start_line, s.visibility, s.docstring \
198                 ORDER BY s.file, s.start_line";
199        let result = self
200            .conn
201            .query(q)
202            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
203        let mut rows = Vec::new();
204        for row in result {
205            if row.len() >= 7 {
206                rows.push(ApiSymbol {
207                    id: row[0].to_string(),
208                    name: row[1].to_string(),
209                    kind: row[2].to_string(),
210                    file: row[3].to_string(),
211                    line: row[4].to_string().parse().unwrap_or(0),
212                    visibility: row[5].to_string(),
213                    docstring: row[6].to_string(),
214                });
215            }
216        }
217        Ok(rows)
218    }
219
220    /// Get file-level dependency graph: what this file imports and what imports it.
221    pub fn get_file_deps(&self, file: &str) -> Result<FileDeps> {
222        let esc = file.replace('\'', "\\'");
223
224        // Files this file imports (outgoing)
225        let q_out = format!(
226            "MATCH (m:Module)-[:IMPORTS]->(dep:Module) WHERE m.file = '{}' RETURN dep.file",
227            esc
228        );
229        let r = self
230            .conn
231            .query(&q_out)
232            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
233        let mut imports = Vec::new();
234        for row in r {
235            if let Some(v) = row.first() {
236                let s = v.to_string().trim_matches('"').to_string();
237                if !s.is_empty() {
238                    imports.push(s);
239                }
240            }
241        }
242
243        // Files that import this file (incoming)
244        let q_in = format!(
245            "MATCH (m:Module)-[:IMPORTS]->(dep:Module) WHERE dep.file = '{}' RETURN m.file",
246            esc
247        );
248        let r2 = self
249            .conn
250            .query(&q_in)
251            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
252        let mut imported_by = Vec::new();
253        for row in r2 {
254            if let Some(v) = row.first() {
255                let s = v.to_string().trim_matches('"').to_string();
256                if !s.is_empty() {
257                    imported_by.push(s);
258                }
259            }
260        }
261
262        Ok(FileDeps {
263            file: file.to_string(),
264            imports,
265            imported_by,
266        })
267    }
268
269    /// Get full type hierarchy for a class/interface: ancestors (up) and descendants (down).
270    pub fn get_type_hierarchy(&self, symbol_id: &str, max_depth: u32) -> Result<TypeHierarchy> {
271        let esc = symbol_id.replace('\'', "\\'");
272
273        // Ancestors: walk INHERITS edges upward
274        let q_up = format!(
275            "MATCH (root:Symbol)-[:INHERITS* 1..{}]->(ancestor:Symbol) \
276             WHERE root.id = '{}' \
277             RETURN ancestor.id, ancestor.name, ancestor.kind, ancestor.file",
278            max_depth, esc
279        );
280        let r = self
281            .conn
282            .query(&q_up)
283            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
284        let mut ancestors = Vec::new();
285        for row in r {
286            if row.len() >= 4 {
287                ancestors.push(HierarchyNode {
288                    id: row[0].to_string(),
289                    name: row[1].to_string(),
290                    kind: row[2].to_string(),
291                    file: row[3].to_string(),
292                });
293            }
294        }
295
296        // Descendants: walk INHERITS edges downward
297        let q_down = format!(
298            "MATCH (descendant:Symbol)-[:INHERITS* 1..{}]->(root:Symbol) \
299             WHERE root.id = '{}' \
300             RETURN descendant.id, descendant.name, descendant.kind, descendant.file",
301            max_depth, esc
302        );
303        let r2 = self
304            .conn
305            .query(&q_down)
306            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
307        let mut descendants = Vec::new();
308        for row in r2 {
309            if row.len() >= 4 {
310                descendants.push(HierarchyNode {
311                    id: row[0].to_string(),
312                    name: row[1].to_string(),
313                    kind: row[2].to_string(),
314                    file: row[3].to_string(),
315                });
316            }
317        }
318
319        // Also get root symbol info
320        let root_detail = self.find_symbol_by_id(symbol_id)?;
321
322        Ok(TypeHierarchy {
323            root_id: symbol_id.to_string(),
324            root_name: root_detail
325                .as_ref()
326                .map(|s| s.name.clone())
327                .unwrap_or_default(),
328            ancestors,
329            descendants,
330        })
331    }
332
333    /// Get test coverage: which symbols have TESTED_BY edges, which don't.
334    pub fn get_test_coverage(&self) -> Result<TestCoverage> {
335        // Testable kinds
336        let q_covered = "MATCH (s:Symbol)-[:TESTED_BY]->(t:Symbol) \
337                         WHERE s.kind IN ['Function','Method','Class','Struct','Trait','Interface'] \
338                         RETURN DISTINCT s.id, s.name, s.kind, s.file, t.id";
339        let r = self
340            .conn
341            .query(q_covered)
342            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
343        let mut covered = Vec::new();
344        for row in r {
345            if row.len() >= 5 {
346                covered.push(CoverageRow {
347                    symbol_id: row[0].to_string(),
348                    symbol_name: row[1].to_string(),
349                    kind: row[2].to_string(),
350                    file: row[3].to_string(),
351                    test_id: Some(row[4].to_string()),
352                });
353            }
354        }
355
356        let q_uncovered = "MATCH (s:Symbol) \
357                           WHERE s.kind IN ['Function','Method','Class','Struct','Trait','Interface'] \
358                           AND NOT EXISTS { MATCH (s)-[:TESTED_BY]->(:Symbol) } \
359                           RETURN s.id, s.name, s.kind, s.file \
360                           ORDER BY s.file, s.start_line";
361        let r2 = self
362            .conn
363            .query(q_uncovered)
364            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
365        let mut uncovered = Vec::new();
366        for row in r2 {
367            if row.len() >= 4 {
368                uncovered.push(CoverageRow {
369                    symbol_id: row[0].to_string(),
370                    symbol_name: row[1].to_string(),
371                    kind: row[2].to_string(),
372                    file: row[3].to_string(),
373                    test_id: None,
374                });
375            }
376        }
377
378        let total = covered.len() + uncovered.len();
379        let pct = if total > 0 {
380            (covered.len() * 100) / total
381        } else {
382            0
383        };
384
385        Ok(TestCoverage {
386            covered_count: covered.len(),
387            uncovered_count: uncovered.len(),
388            coverage_pct: pct,
389            covered,
390            uncovered,
391        })
392    }
393
394    pub fn cross_cutting_for(&self, symbol_id: &str) -> Result<Vec<(String, String)>> {
395        let esc = crate::escape_str(symbol_id);
396        let result = self.conn.query(&format!(
397            "MATCH (s:Symbol)-[:HAS_CONCERN]->(c:Concern) WHERE s.id = '{}' RETURN c.kind, c.detail",
398            esc
399        )).map_err(|e| anyhow::anyhow!("cross_cutting query failed: {e}"))?;
400
401        let mut out = Vec::new();
402        for row in result {
403            if row.len() >= 2 {
404                out.push((row[0].to_string(), row[1].to_string()));
405            }
406        }
407        Ok(out)
408    }
409
410    /// Run a raw Cypher query and return string results.
411    pub fn raw_query(&self, cypher: &str) -> Result<Vec<Vec<String>>> {
412        let result = self
413            .conn
414            .query(cypher)
415            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
416
417        let mut rows = Vec::new();
418        for row in result {
419            let string_row: Vec<String> = row.iter().map(|v| v.to_string()).collect();
420            rows.push(string_row);
421        }
422        Ok(rows)
423    }
424
425    /// Derive TESTED_BY edges from CALLS: if a Test symbol calls a non-test symbol,
426    /// create (called)-[:TESTED_BY]->(test). Returns number of edges created.
427    pub fn derive_tested_by_edges(&self) -> Result<usize> {
428        let _ = self
429            .conn
430            .query("MATCH (s:Symbol)-[r:TESTED_BY]->(t:Symbol) DELETE r");
431        self.conn
432            .query(
433                "MATCH (t:Symbol)-[:CALLS]->(s:Symbol) \
434             WHERE t.kind = 'Test' AND s.kind <> 'Test' \
435             CREATE (s)-[:TESTED_BY]->(t)",
436            )
437            .map_err(|e| anyhow::anyhow!("derive TESTED_BY failed: {e}"))?;
438        let mut r = self
439            .conn
440            .query("MATCH ()-[r:TESTED_BY]->() RETURN count(r)")
441            .map_err(|e| anyhow::anyhow!("count TESTED_BY failed: {e}"))?;
442        let count = r
443            .next()
444            .and_then(|row| row.first().map(|v| v.to_string()))
445            .and_then(|s| s.parse::<usize>().ok())
446            .unwrap_or(0);
447        Ok(count)
448    }
449
450    pub fn skeleton(&self, file: &str) -> Result<String> {
451        use std::collections::HashMap;
452
453        let esc = file.replace('\'', "\\'");
454        let query = format!(
455            "MATCH (s:Symbol) WHERE s.file = '{esc}' \
456             RETURN s.id, s.name, s.kind, s.start_line, s.end_line, s.complexity, s.parameters, s.return_type, s.visibility, s.parent \
457             ORDER BY s.start_line"
458        );
459        let rows = self.raw_query(&query)?;
460
461        if rows.is_empty() {
462            return Ok(format_skeleton(file, &[]));
463        }
464
465        let mut fan_in: HashMap<String, usize> = HashMap::new();
466        for row in &rows {
467            let id = row.first().map(|s| s.as_str()).unwrap_or("");
468            if id.is_empty() {
469                continue;
470            }
471            let callers = self.callers_of(id).unwrap_or_default();
472            fan_in.insert(id.to_string(), callers.len());
473        }
474
475        let stmt_query = format!(
476            "MATCH (s:Symbol)-[:HAS_STATEMENT]->(st:Statement) WHERE s.file = '{esc}' \
477             RETURN s.id, count(st) ORDER BY s.id"
478        );
479        let mut stmt_counts: HashMap<String, usize> = HashMap::new();
480        if let Ok(stmt_rows) = self.raw_query(&stmt_query) {
481            for sr in &stmt_rows {
482                if sr.len() >= 2 {
483                    let count: usize = sr[1].parse().unwrap_or(0);
484                    stmt_counts.insert(sr[0].clone(), count);
485                }
486            }
487        }
488
489        let nesting_query = format!(
490            "MATCH (s:Symbol)-[:HAS_STATEMENT]->(st:Statement) WHERE s.file = '{esc}' \
491             RETURN s.id, max(st.depth) ORDER BY s.id"
492        );
493        let mut nesting: HashMap<String, u32> = HashMap::new();
494        if let Ok(nest_rows) = self.raw_query(&nesting_query) {
495            for nr in &nest_rows {
496                if nr.len() >= 2 {
497                    let depth: u32 = nr[1].parse().unwrap_or(0);
498                    nesting.insert(nr[0].clone(), depth);
499                }
500            }
501        }
502
503        let symbols: Vec<SkeletonSymbol> = rows
504            .iter()
505            .map(|row| {
506                let id = row.first().map(|s| s.to_string()).unwrap_or_default();
507                SkeletonSymbol {
508                    fan_in: fan_in.get(&id).copied().unwrap_or(0),
509                    stmt_count: stmt_counts.get(&id).copied().unwrap_or(0),
510                    nesting: nesting.get(&id).copied().unwrap_or(0),
511                    id,
512                    name: row.get(1).cloned().unwrap_or_default(),
513                    kind: row.get(2).cloned().unwrap_or_default(),
514                    start_line: row.get(3).cloned().unwrap_or_default(),
515                    complexity: row.get(5).and_then(|s| s.parse().ok()).unwrap_or(0),
516                    params: row.get(6).cloned().unwrap_or_default(),
517                    return_type: row.get(7).cloned().unwrap_or_default(),
518                    visibility: row.get(8).cloned().unwrap_or_default(),
519                    parent: row.get(9).cloned().unwrap_or_default(),
520                }
521            })
522            .collect();
523
524        Ok(format_skeleton(file, &symbols))
525    }
526
527    pub fn generate_test_context(
528        &self,
529        file_filter: Option<&str>,
530        limit: usize,
531    ) -> Result<TestContext> {
532        let framework = self.detect_test_framework()?;
533        let example_test = self.find_example_test(file_filter)?;
534
535        let q = String::from(
536            "MATCH (s:Symbol) \
537             WHERE s.kind IN ['Function','Method','Class','Struct','Trait','Interface'] \
538             AND NOT EXISTS { MATCH (s)-[:TESTED_BY]->(:Symbol) } \
539             RETURN s.id, s.name, s.kind, s.file, s.start_line, s.end_line, \
540                    s.visibility, s.parameters, s.return_type, s.complexity \
541             ORDER BY s.complexity DESC, s.file, s.start_line",
542        );
543        let mut result = self
544            .conn
545            .query(&q)
546            .map_err(|e| anyhow::anyhow!("generate_test_context query failed: {e}"))?;
547
548        let mut targets = Vec::new();
549        while let Some(row) = result.next() {
550            if row.len() < 10 {
551                continue;
552            }
553            let file = row[3].to_string();
554            if let Some(f) = file_filter {
555                if !file.contains(f) {
556                    continue;
557                }
558            }
559            let visibility = row[6].to_string();
560            let complexity: u32 = row[9].to_string().parse().unwrap_or(1);
561            let vis_score: u32 = if visibility == "public" || visibility == "pub" {
562                10
563            } else {
564                0
565            };
566            let priority_score = complexity * 5 + vis_score;
567
568            targets.push(TestTarget {
569                symbol_id: row[0].to_string(),
570                name: row[1].to_string(),
571                kind: row[2].to_string(),
572                file,
573                start_line: row[4].to_string().parse().unwrap_or(0),
574                end_line: row[5].to_string().parse().unwrap_or(0),
575                visibility,
576                parameters: row[7].to_string(),
577                return_type: row[8].to_string(),
578                complexity,
579                callers: Vec::new(),
580                callees: Vec::new(),
581                branches: Vec::new(),
582                priority_score,
583            });
584        }
585
586        targets.sort_by(|a, b| b.priority_score.cmp(&a.priority_score));
587        targets.truncate(limit);
588
589        for t in &mut targets {
590            t.callers = self.callers_of(&t.symbol_id).unwrap_or_default();
591            t.callees = self.callees_of(&t.symbol_id).unwrap_or_default();
592            t.branches = self.branches_of(&t.symbol_id).unwrap_or_default();
593            t.priority_score += t.callers.len() as u32 * 3;
594        }
595
596        targets.sort_by(|a, b| b.priority_score.cmp(&a.priority_score));
597
598        Ok(TestContext {
599            framework,
600            example_test,
601            targets,
602        })
603    }
604
605    fn detect_test_framework(&self) -> Result<String> {
606        let q = "MATCH (s:Symbol) WHERE s.kind = 'Test' RETURN s.docstring LIMIT 20";
607        let mut result = self
608            .conn
609            .query(q)
610            .map_err(|e| anyhow::anyhow!("detect_test_framework failed: {e}"))?;
611
612        let mut frameworks = std::collections::HashMap::new();
613        while let Some(row) = result.next() {
614            let doc = row.first().map(|v| v.to_string()).unwrap_or_default();
615            if doc.contains("#[test]")
616                || doc.contains("#[tokio::test]")
617                || doc.contains("#[rstest]")
618            {
619                *frameworks.entry("rust (cargo test)").or_insert(0u32) += 1;
620            }
621            if doc.contains("@Test") || doc.contains("@ParameterizedTest") {
622                *frameworks.entry("java (junit)").or_insert(0) += 1;
623            }
624            if doc.contains("[Test]") || doc.contains("[Fact]") || doc.contains("[Theory]") {
625                *frameworks.entry("csharp (nunit/xunit)").or_insert(0) += 1;
626            }
627            if doc.contains("[TestMethod]") {
628                *frameworks.entry("csharp (mstest)").or_insert(0) += 1;
629            }
630            if doc.contains("@pytest") || doc.contains("@unittest") {
631                *frameworks.entry("python (pytest)").or_insert(0) += 1;
632            }
633        }
634
635        if let Some((fw, _)) = frameworks.into_iter().max_by_key(|(_, count)| *count) {
636            return Ok(fw.to_string());
637        }
638
639        let q2 = "MATCH (d:Dependency) WHERE d.is_dev = true RETURN d.name LIMIT 100";
640        if let Ok(mut r2) = self.conn.query(q2) {
641            while let Some(row) = r2.next() {
642                let dep = row.first().map(|v| v.to_string()).unwrap_or_default();
643                match dep.as_str() {
644                    "jest" | "vitest" | "mocha" | "ava" | "tap" | "cypress" => {
645                        return Ok(format!("javascript ({})", dep))
646                    }
647                    "pytest" => return Ok("python (pytest)".to_string()),
648                    "rspec" | "rspec-core" => return Ok("ruby (rspec)".to_string()),
649                    "minitest" => return Ok("ruby (minitest)".to_string()),
650                    "phpunit/phpunit" => return Ok("php (phpunit)".to_string()),
651                    "flutter_test" => return Ok("dart (flutter_test)".to_string()),
652                    "busted" => return Ok("lua (busted)".to_string()),
653                    "pfunit" => return Ok("fortran (pfunit)".to_string()),
654                    "hspec" | "tasty" | "HUnit" => return Ok(format!("haskell ({})", dep)),
655                    "Test::More" | "Test2" => return Ok(format!("perl ({})", dep)),
656                    _ => {
657                        if dep.contains("kotlin-test") || dep.contains("kotest") {
658                            return Ok(format!("kotlin ({})", dep));
659                        }
660                        if dep.contains("scalatest")
661                            || dep.contains("specs2")
662                            || dep.contains("munit")
663                        {
664                            return Ok(format!("scala ({})", dep));
665                        }
666                    }
667                }
668            }
669        }
670
671        let q3 = "MATCH (s:Symbol) WHERE s.kind = 'Test' \
672                   RETURN s.language, count(s) ORDER BY count(s) DESC LIMIT 1";
673        if let Ok(mut r3) = self.conn.query(q3) {
674            if let Some(row) = r3.next() {
675                let lang = row.first().map(|v| v.to_string()).unwrap_or_default();
676                let fw = match lang.as_str() {
677                    "go" => "go (go test)",
678                    "elixir" => "elixir (ExUnit)",
679                    "swift" => "swift (XCTest)",
680                    "erlang" => "erlang (EUnit/CT)",
681                    "zig" => "zig (builtin test)",
682                    "dart" => "dart (test)",
683                    "julia" => "julia (Test)",
684                    "rust" => "rust (cargo test)",
685                    "python" => "python (unittest/pytest)",
686                    "ruby" => "ruby (minitest/rspec)",
687                    "lua" => "lua (busted)",
688                    "r" => "r (testthat)",
689                    "haskell" => "haskell (hspec/tasty)",
690                    "ocaml" => "ocaml (alcotest/ounit)",
691                    "fortran" => "fortran (pfunit)",
692                    "powershell" => "powershell (pester)",
693                    "bash" => "bash (bats)",
694                    _ if !lang.is_empty() => return Ok(format!("{} (detected)", lang)),
695                    _ => "unknown",
696                };
697                if fw != "unknown" {
698                    return Ok(fw.to_string());
699                }
700            }
701        }
702
703        Ok("unknown".to_string())
704    }
705
706    fn find_example_test(&self, file_filter: Option<&str>) -> Result<Option<ExampleTest>> {
707        let q = if let Some(f) = file_filter {
708            format!(
709                "MATCH (s:Symbol) WHERE s.kind = 'Test' AND s.file CONTAINS '{}' \
710                 RETURN s.id, s.name, s.file, s.start_line, s.end_line LIMIT 1",
711                f.replace('\'', "\\'")
712            )
713        } else {
714            "MATCH (s:Symbol) WHERE s.kind = 'Test' \
715             RETURN s.id, s.name, s.file, s.start_line, s.end_line LIMIT 1"
716                .to_string()
717        };
718
719        let mut result = self
720            .conn
721            .query(&q)
722            .map_err(|e| anyhow::anyhow!("find_example_test failed: {e}"))?;
723
724        if let Some(row) = result.next() {
725            if row.len() >= 5 {
726                return Ok(Some(ExampleTest {
727                    symbol_id: row[0].to_string(),
728                    name: row[1].to_string(),
729                    file: row[2].to_string(),
730                    start_line: row[3].to_string().parse().unwrap_or(0),
731                    end_line: row[4].to_string().parse().unwrap_or(0),
732                }));
733            }
734        }
735        Ok(None)
736    }
737
738    fn collect_strings(&self, query: &str) -> Result<Vec<String>> {
739        let result = self
740            .conn
741            .query(query)
742            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
743        let mut out = Vec::new();
744        for row in result {
745            if let Some(val) = row.first() {
746                out.push(val.to_string());
747            }
748        }
749        Ok(out)
750    }
751}
752
753#[derive(Debug, Serialize)]
754pub struct SymbolRow {
755    pub id: String,
756    pub name: String,
757    pub kind: String,
758    pub start_line: u32,
759    pub end_line: u32,
760}
761
762/// Extended symbol info including file path (for snippet retrieval).
763#[derive(Debug, Serialize)]
764pub struct SymbolDetail {
765    pub id: String,
766    pub name: String,
767    pub kind: String,
768    pub file: String,
769    pub start_line: u32,
770    pub end_line: u32,
771}
772
773#[derive(Debug, Serialize)]
774pub struct ImpactRow {
775    pub id: String,
776    pub name: String,
777    pub file: String,
778    pub kind: String,
779}
780
781#[derive(Debug, Serialize)]
782pub struct ReferenceRow {
783    pub caller_id: String,
784    pub caller_name: String,
785    pub file: String,
786    pub line: u32,
787    pub target_id: String,
788}
789
790#[derive(Debug, Serialize)]
791pub struct ApiSymbol {
792    pub id: String,
793    pub name: String,
794    pub kind: String,
795    pub file: String,
796    pub line: u32,
797    pub visibility: String,
798    pub docstring: String,
799}
800
801#[derive(Debug, Serialize)]
802pub struct FileDeps {
803    pub file: String,
804    pub imports: Vec<String>,
805    pub imported_by: Vec<String>,
806}
807
808#[derive(Debug, Serialize)]
809pub struct HierarchyNode {
810    pub id: String,
811    pub name: String,
812    pub kind: String,
813    pub file: String,
814}
815
816#[derive(Debug, Serialize)]
817pub struct TypeHierarchy {
818    pub root_id: String,
819    pub root_name: String,
820    pub ancestors: Vec<HierarchyNode>,
821    pub descendants: Vec<HierarchyNode>,
822}
823
824#[derive(Debug, Serialize)]
825pub struct CoverageRow {
826    pub symbol_id: String,
827    pub symbol_name: String,
828    pub kind: String,
829    pub file: String,
830    pub test_id: Option<String>,
831}
832
833#[derive(Debug, Serialize)]
834pub struct TestCoverage {
835    pub covered_count: usize,
836    pub uncovered_count: usize,
837    pub coverage_pct: usize,
838    pub covered: Vec<CoverageRow>,
839    pub uncovered: Vec<CoverageRow>,
840}
841
842#[derive(Debug, Serialize)]
843pub struct BranchInfo {
844    pub kind: String,
845    pub condition: String,
846    pub line: u32,
847    pub depth: u32,
848}
849
850#[derive(Debug, Serialize)]
851pub struct TestTarget {
852    pub symbol_id: String,
853    pub name: String,
854    pub kind: String,
855    pub file: String,
856    pub start_line: u32,
857    pub end_line: u32,
858    pub visibility: String,
859    pub parameters: String,
860    pub return_type: String,
861    pub complexity: u32,
862    pub callers: Vec<String>,
863    pub callees: Vec<String>,
864    pub branches: Vec<BranchInfo>,
865    pub priority_score: u32,
866}
867
868#[derive(Debug, Serialize)]
869pub struct TestContext {
870    pub framework: String,
871    pub example_test: Option<ExampleTest>,
872    pub targets: Vec<TestTarget>,
873}
874
875#[derive(Debug, Serialize)]
876pub struct ExampleTest {
877    pub symbol_id: String,
878    pub name: String,
879    pub file: String,
880    pub start_line: u32,
881    pub end_line: u32,
882}
883
884// ── Shared skeleton data + formatter (used by both Kuzu GraphQuery and CozoStore) ──
885
886pub struct SkeletonSymbol {
887    pub id: String,
888    pub name: String,
889    pub kind: String,
890    pub start_line: String,
891    pub complexity: u32,
892    pub params: String,
893    pub return_type: String,
894    pub visibility: String,
895    pub parent: String,
896    pub fan_in: usize,
897    pub stmt_count: usize,
898    pub nesting: u32,
899}
900
901pub fn format_skeleton(file: &str, symbols: &[SkeletonSymbol]) -> String {
902    if symbols.is_empty() {
903        return format!("No symbols found in '{file}'. File may not be indexed.");
904    }
905
906    let mut out = format!("# {file}\n\n");
907    let mut indent_stack: Vec<String> = Vec::new();
908
909    for s in symbols {
910        let indent = if !s.parent.is_empty() {
911            while indent_stack.last().map(|v| v.as_str()) != Some(&s.parent)
912                && !indent_stack.is_empty()
913            {
914                indent_stack.pop();
915            }
916            if indent_stack.is_empty() {
917                indent_stack.push(s.parent.clone());
918            }
919            "  ".repeat(indent_stack.len())
920        } else {
921            indent_stack.clear();
922            String::new()
923        };
924
925        let vis_prefix = if s.visibility.is_empty() || s.visibility == "public" {
926            String::new()
927        } else {
928            format!("{} ", s.visibility)
929        };
930
931        let sig = match s.kind.as_str() {
932            "Function" | "Method" | "Test" => {
933                let p = if s.params.is_empty() { "()" } else { &s.params };
934                let r = if s.return_type.is_empty() {
935                    String::new()
936                } else {
937                    format!(" -> {}", s.return_type)
938                };
939                format!("{vis_prefix}{}{p}{r}", s.name)
940            }
941            "Class" | "Struct" | "Interface" | "Trait" | "Enum" => {
942                indent_stack.push(s.id.clone());
943                format!("{vis_prefix}{} {}", s.kind.to_lowercase(), s.name)
944            }
945            _ => format!("{vis_prefix}{} {}", s.kind, s.name),
946        };
947
948        out.push_str(&format!("{:>4}: {indent}{sig}\n", s.start_line));
949
950        if matches!(s.kind.as_str(), "Function" | "Method" | "Test") {
951            out.push_str(&format!(
952                "       {indent}# complexity: {} | nesting: {} | stmts: {} | fan-in: {}\n",
953                s.complexity, s.nesting, s.stmt_count, s.fan_in
954            ));
955        }
956    }
957
958    out
959}