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 result = self
66            .conn
67            .query(&query)
68            .map_err(|e| anyhow::anyhow!("branches_of failed: {e}"))?;
69        let mut branches = Vec::new();
70        for row in result {
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 = (covered.len() * 100).checked_div(total).unwrap_or(0);
380
381        Ok(TestCoverage {
382            covered_count: covered.len(),
383            uncovered_count: uncovered.len(),
384            coverage_pct: pct,
385            covered,
386            uncovered,
387        })
388    }
389
390    pub fn cross_cutting_for(&self, symbol_id: &str) -> Result<Vec<(String, String)>> {
391        let esc = crate::escape_str(symbol_id);
392        let result = self.conn.query(&format!(
393            "MATCH (s:Symbol)-[:HAS_CONCERN]->(c:Concern) WHERE s.id = '{}' RETURN c.kind, c.detail",
394            esc
395        )).map_err(|e| anyhow::anyhow!("cross_cutting query failed: {e}"))?;
396
397        let mut out = Vec::new();
398        for row in result {
399            if row.len() >= 2 {
400                out.push((row[0].to_string(), row[1].to_string()));
401            }
402        }
403        Ok(out)
404    }
405
406    /// Run a raw Cypher query and return string results.
407    pub fn raw_query(&self, cypher: &str) -> Result<Vec<Vec<String>>> {
408        let result = self
409            .conn
410            .query(cypher)
411            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
412
413        let mut rows = Vec::new();
414        for row in result {
415            let string_row: Vec<String> = row.iter().map(|v| v.to_string()).collect();
416            rows.push(string_row);
417        }
418        Ok(rows)
419    }
420
421    /// Derive TESTED_BY edges from CALLS: if a Test symbol calls a non-test symbol,
422    /// create (called)-[:TESTED_BY]->(test). Returns number of edges created.
423    pub fn derive_tested_by_edges(&self) -> Result<usize> {
424        let _ = self
425            .conn
426            .query("MATCH (s:Symbol)-[r:TESTED_BY]->(t:Symbol) DELETE r");
427        self.conn
428            .query(
429                "MATCH (t:Symbol)-[:CALLS]->(s:Symbol) \
430             WHERE t.kind = 'Test' AND s.kind <> 'Test' \
431             CREATE (s)-[:TESTED_BY]->(t)",
432            )
433            .map_err(|e| anyhow::anyhow!("derive TESTED_BY failed: {e}"))?;
434        let mut r = self
435            .conn
436            .query("MATCH ()-[r:TESTED_BY]->() RETURN count(r)")
437            .map_err(|e| anyhow::anyhow!("count TESTED_BY failed: {e}"))?;
438        let count = r
439            .next()
440            .and_then(|row| row.first().map(|v| v.to_string()))
441            .and_then(|s| s.parse::<usize>().ok())
442            .unwrap_or(0);
443        Ok(count)
444    }
445
446    pub fn skeleton(&self, file: &str) -> Result<String> {
447        use std::collections::HashMap;
448
449        let esc = file.replace('\'', "\\'");
450        let query = format!(
451            "MATCH (s:Symbol) WHERE s.file = '{esc}' \
452             RETURN s.id, s.name, s.kind, s.start_line, s.end_line, s.complexity, s.parameters, s.return_type, s.visibility, s.parent \
453             ORDER BY s.start_line"
454        );
455        let rows = self.raw_query(&query)?;
456
457        if rows.is_empty() {
458            return Ok(format_skeleton(file, &[]));
459        }
460
461        let mut fan_in: HashMap<String, usize> = HashMap::new();
462        for row in &rows {
463            let id = row.first().map(|s| s.as_str()).unwrap_or("");
464            if id.is_empty() {
465                continue;
466            }
467            let callers = self.callers_of(id).unwrap_or_default();
468            fan_in.insert(id.to_string(), callers.len());
469        }
470
471        let stmt_query = format!(
472            "MATCH (s:Symbol)-[:HAS_STATEMENT]->(st:Statement) WHERE s.file = '{esc}' \
473             RETURN s.id, count(st) ORDER BY s.id"
474        );
475        let mut stmt_counts: HashMap<String, usize> = HashMap::new();
476        if let Ok(stmt_rows) = self.raw_query(&stmt_query) {
477            for sr in &stmt_rows {
478                if sr.len() >= 2 {
479                    let count: usize = sr[1].parse().unwrap_or(0);
480                    stmt_counts.insert(sr[0].clone(), count);
481                }
482            }
483        }
484
485        let nesting_query = format!(
486            "MATCH (s:Symbol)-[:HAS_STATEMENT]->(st:Statement) WHERE s.file = '{esc}' \
487             RETURN s.id, max(st.depth) ORDER BY s.id"
488        );
489        let mut nesting: HashMap<String, u32> = HashMap::new();
490        if let Ok(nest_rows) = self.raw_query(&nesting_query) {
491            for nr in &nest_rows {
492                if nr.len() >= 2 {
493                    let depth: u32 = nr[1].parse().unwrap_or(0);
494                    nesting.insert(nr[0].clone(), depth);
495                }
496            }
497        }
498
499        let symbols: Vec<SkeletonSymbol> = rows
500            .iter()
501            .map(|row| {
502                let id = row.first().map(|s| s.to_string()).unwrap_or_default();
503                SkeletonSymbol {
504                    fan_in: fan_in.get(&id).copied().unwrap_or(0),
505                    stmt_count: stmt_counts.get(&id).copied().unwrap_or(0),
506                    nesting: nesting.get(&id).copied().unwrap_or(0),
507                    id,
508                    name: row.get(1).cloned().unwrap_or_default(),
509                    kind: row.get(2).cloned().unwrap_or_default(),
510                    start_line: row.get(3).cloned().unwrap_or_default(),
511                    complexity: row.get(5).and_then(|s| s.parse().ok()).unwrap_or(0),
512                    params: row.get(6).cloned().unwrap_or_default(),
513                    return_type: row.get(7).cloned().unwrap_or_default(),
514                    visibility: row.get(8).cloned().unwrap_or_default(),
515                    parent: row.get(9).cloned().unwrap_or_default(),
516                }
517            })
518            .collect();
519
520        Ok(format_skeleton(file, &symbols))
521    }
522
523    pub fn generate_test_context(
524        &self,
525        file_filter: Option<&str>,
526        limit: usize,
527    ) -> Result<TestContext> {
528        let framework = self.detect_test_framework()?;
529        let example_test = self.find_example_test(file_filter)?;
530
531        let q = String::from(
532            "MATCH (s:Symbol) \
533             WHERE s.kind IN ['Function','Method','Class','Struct','Trait','Interface'] \
534             AND NOT EXISTS { MATCH (s)-[:TESTED_BY]->(:Symbol) } \
535             RETURN s.id, s.name, s.kind, s.file, s.start_line, s.end_line, \
536                    s.visibility, s.parameters, s.return_type, s.complexity \
537             ORDER BY s.complexity DESC, s.file, s.start_line",
538        );
539        let result = self
540            .conn
541            .query(&q)
542            .map_err(|e| anyhow::anyhow!("generate_test_context query failed: {e}"))?;
543
544        let mut targets = Vec::new();
545        for row in result {
546            if row.len() < 10 {
547                continue;
548            }
549            let file = row[3].to_string();
550            if let Some(f) = file_filter {
551                if !file.contains(f) {
552                    continue;
553                }
554            }
555            let visibility = row[6].to_string();
556            let complexity: u32 = row[9].to_string().parse().unwrap_or(1);
557            let vis_score: u32 = if visibility == "public" || visibility == "pub" {
558                10
559            } else {
560                0
561            };
562            let priority_score = complexity * 5 + vis_score;
563
564            targets.push(TestTarget {
565                symbol_id: row[0].to_string(),
566                name: row[1].to_string(),
567                kind: row[2].to_string(),
568                file,
569                start_line: row[4].to_string().parse().unwrap_or(0),
570                end_line: row[5].to_string().parse().unwrap_or(0),
571                visibility,
572                parameters: row[7].to_string(),
573                return_type: row[8].to_string(),
574                complexity,
575                callers: Vec::new(),
576                callees: Vec::new(),
577                branches: Vec::new(),
578                priority_score,
579            });
580        }
581
582        targets.sort_by_key(|t| std::cmp::Reverse(t.priority_score));
583        targets.truncate(limit);
584
585        for t in &mut targets {
586            t.callers = self.callers_of(&t.symbol_id).unwrap_or_default();
587            t.callees = self.callees_of(&t.symbol_id).unwrap_or_default();
588            t.branches = self.branches_of(&t.symbol_id).unwrap_or_default();
589            t.priority_score += t.callers.len() as u32 * 3;
590        }
591
592        targets.sort_by_key(|t| std::cmp::Reverse(t.priority_score));
593
594        Ok(TestContext {
595            framework,
596            example_test,
597            targets,
598        })
599    }
600
601    fn detect_test_framework(&self) -> Result<String> {
602        let q = "MATCH (s:Symbol) WHERE s.kind = 'Test' RETURN s.docstring LIMIT 20";
603        let result = self
604            .conn
605            .query(q)
606            .map_err(|e| anyhow::anyhow!("detect_test_framework failed: {e}"))?;
607
608        let mut frameworks = std::collections::HashMap::new();
609        for row in result {
610            let doc = row.first().map(|v| v.to_string()).unwrap_or_default();
611            if doc.contains("#[test]")
612                || doc.contains("#[tokio::test]")
613                || doc.contains("#[rstest]")
614            {
615                *frameworks.entry("rust (cargo test)").or_insert(0u32) += 1;
616            }
617            if doc.contains("@Test") || doc.contains("@ParameterizedTest") {
618                *frameworks.entry("java (junit)").or_insert(0) += 1;
619            }
620            if doc.contains("[Test]") || doc.contains("[Fact]") || doc.contains("[Theory]") {
621                *frameworks.entry("csharp (nunit/xunit)").or_insert(0) += 1;
622            }
623            if doc.contains("[TestMethod]") {
624                *frameworks.entry("csharp (mstest)").or_insert(0) += 1;
625            }
626            if doc.contains("@pytest") || doc.contains("@unittest") {
627                *frameworks.entry("python (pytest)").or_insert(0) += 1;
628            }
629        }
630
631        if let Some((fw, _)) = frameworks.into_iter().max_by_key(|(_, count)| *count) {
632            return Ok(fw.to_string());
633        }
634
635        let q2 = "MATCH (d:Dependency) WHERE d.is_dev = true RETURN d.name LIMIT 100";
636        if let Ok(r2) = self.conn.query(q2) {
637            for row in r2 {
638                let dep = row.first().map(|v| v.to_string()).unwrap_or_default();
639                match dep.as_str() {
640                    "jest" | "vitest" | "mocha" | "ava" | "tap" | "cypress" => {
641                        return Ok(format!("javascript ({})", dep))
642                    }
643                    "pytest" => return Ok("python (pytest)".to_string()),
644                    "rspec" | "rspec-core" => return Ok("ruby (rspec)".to_string()),
645                    "minitest" => return Ok("ruby (minitest)".to_string()),
646                    "phpunit/phpunit" => return Ok("php (phpunit)".to_string()),
647                    "flutter_test" => return Ok("dart (flutter_test)".to_string()),
648                    "busted" => return Ok("lua (busted)".to_string()),
649                    "pfunit" => return Ok("fortran (pfunit)".to_string()),
650                    "hspec" | "tasty" | "HUnit" => return Ok(format!("haskell ({})", dep)),
651                    "Test::More" | "Test2" => return Ok(format!("perl ({})", dep)),
652                    _ => {
653                        if dep.contains("kotlin-test") || dep.contains("kotest") {
654                            return Ok(format!("kotlin ({})", dep));
655                        }
656                        if dep.contains("scalatest")
657                            || dep.contains("specs2")
658                            || dep.contains("munit")
659                        {
660                            return Ok(format!("scala ({})", dep));
661                        }
662                    }
663                }
664            }
665        }
666
667        let q3 = "MATCH (s:Symbol) WHERE s.kind = 'Test' \
668                   RETURN s.language, count(s) ORDER BY count(s) DESC LIMIT 1";
669        if let Ok(mut r3) = self.conn.query(q3) {
670            if let Some(row) = r3.next() {
671                let lang = row.first().map(|v| v.to_string()).unwrap_or_default();
672                let fw = match lang.as_str() {
673                    "go" => "go (go test)",
674                    "elixir" => "elixir (ExUnit)",
675                    "swift" => "swift (XCTest)",
676                    "erlang" => "erlang (EUnit/CT)",
677                    "zig" => "zig (builtin test)",
678                    "dart" => "dart (test)",
679                    "julia" => "julia (Test)",
680                    "rust" => "rust (cargo test)",
681                    "python" => "python (unittest/pytest)",
682                    "ruby" => "ruby (minitest/rspec)",
683                    "lua" => "lua (busted)",
684                    "r" => "r (testthat)",
685                    "haskell" => "haskell (hspec/tasty)",
686                    "ocaml" => "ocaml (alcotest/ounit)",
687                    "fortran" => "fortran (pfunit)",
688                    "powershell" => "powershell (pester)",
689                    "bash" => "bash (bats)",
690                    _ if !lang.is_empty() => return Ok(format!("{} (detected)", lang)),
691                    _ => "unknown",
692                };
693                if fw != "unknown" {
694                    return Ok(fw.to_string());
695                }
696            }
697        }
698
699        Ok("unknown".to_string())
700    }
701
702    fn find_example_test(&self, file_filter: Option<&str>) -> Result<Option<ExampleTest>> {
703        let q = if let Some(f) = file_filter {
704            format!(
705                "MATCH (s:Symbol) WHERE s.kind = 'Test' AND s.file CONTAINS '{}' \
706                 RETURN s.id, s.name, s.file, s.start_line, s.end_line LIMIT 1",
707                f.replace('\'', "\\'")
708            )
709        } else {
710            "MATCH (s:Symbol) WHERE s.kind = 'Test' \
711             RETURN s.id, s.name, s.file, s.start_line, s.end_line LIMIT 1"
712                .to_string()
713        };
714
715        let mut result = self
716            .conn
717            .query(&q)
718            .map_err(|e| anyhow::anyhow!("find_example_test failed: {e}"))?;
719
720        if let Some(row) = result.next() {
721            if row.len() >= 5 {
722                return Ok(Some(ExampleTest {
723                    symbol_id: row[0].to_string(),
724                    name: row[1].to_string(),
725                    file: row[2].to_string(),
726                    start_line: row[3].to_string().parse().unwrap_or(0),
727                    end_line: row[4].to_string().parse().unwrap_or(0),
728                }));
729            }
730        }
731        Ok(None)
732    }
733
734    fn collect_strings(&self, query: &str) -> Result<Vec<String>> {
735        let result = self
736            .conn
737            .query(query)
738            .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
739        let mut out = Vec::new();
740        for row in result {
741            if let Some(val) = row.first() {
742                out.push(val.to_string());
743            }
744        }
745        Ok(out)
746    }
747}
748
749#[derive(Debug, Serialize)]
750pub struct SymbolRow {
751    pub id: String,
752    pub name: String,
753    pub kind: String,
754    pub start_line: u32,
755    pub end_line: u32,
756}
757
758/// Extended symbol info including file path (for snippet retrieval).
759#[derive(Debug, Serialize)]
760pub struct SymbolDetail {
761    pub id: String,
762    pub name: String,
763    pub kind: String,
764    pub file: String,
765    pub start_line: u32,
766    pub end_line: u32,
767}
768
769#[derive(Debug, Serialize)]
770pub struct ImpactRow {
771    pub id: String,
772    pub name: String,
773    pub file: String,
774    pub kind: String,
775}
776
777#[derive(Debug, Serialize)]
778pub struct ReferenceRow {
779    pub caller_id: String,
780    pub caller_name: String,
781    pub file: String,
782    pub line: u32,
783    pub target_id: String,
784}
785
786#[derive(Debug, Serialize)]
787pub struct ApiSymbol {
788    pub id: String,
789    pub name: String,
790    pub kind: String,
791    pub file: String,
792    pub line: u32,
793    pub visibility: String,
794    pub docstring: String,
795}
796
797#[derive(Debug, Serialize)]
798pub struct FileDeps {
799    pub file: String,
800    pub imports: Vec<String>,
801    pub imported_by: Vec<String>,
802}
803
804#[derive(Debug, Serialize)]
805pub struct HierarchyNode {
806    pub id: String,
807    pub name: String,
808    pub kind: String,
809    pub file: String,
810}
811
812#[derive(Debug, Serialize)]
813pub struct TypeHierarchy {
814    pub root_id: String,
815    pub root_name: String,
816    pub ancestors: Vec<HierarchyNode>,
817    pub descendants: Vec<HierarchyNode>,
818}
819
820#[derive(Debug, Serialize)]
821pub struct CoverageRow {
822    pub symbol_id: String,
823    pub symbol_name: String,
824    pub kind: String,
825    pub file: String,
826    pub test_id: Option<String>,
827}
828
829#[derive(Debug, Serialize)]
830pub struct TestCoverage {
831    pub covered_count: usize,
832    pub uncovered_count: usize,
833    pub coverage_pct: usize,
834    pub covered: Vec<CoverageRow>,
835    pub uncovered: Vec<CoverageRow>,
836}
837
838#[derive(Debug, Serialize)]
839pub struct BranchInfo {
840    pub kind: String,
841    pub condition: String,
842    pub line: u32,
843    pub depth: u32,
844}
845
846#[derive(Debug, Serialize)]
847pub struct TestTarget {
848    pub symbol_id: String,
849    pub name: String,
850    pub kind: String,
851    pub file: String,
852    pub start_line: u32,
853    pub end_line: u32,
854    pub visibility: String,
855    pub parameters: String,
856    pub return_type: String,
857    pub complexity: u32,
858    pub callers: Vec<String>,
859    pub callees: Vec<String>,
860    pub branches: Vec<BranchInfo>,
861    pub priority_score: u32,
862}
863
864#[derive(Debug, Serialize)]
865pub struct TestContext {
866    pub framework: String,
867    pub example_test: Option<ExampleTest>,
868    pub targets: Vec<TestTarget>,
869}
870
871#[derive(Debug, Serialize)]
872pub struct ExampleTest {
873    pub symbol_id: String,
874    pub name: String,
875    pub file: String,
876    pub start_line: u32,
877    pub end_line: u32,
878}
879
880// ── Shared skeleton data + formatter (used by both Kuzu GraphQuery and CozoStore) ──
881
882pub struct SkeletonSymbol {
883    pub id: String,
884    pub name: String,
885    pub kind: String,
886    pub start_line: String,
887    pub complexity: u32,
888    pub params: String,
889    pub return_type: String,
890    pub visibility: String,
891    pub parent: String,
892    pub fan_in: usize,
893    pub stmt_count: usize,
894    pub nesting: u32,
895}
896
897pub fn format_skeleton(file: &str, symbols: &[SkeletonSymbol]) -> String {
898    if symbols.is_empty() {
899        return format!("No symbols found in '{file}'. File may not be indexed.");
900    }
901
902    let mut out = format!("# {file}\n\n");
903    let mut indent_stack: Vec<String> = Vec::new();
904
905    for s in symbols {
906        let indent = if !s.parent.is_empty() {
907            while indent_stack.last().map(|v| v.as_str()) != Some(&s.parent)
908                && !indent_stack.is_empty()
909            {
910                indent_stack.pop();
911            }
912            if indent_stack.is_empty() {
913                indent_stack.push(s.parent.clone());
914            }
915            "  ".repeat(indent_stack.len())
916        } else {
917            indent_stack.clear();
918            String::new()
919        };
920
921        let vis_prefix = if s.visibility.is_empty() || s.visibility == "public" {
922            String::new()
923        } else {
924            format!("{} ", s.visibility)
925        };
926
927        let sig = match s.kind.as_str() {
928            "Function" | "Method" | "Test" => {
929                let p = if s.params.is_empty() { "()" } else { &s.params };
930                let r = if s.return_type.is_empty() {
931                    String::new()
932                } else {
933                    format!(" -> {}", s.return_type)
934                };
935                format!("{vis_prefix}{}{p}{r}", s.name)
936            }
937            "Class" | "Struct" | "Interface" | "Trait" | "Enum" => {
938                indent_stack.push(s.id.clone());
939                format!("{vis_prefix}{} {}", s.kind.to_lowercase(), s.name)
940            }
941            _ => format!("{vis_prefix}{} {}", s.kind, s.name),
942        };
943
944        out.push_str(&format!("{:>4}: {indent}{sig}\n", s.start_line));
945
946        if matches!(s.kind.as_str(), "Function" | "Method" | "Test") {
947            out.push_str(&format!(
948                "       {indent}# complexity: {} | nesting: {} | stmts: {} | fan-in: {}\n",
949                s.complexity, s.nesting, s.stmt_count, s.fan_in
950            ));
951        }
952    }
953
954    out
955}