1use anyhow::Result;
2use kuzu::Connection;
3use serde::Serialize;
4
5pub 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 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 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 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 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 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 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 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 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 pub fn get_file_deps(&self, file: &str) -> Result<FileDeps> {
222 let esc = file.replace('\'', "\\'");
223
224 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 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 pub fn get_type_hierarchy(&self, symbol_id: &str, max_depth: u32) -> Result<TypeHierarchy> {
271 let esc = symbol_id.replace('\'', "\\'");
272
273 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 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 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 pub fn get_test_coverage(&self) -> Result<TestCoverage> {
335 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 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 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#[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
880pub 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}