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 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 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 = 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 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 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#[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
884pub 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}