1use crate::core::property_graph::{CodeGraph, DependencyChain, Edge, EdgeKind, ImpactResult, Node};
7use crate::core::tokens::count_tokens;
8use crate::core::type_ref_edges::{DefIndex, ExtMethodIndex};
9use serde_json::{Value, json};
10use std::collections::BTreeSet;
11use std::path::Path;
12use std::process::Stdio;
13
14const GRAPH_SOURCE_EXTS: &[&str] = &[
19 "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "gd", "cs",
20];
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23enum OutputFormat {
24 Text,
25 Json,
26}
27
28fn parse_format(format: Option<&str>) -> Result<OutputFormat, String> {
29 let f = format.unwrap_or("text").trim().to_lowercase();
30 match f.as_str() {
31 "text" => Ok(OutputFormat::Text),
32 "json" => Ok(OutputFormat::Json),
33 _ => Err("Error: format must be text|json".to_string()),
34 }
35}
36
37pub fn handle(
38 action: &str,
39 path: Option<&str>,
40 root: &str,
41 depth: Option<usize>,
42 format: Option<&str>,
43) -> String {
44 let fmt = match parse_format(format) {
45 Ok(f) => f,
46 Err(e) => return e,
47 };
48
49 match action {
50 "analyze" => handle_analyze(path, root, depth.unwrap_or(5), fmt),
51 "diff" => handle_diff(root, depth.unwrap_or(5), fmt),
52 "chain" => handle_chain(path, root, fmt),
53 "build" => handle_build(root, fmt),
54 "update" => handle_update(root, fmt),
55 "status" => handle_status(root, fmt),
56 "parity" => handle_parity(root, fmt),
57 _ => "Unknown action. Use: analyze, diff, chain, build, status, update, parity".to_string(),
58 }
59}
60
61fn handle_parity(root: &str, fmt: OutputFormat) -> String {
65 let index = crate::core::graph_index::scan_with_content_cache(root).0;
72
73 let report = match crate::core::graph_parity::compare(&index) {
74 Ok(r) => r,
75 Err(e) => return format!("Parity comparison failed: {e}"),
76 };
77
78 match fmt {
79 OutputFormat::Json => {
80 let v = json!({
81 "tool": "ctx_impact",
82 "action": "parity",
83 "lossless": report.is_lossless(),
84 "files": report.files,
85 "symbols": { "gi": report.symbol_count_gi, "pg": report.symbol_count_pg,
86 "matched": report.symbols_matched, "checked": report.symbols_checked },
87 "edges": { "gi": report.edge_count_gi, "pg": report.edge_count_pg,
88 "superset": report.edge_pairs_lossless },
89 "dependencies": { "lossless": report.dependencies_lossless,
90 "checked": report.files_checked, "extra": report.dependencies_extra },
91 "dependents": { "lossless": report.dependents_lossless, "checked": report.files_checked },
92 "divergences": report.divergences,
93 });
94 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
95 }
96 OutputFormat::Text => {
97 let body = crate::core::graph_parity::format_report(&report);
98 let tokens = count_tokens(&body);
99 format!("{body}\n[ctx_impact parity: {tokens} tok]")
100 }
101 }
102}
103
104fn open_graph(root: &str) -> Result<CodeGraph, String> {
105 CodeGraph::open(root).map_err(|e| format!("Failed to open graph: {e}"))
106}
107
108fn open_graph_fresh(root: &str) -> Result<CodeGraph, String> {
115 let graph = open_graph(root)?;
116 let empty = graph.node_count().unwrap_or(0) == 0;
117 let outdated = !empty && crate::core::property_graph::engine_outdated(root);
118 if empty || outdated {
119 drop(graph);
120 let build_result = handle_build(root, OutputFormat::Text);
121 tracing::info!(
122 "Rebuilt property graph before impact query ({}): {}",
123 if empty { "empty" } else { "engine outdated" },
124 &build_result[..build_result.len().min(100)]
125 );
126 return open_graph(root);
127 }
128 Ok(graph)
129}
130
131fn handle_analyze(path: Option<&str>, root: &str, max_depth: usize, fmt: OutputFormat) -> String {
132 let Some(target) = path else {
133 return "path is required for 'analyze' action".to_string();
134 };
135
136 let graph = match open_graph_fresh(root) {
137 Ok(g) => g,
138 Err(e) => return e,
139 };
140
141 if graph.node_count().unwrap_or(0) == 0 {
142 return "Graph is empty after auto-build. No supported source files found.".to_string();
143 }
144
145 let rel_target = graph_target_key(target, root);
146
147 if graph.get_node_by_path(&rel_target).ok().flatten().is_some() {
149 let impact = match graph.impact_analysis(&rel_target, max_depth) {
150 Ok(r) => r,
151 Err(e) => return format!("Impact analysis failed: {e}"),
152 };
153 return format_impact(&impact, &rel_target, root, fmt);
154 }
155
156 let symbol = symbol_query_name(target);
162 if !symbol.is_empty()
163 && let Ok(def_files) = graph.resolve_symbol_def_files(&symbol)
164 && !def_files.is_empty()
165 {
166 return analyze_symbol(&graph, &symbol, &def_files, root, max_depth, fmt);
167 }
168
169 analyze_unresolved(&graph, target, &rel_target, root, fmt)
172}
173
174fn symbol_query_name(target: &str) -> String {
181 let base = target.rsplit(['/', '\\']).next().unwrap_or(target).trim();
182 let stem = base
183 .rsplit_once('.')
184 .filter(|(_, ext)| GRAPH_SOURCE_EXTS.contains(ext))
185 .map_or(base, |(s, _)| s);
186 if stem.is_empty()
187 || stem.contains(|c: char| {
188 c.is_whitespace() || matches!(c, '.' | ':' | '*' | '<' | '>' | '(' | ')' | '/' | '\\')
189 })
190 {
191 return String::new();
192 }
193 stem.to_string()
194}
195
196fn analyze_symbol(
201 graph: &CodeGraph,
202 symbol: &str,
203 def_files: &[String],
204 root: &str,
205 max_depth: usize,
206 fmt: OutputFormat,
207) -> String {
208 let mut affected: BTreeSet<String> = BTreeSet::new();
209 let mut max_depth_reached = 0usize;
210 let mut edges_traversed = 0usize;
211 for f in def_files {
212 if let Ok(r) = graph.impact_analysis(f, max_depth) {
213 max_depth_reached = max_depth_reached.max(r.max_depth_reached);
214 edges_traversed += r.edges_traversed;
215 affected.extend(r.affected_files);
216 }
217 }
218 for f in def_files {
220 affected.remove(f);
221 }
222
223 let mut sorted: Vec<String> = affected.into_iter().collect();
224 let total = sorted.len();
225 let limit = crate::core::budgets::IMPACT_AFFECTED_FILES_LIMIT.max(1);
226 let truncated = total > limit;
227 if truncated {
228 sorted.truncate(limit);
229 }
230
231 match fmt {
232 OutputFormat::Json => {
233 let v = json!({
234 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
235 "tool": "ctx_impact",
236 "action": "analyze",
237 "project": project_meta(root),
238 "graph": graph_summary(root),
239 "graph_meta": crate::core::property_graph::load_meta(root),
240 "target": symbol,
241 "resolved_from": "symbol",
242 "defined_in": def_files,
243 "max_depth_reached": max_depth_reached,
244 "edges_traversed": edges_traversed,
245 "affected_files_total": total,
246 "affected_files": sorted,
247 "truncated": truncated
248 });
249 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
250 }
251 OutputFormat::Text => {
252 let defined = def_files.join(", ");
253 if total == 0 {
254 let result = format!(
255 "No files depend on {symbol} (defined in {defined}); it is a leaf in the dependency graph."
256 );
257 let tokens = count_tokens(&result);
258 return format!("{result}\n[ctx_impact: {tokens} tok]");
259 }
260 let mut result = format!(
261 "Impact of changing {symbol} (defined in {defined}): {total} affected files \
262 (depth: {max_depth_reached}, edges traversed: {edges_traversed})\n"
263 );
264 for file in &sorted {
265 result.push_str(&format!(" {file}\n"));
266 }
267 if truncated {
268 result.push_str(&format!(" ... +{} more\n", total - limit));
269 }
270 let tokens = count_tokens(&result);
271 format!("{result}[ctx_impact: {tokens} tok]")
272 }
273 }
274}
275
276fn analyze_unresolved(
280 graph: &CodeGraph,
281 target: &str,
282 rel_target: &str,
283 root: &str,
284 fmt: OutputFormat,
285) -> String {
286 let files = graph.file_node_count().unwrap_or(0);
287 let symbols = graph.symbol_count().unwrap_or(0);
288 match fmt {
289 OutputFormat::Json => {
290 let v = json!({
291 "tool": "ctx_impact",
292 "action": "analyze",
293 "project": project_meta(root),
294 "graph": graph_summary(root),
295 "target": target,
296 "resolved": false,
297 "indexed_files": files,
298 "indexed_symbols": symbols,
299 "hint": "Target is neither an indexed file path nor a known symbol. Pass a path relative to the project root, or rebuild with action='build'."
300 });
301 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
302 }
303 OutputFormat::Text => {
304 let result = format!(
305 "'{target}' is not a known file or symbol in the graph \
306 ({files} files, {symbols} symbols indexed).\n \
307 - As a file: pass a path relative to the project root (looked up '{rel_target}').\n \
308 - As a class/type: check the spelling, or run ctx_impact action='build' to (re)index."
309 );
310 let tokens = count_tokens(&result);
311 format!("{result}\n[ctx_impact: {tokens} tok]")
312 }
313 }
314}
315
316fn format_impact(impact: &ImpactResult, target: &str, root: &str, fmt: OutputFormat) -> String {
317 let mut sorted = impact.affected_files.clone();
318 sorted.sort();
319
320 let total = sorted.len();
321 let limit = crate::core::budgets::IMPACT_AFFECTED_FILES_LIMIT.max(1);
322 let truncated = total > limit;
323 if truncated {
324 sorted.truncate(limit);
325 }
326
327 match fmt {
328 OutputFormat::Json => {
329 let v = json!({
330 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
331 "tool": "ctx_impact",
332 "action": "analyze",
333 "project": project_meta(root),
334 "graph": graph_summary(root),
335 "graph_meta": crate::core::property_graph::load_meta(root),
336 "target": target,
337 "max_depth_reached": impact.max_depth_reached,
338 "edges_traversed": impact.edges_traversed,
339 "affected_files_total": total,
340 "affected_files": sorted,
341 "truncated": truncated
342 });
343 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
344 }
345 OutputFormat::Text => {
346 if total == 0 {
347 let result =
348 format!("No files depend on {target} (leaf node in the dependency graph).");
349 let tokens = count_tokens(&result);
350 return format!("{result}\n[ctx_impact: {tokens} tok]");
351 }
352
353 let mut result = format!(
354 "Impact of changing {target}: {total} affected files (depth: {}, edges traversed: {})\n",
355 impact.max_depth_reached, impact.edges_traversed
356 );
357
358 for file in &sorted {
359 result.push_str(&format!(" {file}\n"));
360 }
361 if truncated {
362 result.push_str(&format!(" ... +{} more\n", total - limit));
363 }
364
365 let tokens = count_tokens(&result);
366 format!("{result}[ctx_impact: {tokens} tok]")
367 }
368 }
369}
370
371fn handle_diff(root: &str, max_depth: usize, fmt: OutputFormat) -> String {
372 let changed = git_changed_files(root);
373 if changed.is_empty() {
374 return match fmt {
375 OutputFormat::Json => {
376 let v = json!({
377 "tool": "ctx_impact",
378 "action": "diff",
379 "changed_files": [],
380 "blast_radius": [],
381 "total_affected": 0
382 });
383 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
384 }
385 OutputFormat::Text => "No uncommitted changes found.".to_string(),
386 };
387 }
388
389 let graph = match open_graph_fresh(root) {
390 Ok(g) => g,
391 Err(e) => return e,
392 };
393
394 compute_diff_impact(&graph, &changed, root, max_depth, fmt)
395}
396
397fn git_changed_files(root: &str) -> Vec<String> {
398 let output = std::process::Command::new("git")
399 .args(["diff", "--name-only", "HEAD"])
400 .current_dir(root)
401 .stdout(Stdio::piped())
402 .stderr(Stdio::null())
403 .output();
404
405 let mut files: BTreeSet<String> = BTreeSet::new();
406
407 if let Ok(o) = output
408 && o.status.success()
409 {
410 for line in String::from_utf8_lossy(&o.stdout).lines() {
411 let trimmed = line.trim();
412 if !trimmed.is_empty() {
413 files.insert(trimmed.to_string());
414 }
415 }
416 }
417
418 let staged = std::process::Command::new("git")
419 .args(["diff", "--name-only", "--cached"])
420 .current_dir(root)
421 .stdout(Stdio::piped())
422 .stderr(Stdio::null())
423 .output();
424
425 if let Ok(o) = staged
426 && o.status.success()
427 {
428 for line in String::from_utf8_lossy(&o.stdout).lines() {
429 let trimmed = line.trim();
430 if !trimmed.is_empty() {
431 files.insert(trimmed.to_string());
432 }
433 }
434 }
435
436 let untracked = std::process::Command::new("git")
437 .args(["ls-files", "--others", "--exclude-standard"])
438 .current_dir(root)
439 .stdout(Stdio::piped())
440 .stderr(Stdio::null())
441 .output();
442
443 if let Ok(o) = untracked
444 && o.status.success()
445 {
446 for line in String::from_utf8_lossy(&o.stdout).lines() {
447 let trimmed = line.trim();
448 if !trimmed.is_empty() {
449 files.insert(trimmed.to_string());
450 }
451 }
452 }
453
454 files.into_iter().collect()
455}
456
457fn compute_diff_impact(
458 graph: &CodeGraph,
459 changed: &[String],
460 root: &str,
461 max_depth: usize,
462 fmt: OutputFormat,
463) -> String {
464 let mut all_affected: BTreeSet<String> = BTreeSet::new();
465 let mut per_file: Vec<(String, Vec<String>)> = Vec::new();
466
467 for file in changed {
468 let rel = graph_target_key(file, root);
469 if let Ok(impact) = graph.impact_analysis(&rel, max_depth) {
470 let mut affected: Vec<String> = impact
471 .affected_files
472 .into_iter()
473 .filter(|f| !changed.contains(f))
474 .collect();
475 affected.sort();
476 for a in &affected {
477 all_affected.insert(a.clone());
478 }
479 if !affected.is_empty() {
480 per_file.push((rel, affected));
481 }
482 }
483 }
484
485 match fmt {
486 OutputFormat::Json => {
487 let items: Vec<Value> = per_file
488 .iter()
489 .map(|(file, affected)| {
490 json!({
491 "changed_file": file,
492 "affected": affected,
493 "count": affected.len()
494 })
495 })
496 .collect();
497 let v = json!({
498 "tool": "ctx_impact",
499 "action": "diff",
500 "changed_files": changed,
501 "blast_radius": items,
502 "total_affected": all_affected.len()
503 });
504 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
505 }
506 OutputFormat::Text => {
507 let mut result = format!(
508 "Diff Impact Analysis ({} changed files, {} blast radius)\n\n",
509 changed.len(),
510 all_affected.len()
511 );
512 result.push_str("Changed files:\n");
513 for f in changed.iter().take(30) {
514 result.push_str(&format!(" {f}\n"));
515 }
516
517 if !per_file.is_empty() {
518 result.push_str("\nBlast radius:\n");
519 for (file, affected) in per_file.iter().take(15) {
520 result.push_str(&format!(" {file} -> {} affected\n", affected.len()));
521 for a in affected.iter().take(10) {
522 result.push_str(&format!(" {a}\n"));
523 }
524 if affected.len() > 10 {
525 result.push_str(&format!(" ... +{} more\n", affected.len() - 10));
526 }
527 }
528 }
529
530 let tokens = count_tokens(&result);
531 format!("{result}\n[ctx_impact diff: {tokens} tok]")
532 }
533 }
534}
535
536fn handle_chain(path: Option<&str>, root: &str, fmt: OutputFormat) -> String {
537 let Some(spec) = path else {
538 return "path is required for 'chain' action (format: from_file->to_file)".to_string();
539 };
540
541 let (from, to) = match spec.split_once("->") {
542 Some((f, t)) => (f.trim(), t.trim()),
543 None => {
544 return format!(
545 "Invalid chain spec '{spec}'. Use format: from_file->to_file\n\
546 Example: src/server.rs->src/core/config.rs"
547 );
548 }
549 };
550
551 let graph = match open_graph_fresh(root) {
552 Ok(g) => g,
553 Err(e) => return e,
554 };
555
556 let rel_from = graph_target_key(from, root);
557 let rel_to = graph_target_key(to, root);
558
559 match graph.dependency_chain(&rel_from, &rel_to) {
560 Ok(Some(chain)) => format_chain(&chain, root, fmt),
561 Ok(None) => match fmt {
562 OutputFormat::Json => {
563 let v = json!({
564 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
565 "tool": "ctx_impact",
566 "action": "chain",
567 "project": project_meta(root),
568 "graph": graph_summary(root),
569 "graph_meta": crate::core::property_graph::load_meta(root),
570 "from": rel_from,
571 "to": rel_to,
572 "found": false
573 });
574 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
575 }
576 OutputFormat::Text => {
577 let result = format!("No dependency path from {rel_from} to {rel_to}");
578 let tokens = count_tokens(&result);
579 format!("{result}\n[ctx_impact chain: {tokens} tok]")
580 }
581 },
582 Err(e) => format!("Chain analysis failed: {e}"),
583 }
584}
585
586fn format_chain(chain: &DependencyChain, root: &str, fmt: OutputFormat) -> String {
587 match fmt {
588 OutputFormat::Json => {
589 let v = json!({
590 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
591 "tool": "ctx_impact",
592 "action": "chain",
593 "project": project_meta(root),
594 "graph": graph_summary(root),
595 "graph_meta": crate::core::property_graph::load_meta(root),
596 "found": true,
597 "depth": chain.depth,
598 "path": chain.path
599 });
600 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
601 }
602 OutputFormat::Text => {
603 let mut result = format!("Dependency chain (depth {}):\n", chain.depth);
604 for (i, step) in chain.path.iter().enumerate() {
605 if i > 0 {
606 result.push_str(" -> ");
607 } else {
608 result.push_str(" ");
609 }
610 result.push_str(step);
611 result.push('\n');
612 }
613 let tokens = count_tokens(&result);
614 format!("{result}[ctx_impact chain: {tokens} tok]")
615 }
616 }
617}
618
619fn graph_target_key(path: &str, root: &str) -> String {
620 let rel = crate::core::index_paths::graph_relative_key(path, root);
621 let rel_key = crate::core::index_paths::graph_match_key(&rel);
622 if rel_key.is_empty() {
623 crate::core::index_paths::graph_match_key(path)
624 } else {
625 rel_key
626 }
627}
628
629fn walk_supported_sources(root_path: &Path) -> (Vec<String>, Vec<(String, String, String)>) {
630 let walker = ignore::WalkBuilder::new(root_path)
631 .hidden(true)
632 .git_ignore(true)
633 .require_git(false)
634 .filter_entry(crate::core::walk_filter::keep_entry)
635 .build();
636
637 let mut file_paths: Vec<String> = Vec::new();
638 let mut file_contents: Vec<(String, String, String)> = Vec::new();
639
640 for entry in walker.flatten() {
641 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
642 continue;
643 }
644
645 let path = entry.path();
646 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
647
648 if !GRAPH_SOURCE_EXTS.contains(&ext) {
649 continue;
650 }
651
652 let rel_path = path
656 .strip_prefix(root_path)
657 .unwrap_or(path)
658 .to_string_lossy()
659 .replace('\\', "/");
660
661 file_paths.push(rel_path.clone());
662
663 if let Ok(content) = std::fs::read_to_string(path) {
664 file_contents.push((rel_path, content, ext.to_string()));
665 }
666 }
667
668 file_paths.sort();
669 file_paths.dedup();
670 file_contents.sort_by(|a, b| a.0.cmp(&b.0));
671 (file_paths, file_contents)
672}
673
674type AnalyzedFile<'a> = (
676 &'a str,
677 &'a str,
678 &'a str,
679 crate::core::deep_queries::DeepAnalysis,
680);
681
682fn analyze_all(
686 file_contents: &[(String, String, String)],
687) -> (Vec<AnalyzedFile<'_>>, DefIndex, ExtMethodIndex) {
688 use rayon::prelude::*;
689 let per_file: Vec<AnalyzedFile<'_>> = file_contents
690 .par_iter()
691 .map(|(p, c, e)| {
692 (
693 p.as_str(),
694 c.as_str(),
695 e.as_str(),
696 crate::core::deep_queries::analyze(c.as_str(), e.as_str()),
697 )
698 })
699 .collect();
700
701 let def_index =
704 crate::core::type_ref_edges::build_def_index(per_file.iter().map(|(p, _, _, a)| (*p, a)));
705 let ext_method_index = crate::core::type_ref_edges::build_ext_method_index(
706 per_file.iter().map(|(p, _, _, a)| (*p, a)),
707 );
708
709 (per_file, def_index, ext_method_index)
710}
711
712fn insert_type_ref_edges(
721 graph: &CodeGraph,
722 file_node_id: i64,
723 rel_path: &str,
724 type_uses: &[crate::core::deep_queries::TypeUse],
725 def_index: &DefIndex,
726 visible_ns: &std::collections::HashSet<String>,
727) -> usize {
728 let mut added = 0usize;
729 for (target_file, type_name, line_start, line_end) in
730 crate::core::type_ref_edges::type_ref_targets(def_index, type_uses, rel_path, visible_ns)
731 {
732 let Ok(target_id) = graph.upsert_node(&Node::file(&target_file)) else {
733 continue;
734 };
735 let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::TypeRef));
736 added += 1;
737
738 let sym_node = Node::symbol(
739 &type_name,
740 &target_file,
741 crate::core::property_graph::NodeKind::Symbol,
742 )
743 .with_lines(line_start, line_end);
744 if let Ok(sym_id) = graph.upsert_node(&sym_node) {
745 let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::TypeRef));
746 added += 1;
747 }
748 }
749 added
750}
751
752fn insert_ext_method_edges(
759 graph: &CodeGraph,
760 file_node_id: i64,
761 rel_path: &str,
762 calls: &[crate::core::deep_queries::CallSite],
763 ext_method_index: &ExtMethodIndex,
764) -> usize {
765 let mut added = 0usize;
766 for (target_file, method_name, line_start, line_end) in
767 crate::core::type_ref_edges::ext_method_targets(ext_method_index, calls, rel_path)
768 {
769 let Ok(target_id) = graph.upsert_node(&Node::file(&target_file)) else {
770 continue;
771 };
772 let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::TypeRef));
773 added += 1;
774
775 let sym_node = Node::symbol(
776 &method_name,
777 &target_file,
778 crate::core::property_graph::NodeKind::Symbol,
779 )
780 .with_lines(line_start, line_end);
781 if let Ok(sym_id) = graph.upsert_node(&sym_node) {
782 let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::TypeRef));
783 added += 1;
784 }
785 }
786 added
787}
788
789fn normalize_git_path(line: &str) -> String {
790 line.trim().replace('\\', "/")
791}
792
793fn git_diff_name_only_lines(project_root: &Path, args: &[&str]) -> Option<Vec<String>> {
794 let out = std::process::Command::new("git")
795 .args(args)
796 .current_dir(project_root)
797 .stdout(Stdio::piped())
798 .stderr(Stdio::null())
799 .output()
800 .ok()?;
801 if !out.status.success() {
802 return None;
803 }
804 let s = String::from_utf8(out.stdout).ok()?;
805 Some(
806 s.lines()
807 .map(normalize_git_path)
808 .filter(|l| !l.is_empty())
809 .collect(),
810 )
811}
812
813fn collect_git_changed_paths(project_root: &Path, last_git_head: &str) -> Option<BTreeSet<String>> {
814 let range = format!("{last_git_head}..HEAD");
815 let mut set: BTreeSet<String> = BTreeSet::new();
816 for line in git_diff_name_only_lines(project_root, &["diff", "--name-only", &range])? {
817 set.insert(line);
818 }
819 for line in git_diff_name_only_lines(project_root, &["diff", "--name-only"])? {
820 set.insert(line);
821 }
822 for line in git_diff_name_only_lines(project_root, &["diff", "--name-only", "--cached"])? {
823 set.insert(line);
824 }
825 Some(set)
826}
827
828#[cfg(feature = "embeddings")]
829fn enclosing_symbol_name_for_line(
830 types: &[crate::core::deep_queries::TypeDef],
831 line: usize,
832) -> String {
833 let mut best: Option<(&crate::core::deep_queries::TypeDef, usize)> = None;
834 for t in types {
835 if line >= t.line && line <= t.end_line {
836 let span = t.end_line.saturating_sub(t.line);
837 match best {
838 None => best = Some((t, span)),
839 Some((_, prev_span)) => {
840 if span < prev_span {
841 best = Some((t, span));
842 }
843 }
844 }
845 }
846 }
847 best.map_or_else(|| "<module>".to_string(), |(t, _)| t.name.clone())
848}
849
850#[cfg(feature = "embeddings")]
851fn resolve_call_callee_site(
852 def_index: &DefIndex,
853 callee: &str,
854 caller_file: &str,
855) -> Option<(String, usize, usize)> {
856 let sites = def_index.get(callee)?;
857 for (f, _ns, ls, le) in sites {
858 if f == caller_file {
859 return Some((f.clone(), *ls, *le));
860 }
861 }
862 let mut sorted: Vec<(String, usize, usize)> = sites
863 .iter()
864 .map(|(f, _ns, ls, le)| (f.clone(), *ls, *le))
865 .collect();
866 sorted.sort_by(|a, b| a.0.cmp(&b.0));
867 sorted.into_iter().next()
868}
869
870#[cfg(feature = "embeddings")]
871fn index_graph_file_embeddings(
872 graph: &CodeGraph,
873 rel_path: &str,
874 ext: &str,
875 analysis: &crate::core::deep_queries::DeepAnalysis,
876 resolver_ctx: &crate::core::import_resolver::ResolverContext,
877 def_index: &DefIndex,
878 ext_method_index: &ExtMethodIndex,
879) -> (usize, usize) {
880 let mut total_nodes = 0usize;
881 let mut total_edges = 0usize;
882
883 let Ok(file_node_id) = graph.upsert_node(&Node::file(rel_path)) else {
884 return (0, 0);
885 };
886 total_nodes += 1;
887
888 for type_def in &analysis.types {
889 let sym_node = Node::symbol(
890 &type_def.name,
891 rel_path,
892 crate::core::property_graph::NodeKind::Symbol,
893 )
894 .with_lines(type_def.line, type_def.end_line);
895 if let Ok(sym_id) = graph.upsert_node(&sym_node) {
896 total_nodes += 1;
897 let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::Defines));
898 total_edges += 1;
899 if type_def.is_exported {
900 let _ = graph.upsert_edge(&Edge::new(sym_id, file_node_id, EdgeKind::Exports));
901 total_edges += 1;
902 }
903 }
904 }
905
906 let resolved = crate::core::import_resolver::resolve_imports(
907 &analysis.imports,
908 rel_path,
909 ext,
910 resolver_ctx,
911 );
912
913 let mut targets: Vec<String> = resolved
914 .into_iter()
915 .filter(|imp| !imp.is_external)
916 .filter_map(|imp| imp.resolved_path)
917 .collect();
918 targets.sort();
919 targets.dedup();
920
921 for target_path in targets {
922 let Ok(target_id) = graph.upsert_node(&Node::file(&target_path)) else {
923 continue;
924 };
925 let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::Imports));
926 total_edges += 1;
927 }
928
929 for call in &analysis.calls {
930 let caller_name = enclosing_symbol_name_for_line(&analysis.types, call.line);
931 let mut caller_node = Node::symbol(
932 &caller_name,
933 rel_path,
934 crate::core::property_graph::NodeKind::Symbol,
935 );
936 if let Some(t) = analysis.types.iter().find(|t| t.name == caller_name) {
937 caller_node = caller_node.with_lines(t.line, t.end_line);
938 }
939 let Ok(caller_id) = graph.upsert_node(&caller_node) else {
940 continue;
941 };
942 total_nodes += 1;
943
944 let Some((callee_file, c_line, c_end)) =
945 resolve_call_callee_site(def_index, &call.callee, rel_path)
946 else {
947 continue;
948 };
949
950 let callee_node = Node::symbol(
951 &call.callee,
952 &callee_file,
953 crate::core::property_graph::NodeKind::Symbol,
954 )
955 .with_lines(c_line, c_end);
956 let Ok(callee_id) = graph.upsert_node(&callee_node) else {
957 continue;
958 };
959 total_nodes += 1;
960 let _ = graph.upsert_edge(&Edge::new(caller_id, callee_id, EdgeKind::Calls));
961 total_edges += 1;
962
963 if callee_file != rel_path {
964 let Ok(callee_file_id) = graph.upsert_node(&Node::file(&callee_file)) else {
965 continue;
966 };
967 let _ = graph.upsert_edge(&Edge::new(file_node_id, callee_file_id, EdgeKind::Calls));
968 total_edges += 1;
969 }
970 }
971
972 let visible_ns = if ext == "cs" {
976 crate::core::type_ref_edges::csharp_visible_namespaces(analysis)
977 } else {
978 std::collections::HashSet::new()
979 };
980 total_edges += insert_type_ref_edges(
981 graph,
982 file_node_id,
983 rel_path,
984 &analysis.type_uses,
985 def_index,
986 &visible_ns,
987 );
988 total_edges += insert_ext_method_edges(
990 graph,
991 file_node_id,
992 rel_path,
993 &analysis.calls,
994 ext_method_index,
995 );
996
997 (total_nodes, total_edges)
998}
999
1000#[cfg(not(feature = "embeddings"))]
1001fn index_graph_file_minimal(
1002 graph: &CodeGraph,
1003 rel_path: &str,
1004 content: &str,
1005 ext: &str,
1006 analysis: &crate::core::deep_queries::DeepAnalysis,
1007 resolver_ctx: &crate::core::import_resolver::ResolverContext,
1008 def_index: &DefIndex,
1009 ext_method_index: &ExtMethodIndex,
1010) -> (usize, usize) {
1011 let Ok(file_node_id) = graph.upsert_node(&Node::file(rel_path)) else {
1012 return (0, 0);
1013 };
1014 let mut total_nodes = 1usize;
1015 let mut total_edges = 0usize;
1016
1017 let resolved = crate::core::import_resolver::resolve_imports(
1018 &analysis.imports,
1019 rel_path,
1020 ext,
1021 resolver_ctx,
1022 );
1023
1024 let mut targets: Vec<String> = resolved
1025 .into_iter()
1026 .filter(|imp| !imp.is_external)
1027 .filter_map(|imp| imp.resolved_path)
1028 .filter(|p| p != rel_path)
1029 .collect();
1030 targets.sort();
1031 targets.dedup();
1032
1033 for target_path in targets {
1034 let Ok(target_id) = graph.upsert_node(&Node::file(&target_path)) else {
1035 continue;
1036 };
1037 total_nodes += 1;
1038 let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::Imports));
1039 total_edges += 1;
1040 }
1041
1042 for type_def in &analysis.types {
1043 if type_def.is_exported {
1044 let sym_node = Node::symbol(
1045 &type_def.name,
1046 rel_path,
1047 crate::core::property_graph::NodeKind::Symbol,
1048 )
1049 .with_lines(type_def.line, type_def.end_line);
1050 if let Ok(sym_id) = graph.upsert_node(&sym_node) {
1051 total_nodes += 1;
1052 let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::Defines));
1053 let _ = graph.upsert_edge(&Edge::new(sym_id, file_node_id, EdgeKind::Exports));
1054 total_edges += 2;
1055 }
1056 }
1057 }
1058
1059 let visible_ns = if ext == "cs" {
1062 crate::core::type_ref_edges::csharp_visible_namespaces(analysis)
1063 } else {
1064 std::collections::HashSet::new()
1065 };
1066 total_edges += insert_type_ref_edges(
1067 graph,
1068 file_node_id,
1069 rel_path,
1070 &analysis.type_uses,
1071 def_index,
1072 &visible_ns,
1073 );
1074 total_edges += insert_ext_method_edges(
1075 graph,
1076 file_node_id,
1077 rel_path,
1078 &analysis.calls,
1079 ext_method_index,
1080 );
1081
1082 let exports: Vec<String> = analysis
1083 .types
1084 .iter()
1085 .filter(|t| t.is_exported)
1086 .map(|t| t.name.clone())
1087 .collect();
1088 let line_count = content.lines().count();
1089 let token_count = crate::core::tokens::count_tokens(content);
1090 let hash = {
1091 use md5::{Digest, Md5};
1092 let mut h = Md5::new();
1093 h.update(content.as_bytes());
1094 crate::core::agent_identity::hex_encode(&h.finalize())
1095 };
1096 let _ = graph.upsert_file_catalog(&crate::core::property_graph::FileCatalogEntry {
1097 path: rel_path.to_string(),
1098 hash,
1099 language: ext.to_string(),
1100 line_count,
1101 token_count,
1102 exports,
1103 summary: String::new(),
1104 });
1105
1106 (total_nodes, total_edges)
1107}
1108
1109fn handle_build(root: &str, fmt: OutputFormat) -> String {
1110 let t0 = std::time::Instant::now();
1111 let root_path = Path::new(root);
1112
1113 let graph = match open_graph(root) {
1114 Ok(g) => g,
1115 Err(e) => return e,
1116 };
1117
1118 let incremental_hint: Option<&'static str> = {
1119 let nodes_ok = graph.node_count().unwrap_or(0) > 0;
1120 let has_head = crate::core::property_graph::load_meta(root)
1121 .and_then(|m| m.git_head)
1122 .is_some_and(|s| !s.is_empty());
1123 if nodes_ok && has_head {
1124 Some(
1125 "Hint: Graph already indexed — for faster refresh, use ctx_impact action='update' \
1126 to apply incremental git-based updates instead of a full rebuild.",
1127 )
1128 } else {
1129 None
1130 }
1131 };
1132
1133 if let Err(e) = graph.clear() {
1134 return format!("Failed to clear graph: {e}");
1135 }
1136
1137 let (file_paths, file_contents) = walk_supported_sources(root_path);
1138
1139 let cs_contents: std::collections::HashMap<String, String> = file_contents
1140 .iter()
1141 .filter(|(_, _, e)| e.eq_ignore_ascii_case("cs"))
1142 .map(|(p, c, _)| (p.clone(), c.clone()))
1143 .collect();
1144 let resolver_ctx = crate::core::import_resolver::ResolverContext::new(
1145 root_path,
1146 file_paths.clone(),
1147 &cs_contents,
1148 );
1149
1150 let mut total_nodes = 0usize;
1151 let mut total_edges = 0usize;
1152
1153 let (per_file, def_index, ext_method_index) = analyze_all(&file_contents);
1154
1155 #[cfg(feature = "embeddings")]
1156 for (rel_path, _content, ext, analysis) in &per_file {
1157 let (n, e) = index_graph_file_embeddings(
1158 &graph,
1159 rel_path,
1160 ext,
1161 analysis,
1162 &resolver_ctx,
1163 &def_index,
1164 &ext_method_index,
1165 );
1166 total_nodes += n;
1167 total_edges += e;
1168 }
1169
1170 #[cfg(not(feature = "embeddings"))]
1171 for (rel_path, content, ext, analysis) in &per_file {
1172 let (n, e) = index_graph_file_minimal(
1173 &graph,
1174 rel_path,
1175 content,
1176 ext,
1177 analysis,
1178 &resolver_ctx,
1179 &def_index,
1180 &ext_method_index,
1181 );
1182 total_nodes += n;
1183 total_edges += e;
1184 }
1185
1186 let build_time_ms = t0.elapsed().as_millis() as u64;
1187
1188 let db_display = graph.db_path().display();
1189 let mut result = format!(
1190 "Graph built: {total_nodes} nodes, {total_edges} edges from {} files\n\
1191 Stored at: {db_display}\n\
1192 Build time: {build_time_ms}ms",
1193 file_contents.len(),
1194 );
1195 if let Some(h) = incremental_hint {
1196 result.push('\n');
1197 result.push_str(h);
1198 }
1199
1200 let _ = crate::core::property_graph::write_meta(
1201 root,
1202 &crate::core::property_graph::PropertyGraphMetaV1 {
1203 schema_version: 1,
1204 engine_version: crate::core::property_graph::GRAPH_ENGINE_VERSION,
1205 built_with: env!("CARGO_PKG_VERSION").to_string(),
1206 project_root: crate::core::graph_index::normalize_project_root(root),
1207 built_at: chrono::Utc::now().to_rfc3339(),
1208 git_head: git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1209 git_dirty: Some(git_dirty(root_path)),
1210 nodes: graph.node_count().ok(),
1211 edges: graph.edge_count().ok(),
1212 files_indexed: Some(file_contents.len()),
1213 build_time_ms: Some(build_time_ms),
1214 },
1215 );
1216
1217 let tokens = count_tokens(&result);
1218 match fmt {
1219 OutputFormat::Json => {
1220 let mut v = serde_json::json!({
1221 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1222 "tool": "ctx_impact",
1223 "action": "build",
1224 "project": project_meta(root),
1225 "graph": graph_summary(root),
1226 "graph_meta": crate::core::property_graph::load_meta(root),
1227 "indexed_files": file_contents.len(),
1228 "nodes": total_nodes,
1229 "edges": total_edges,
1230 "build_time_ms": build_time_ms,
1231 "db_path": graph.db_path().display().to_string()
1232 });
1233 if let Some(h) = incremental_hint {
1234 v.as_object_mut()
1235 .map(|m| m.insert("incremental_hint".to_string(), json!(h)));
1236 }
1237 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1238 }
1239 OutputFormat::Text => format!("{result}\n[ctx_impact build: {tokens} tok]"),
1240 }
1241}
1242
1243fn handle_update(root: &str, fmt: OutputFormat) -> String {
1244 let t0 = std::time::Instant::now();
1245 let root_path = Path::new(root);
1246
1247 let graph = match open_graph(root) {
1248 Ok(g) => g,
1249 Err(e) => return e,
1250 };
1251
1252 if graph.node_count().unwrap_or(0) == 0 {
1253 return handle_build(root, fmt);
1254 }
1255
1256 let Some(meta) = crate::core::property_graph::load_meta(root) else {
1257 return handle_build(root, fmt);
1258 };
1259
1260 let Some(last_git_head) = meta.git_head.filter(|s| !s.is_empty()) else {
1261 return handle_build(root, fmt);
1262 };
1263
1264 let Some(changed) = collect_git_changed_paths(root_path, &last_git_head) else {
1265 return handle_build(root, fmt);
1266 };
1267
1268 let changed_count = changed.len();
1269 let (file_paths, file_contents) = walk_supported_sources(root_path);
1270 let cs_contents: std::collections::HashMap<String, String> = file_contents
1271 .iter()
1272 .filter(|(_, _, e)| e.eq_ignore_ascii_case("cs"))
1273 .map(|(p, c, _)| (p.clone(), c.clone()))
1274 .collect();
1275 let resolver_ctx = crate::core::import_resolver::ResolverContext::new(
1276 root_path,
1277 file_paths.clone(),
1278 &cs_contents,
1279 );
1280
1281 let (per_file, def_index, ext_method_index) = analyze_all(&file_contents);
1282
1283 let mut total_nodes = 0usize;
1284 let mut total_edges = 0usize;
1285
1286 for rel_path in &changed {
1287 let p = Path::new(rel_path);
1288 let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
1289 let supported = GRAPH_SOURCE_EXTS.contains(&ext);
1290 let abs = root_path.join(rel_path);
1291
1292 if !abs.exists() {
1293 if supported {
1294 let _ = graph.remove_file_nodes(rel_path);
1295 }
1296 continue;
1297 }
1298
1299 if !supported {
1300 continue;
1301 }
1302
1303 if let Err(e) = graph.remove_file_nodes(rel_path) {
1304 return format!("Failed to remove old nodes for {rel_path}: {e}");
1305 }
1306
1307 let Some((_, _content, ext_owned, analysis)) =
1308 per_file.iter().find(|(p, _, _, _)| *p == rel_path)
1309 else {
1310 continue;
1311 };
1312
1313 #[cfg(feature = "embeddings")]
1314 {
1315 let (n, e) = index_graph_file_embeddings(
1316 &graph,
1317 rel_path,
1318 ext_owned,
1319 analysis,
1320 &resolver_ctx,
1321 &def_index,
1322 &ext_method_index,
1323 );
1324 total_nodes += n;
1325 total_edges += e;
1326 }
1327
1328 #[cfg(not(feature = "embeddings"))]
1329 {
1330 let (n, e) = index_graph_file_minimal(
1331 &graph,
1332 rel_path,
1333 _content,
1334 ext_owned,
1335 analysis,
1336 &resolver_ctx,
1337 &def_index,
1338 &ext_method_index,
1339 );
1340 total_nodes += n;
1341 total_edges += e;
1342 }
1343 }
1344
1345 let elapsed_ms = t0.elapsed().as_millis() as u64;
1346
1347 let _ = crate::core::property_graph::write_meta(
1348 root,
1349 &crate::core::property_graph::PropertyGraphMetaV1 {
1350 schema_version: 1,
1351 engine_version: crate::core::property_graph::GRAPH_ENGINE_VERSION,
1352 built_with: env!("CARGO_PKG_VERSION").to_string(),
1353 project_root: crate::core::graph_index::normalize_project_root(root),
1354 built_at: chrono::Utc::now().to_rfc3339(),
1355 git_head: git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1356 git_dirty: Some(git_dirty(root_path)),
1357 nodes: graph.node_count().ok(),
1358 edges: graph.edge_count().ok(),
1359 files_indexed: Some(file_contents.len()),
1360 build_time_ms: Some(elapsed_ms),
1361 },
1362 );
1363
1364 let summary = format!(
1365 "Incremental update: {changed_count} files changed, {total_nodes} nodes updated, {total_edges} edges added ({elapsed_ms}ms)"
1366 );
1367
1368 let tokens = count_tokens(&summary);
1369 match fmt {
1370 OutputFormat::Json => {
1371 let v = json!({
1372 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1373 "tool": "ctx_impact",
1374 "action": "update",
1375 "project": project_meta(root),
1376 "graph": graph_summary(root),
1377 "graph_meta": crate::core::property_graph::load_meta(root),
1378 "git_range_from": last_git_head,
1379 "files_changed_reported": changed_count,
1380 "nodes_added": total_nodes,
1381 "edges_added": total_edges,
1382 "update_time_ms": elapsed_ms,
1383 "db_path": graph.db_path().display().to_string()
1384 });
1385 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1386 }
1387 OutputFormat::Text => format!("{summary}\n[ctx_impact update: {tokens} tok]"),
1388 }
1389}
1390
1391fn handle_status(root: &str, fmt: OutputFormat) -> String {
1392 let graph = match open_graph(root) {
1393 Ok(g) => g,
1394 Err(e) => return e,
1395 };
1396
1397 let nodes = graph.node_count().unwrap_or(0);
1398 let edges = graph.edge_count().unwrap_or(0);
1399
1400 if nodes == 0 {
1401 return match fmt {
1402 OutputFormat::Json => {
1403 let v = json!({
1404 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1405 "tool": "ctx_impact",
1406 "action": "status",
1407 "project": project_meta(root),
1408 "graph": graph_summary(root),
1409 "freshness": "empty",
1410 "hint": "Run ctx_impact action='build' to index."
1411 });
1412 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1413 }
1414 OutputFormat::Text => {
1415 "Graph is empty. Run ctx_impact action='build' to index.".to_string()
1416 }
1417 };
1418 }
1419
1420 let root_path = Path::new(root);
1421 let meta = crate::core::property_graph::load_meta(root);
1422 let current_head = git_out(root_path, &["rev-parse", "--short", "HEAD"]);
1423 let current_dirty = git_dirty(root_path);
1424 let stale = meta.as_ref().is_some_and(|m| {
1425 let head_mismatch = match (m.git_head.as_ref(), current_head.as_ref()) {
1426 (Some(a), Some(b)) => a != b,
1427 _ => false,
1428 };
1429 let dirty_mismatch = match (m.git_dirty, Some(current_dirty)) {
1430 (Some(a), Some(b)) => a != b,
1431 _ => false,
1432 };
1433 head_mismatch || dirty_mismatch
1434 });
1435 let freshness = if stale { "stale" } else { "fresh" };
1436
1437 match fmt {
1438 OutputFormat::Json => {
1439 let v = json!({
1440 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1441 "tool": "ctx_impact",
1442 "action": "status",
1443 "project": project_meta(root),
1444 "graph": graph_summary(root),
1445 "freshness": freshness,
1446 "meta": meta
1447 });
1448 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1449 }
1450 OutputFormat::Text => {
1451 let db_display = graph.db_path().display();
1452 let mut out =
1453 format!("Property Graph: {nodes} nodes, {edges} edges\nStored: {db_display}");
1454 if stale {
1455 out.push_str("\nWARNING: graph looks stale (git HEAD / dirty mismatch). Run ctx_impact action='build' to refresh.");
1456 }
1457 out
1458 }
1459 }
1460}
1461
1462fn project_meta(root: &str) -> Value {
1463 let root_hash = crate::core::project_hash::hash_project_root(root);
1464 let identity_hash = crate::core::project_hash::project_identity(root)
1465 .as_deref()
1466 .map(crate::core::hasher::hash_str);
1467
1468 let root_path = Path::new(root);
1469 json!({
1470 "project_root_hash": root_hash,
1471 "project_identity_hash": identity_hash,
1472 "git": {
1473 "head": git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1474 "branch": git_out(root_path, &["rev-parse", "--abbrev-ref", "HEAD"]),
1475 "dirty": git_dirty(root_path)
1476 }
1477 })
1478}
1479
1480fn graph_summary(project_root: &str) -> Value {
1481 let graph_dir = crate::core::property_graph::graph_dir(project_root);
1482 let db_path = graph_dir.join("graph.db");
1483 let db_path_display = db_path.display().to_string();
1484 if !db_path.exists() {
1485 return json!({
1486 "exists": false,
1487 "db_path": db_path_display,
1488 "nodes": null,
1489 "edges": null
1490 });
1491 }
1492 match crate::core::property_graph::CodeGraph::open(project_root) {
1493 Ok(g) => json!({
1494 "exists": true,
1495 "db_path": g.db_path().display().to_string(),
1496 "nodes": g.node_count().ok(),
1497 "edges": g.edge_count().ok()
1498 }),
1499 Err(_) => json!({
1500 "exists": true,
1501 "db_path": db_path_display,
1502 "nodes": null,
1503 "edges": null
1504 }),
1505 }
1506}
1507
1508fn git_dirty(project_root: &Path) -> bool {
1509 let out = std::process::Command::new("git")
1510 .args(["status", "--porcelain"])
1511 .current_dir(project_root)
1512 .stdout(std::process::Stdio::piped())
1513 .stderr(std::process::Stdio::null())
1514 .output();
1515 match out {
1516 Ok(o) if o.status.success() => !o.stdout.is_empty(),
1517 _ => false,
1518 }
1519}
1520
1521fn git_out(project_root: &Path, args: &[&str]) -> Option<String> {
1522 let out = std::process::Command::new("git")
1523 .args(args)
1524 .current_dir(project_root)
1525 .stdout(std::process::Stdio::piped())
1526 .stderr(std::process::Stdio::null())
1527 .output()
1528 .ok()?;
1529 if !out.status.success() {
1530 return None;
1531 }
1532 let s = String::from_utf8(out.stdout).ok()?;
1533 let s = s.trim().to_string();
1534 if s.is_empty() { None } else { Some(s) }
1535}
1536
1537#[cfg(test)]
1538mod tests {
1539 use super::*;
1540
1541 #[test]
1542 fn format_impact_empty() {
1543 let impact = ImpactResult {
1544 root_file: "a.rs".to_string(),
1545 affected_files: vec![],
1546 max_depth_reached: 0,
1547 edges_traversed: 0,
1548 };
1549 let result = format_impact(&impact, "a.rs", "/tmp", OutputFormat::Text);
1550 assert!(result.contains("No files depend on"));
1551 }
1552
1553 #[test]
1554 fn format_impact_with_files() {
1555 let impact = ImpactResult {
1556 root_file: "a.rs".to_string(),
1557 affected_files: vec!["b.rs".to_string(), "c.rs".to_string()],
1558 max_depth_reached: 2,
1559 edges_traversed: 3,
1560 };
1561 let result = format_impact(&impact, "a.rs", "/tmp", OutputFormat::Text);
1562 assert!(result.contains("2 affected files"));
1563 assert!(result.contains("b.rs"));
1564 assert!(result.contains("c.rs"));
1565 }
1566
1567 #[test]
1568 fn format_chain_display() {
1569 let chain = DependencyChain {
1570 path: vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()],
1571 depth: 2,
1572 };
1573 let result = format_chain(&chain, "/tmp", OutputFormat::Text);
1574 assert!(result.contains("depth 2"));
1575 assert!(result.contains("a.rs"));
1576 assert!(result.contains("-> b.rs"));
1577 assert!(result.contains("-> c.rs"));
1578 }
1579
1580 #[test]
1581 fn handle_missing_path() {
1582 let result = handle("analyze", None, "/tmp", None, None);
1583 assert!(result.contains("path is required"));
1584 }
1585
1586 #[test]
1587 fn handle_invalid_chain_spec() {
1588 let result = handle("chain", Some("no_arrow_here"), "/tmp", None, None);
1589 assert!(result.contains("Invalid chain spec"));
1590 }
1591
1592 #[test]
1593 fn handle_unknown_action() {
1594 let result = handle("invalid", None, "/tmp", None, None);
1595 assert!(result.contains("Unknown action"));
1596 }
1597
1598 #[test]
1599 fn graph_target_key_normalizes_windows_styles() {
1600 let target = graph_target_key(r"C:/repo/src/main.rs", r"C:\repo");
1601 let expected = if cfg!(windows) {
1602 "src/main.rs"
1603 } else {
1604 "C:/repo/src/main.rs"
1605 };
1606 assert_eq!(target, expected);
1607 }
1608
1609 #[cfg(feature = "embeddings")]
1616 #[test]
1617 fn dead_code_builder_does_not_flag_instantiated_python_class() {
1618 let _env = crate::core::data_dir::test_env_lock();
1624 let tmp = tempfile::tempdir().expect("tempdir");
1625 let root = tmp.path();
1626 std::fs::create_dir_all(root.join("models")).unwrap();
1627 std::fs::write(
1628 root.join("models/engine.py"),
1629 "class Engine:\n def __init__(self, power):\n self.power = power\n\n\n\
1630 class Pipeline:\n def __init__(self, cfg):\n self.cfg = cfg\n\n\n\
1631 class UnusedOrphan:\n pass\n",
1632 )
1633 .unwrap();
1634 std::fs::write(
1635 root.join("app.py"),
1636 "from models.engine import Engine, Pipeline\n\n\
1637 engine = Engine(power=100)\npipeline = Pipeline(cfg={})\n",
1638 )
1639 .unwrap();
1640
1641 let root_str = root.to_string_lossy().to_string();
1642 let out = handle("build", None, &root_str, None, Some("text"));
1643 assert!(!out.contains("ERROR"), "graph build failed: {out}");
1644
1645 let graph =
1646 crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
1647 let findings = crate::core::smells::scan_rule(
1648 graph.connection(),
1649 "dead_code",
1650 &crate::core::smells::SmellConfig::default(),
1651 );
1652 let dead: Vec<String> = findings.iter().filter_map(|f| f.symbol.clone()).collect();
1653
1654 assert!(
1655 !dead.iter().any(|s| s == "Engine"),
1656 "instantiated class `Engine` must not be dead_code; findings: {dead:?}"
1657 );
1658 assert!(
1659 !dead.iter().any(|s| s == "Pipeline"),
1660 "instantiated class `Pipeline` must not be dead_code; findings: {dead:?}"
1661 );
1662 assert!(
1663 dead.iter().any(|s| s == "UnusedOrphan"),
1664 "never-referenced class `UnusedOrphan` should still be flagged (non-vacuous); \
1665 findings: {dead:?}"
1666 );
1667 }
1668
1669 #[cfg(feature = "embeddings")]
1676 #[test]
1677 fn csharp_same_namespace_type_use_is_not_a_leaf() {
1678 let _env = crate::core::data_dir::test_env_lock();
1679 let tmp = tempfile::tempdir().expect("tempdir");
1680 let root = tmp.path();
1681 std::fs::create_dir_all(root.join("Models")).unwrap();
1682 std::fs::create_dir_all(root.join("Services")).unwrap();
1683
1684 std::fs::write(
1686 root.join("Models/Engine.cs"),
1687 "namespace App.Core;\n\n\
1688 public class Engine\n{\n public int Power { get; set; }\n}\n",
1689 )
1690 .unwrap();
1691 std::fs::write(
1693 root.join("Services/Motor.cs"),
1694 "namespace App.Core;\n\n\
1695 public class Motor\n{\n private readonly Engine _engine;\n\n\
1696 \x20 public Motor(Engine engine)\n {\n _engine = engine;\n }\n}\n",
1697 )
1698 .unwrap();
1699 std::fs::write(
1701 root.join("Services/TurboEngine.cs"),
1702 "namespace App.Core;\n\n\
1703 public class TurboEngine : Engine\n{\n public int Boost { get; set; }\n}\n",
1704 )
1705 .unwrap();
1706 std::fs::write(
1708 root.join("Services/Logger.cs"),
1709 "namespace App.Core;\n\n\
1710 public class Logger\n{\n public void Log(string msg) { }\n}\n",
1711 )
1712 .unwrap();
1713
1714 let root_str = root.to_string_lossy().to_string();
1715 let out = handle("build", None, &root_str, None, Some("text"));
1716 assert!(!out.contains("ERROR"), "graph build failed: {out}");
1717
1718 let graph =
1719 crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
1720 let impact = graph
1721 .impact_analysis("Models/Engine.cs", 5)
1722 .expect("impact analysis");
1723
1724 assert!(
1725 impact
1726 .affected_files
1727 .contains(&"Services/Motor.cs".to_string()),
1728 "DI consumer (field + ctor param, no using, no new) must be affected; got: {:?}",
1729 impact.affected_files
1730 );
1731 assert!(
1732 impact
1733 .affected_files
1734 .contains(&"Services/TurboEngine.cs".to_string()),
1735 "subclass (base_list, no using) must be affected; got: {:?}",
1736 impact.affected_files
1737 );
1738 assert!(
1739 !impact
1740 .affected_files
1741 .contains(&"Services/Logger.cs".to_string()),
1742 "unrelated file must NOT be affected; got: {:?}",
1743 impact.affected_files
1744 );
1745
1746 let findings = crate::core::smells::scan_rule(
1751 graph.connection(),
1752 "dead_code",
1753 &crate::core::smells::SmellConfig::default(),
1754 );
1755 let dead: Vec<String> = findings.iter().filter_map(|f| f.symbol.clone()).collect();
1756 assert!(
1757 !dead.iter().any(|s| s == "Engine"),
1758 "type-consumed class `Engine` must not be dead_code; findings: {dead:?}"
1759 );
1760 assert!(
1761 dead.iter().any(|s| s == "Logger"),
1762 "never-referenced class `Logger` should still be flagged (non-vacuous); \
1763 findings: {dead:?}"
1764 );
1765 }
1766
1767 #[cfg(feature = "tree-sitter")]
1776 #[test]
1777 fn csharp_blast_radius_survives_background_reindex() {
1778 let _env = crate::core::data_dir::test_env_lock();
1779 let tmp = tempfile::tempdir().expect("tempdir");
1780 let root = tmp.path();
1781 std::fs::create_dir_all(root.join(".git")).unwrap();
1783 std::fs::create_dir_all(root.join("Models")).unwrap();
1784 std::fs::create_dir_all(root.join("Services")).unwrap();
1785
1786 std::fs::write(
1787 root.join("Models/Engine.cs"),
1788 "namespace App.Core;\n\n\
1789 public class Engine\n{\n public int Power { get; set; }\n}\n",
1790 )
1791 .unwrap();
1792 std::fs::write(
1794 root.join("Services/Motor.cs"),
1795 "namespace App.Core;\n\n\
1796 public class Motor\n{\n private readonly Engine _engine;\n\n\
1797 \x20 public Motor(Engine engine)\n {\n _engine = engine;\n }\n}\n",
1798 )
1799 .unwrap();
1800
1801 let root_str = root.to_string_lossy().to_string();
1802
1803 let out = handle("build", None, &root_str, None, Some("text"));
1805 assert!(!out.contains("ERROR"), "graph build failed: {out}");
1806
1807 let _ = crate::core::graph_index::scan(&root_str);
1813
1814 let graph =
1816 crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
1817 let impact = graph
1818 .impact_analysis("Models/Engine.cs", 5)
1819 .expect("impact analysis");
1820 assert!(
1821 impact
1822 .affected_files
1823 .contains(&"Services/Motor.cs".to_string()),
1824 "same-namespace consumer must survive a background reindex; got: {:?}",
1825 impact.affected_files
1826 );
1827 }
1828
1829 #[cfg(feature = "embeddings")]
1838 #[test]
1839 fn stale_engine_graph_is_rebuilt_before_query() {
1840 let _env = crate::core::data_dir::test_env_lock();
1841 let tmp = tempfile::tempdir().expect("tempdir");
1842 let root = tmp.path();
1843 std::fs::create_dir_all(root.join("Models")).unwrap();
1844 std::fs::create_dir_all(root.join("Services")).unwrap();
1845
1846 std::fs::write(
1847 root.join("Models/Engine.cs"),
1848 "namespace App.Core;\n\n\
1849 public class Engine\n{\n public int Power { get; set; }\n}\n",
1850 )
1851 .unwrap();
1852 std::fs::write(
1854 root.join("Services/Motor.cs"),
1855 "namespace App.Core;\n\n\
1856 public class Motor\n{\n private readonly Engine _engine;\n\n\
1857 \x20 public Motor(Engine engine)\n {\n _engine = engine;\n }\n}\n",
1858 )
1859 .unwrap();
1860
1861 let root_str = root.to_string_lossy().to_string();
1862
1863 let out = handle("build", None, &root_str, None, Some("text"));
1866 assert!(!out.contains("ERROR"), "graph build failed: {out}");
1867 let mut meta = crate::core::property_graph::load_meta(&root_str).expect("meta after build");
1868 assert_eq!(
1869 meta.engine_version,
1870 crate::core::property_graph::GRAPH_ENGINE_VERSION,
1871 "a fresh build must stamp the current engine version"
1872 );
1873 meta.engine_version = 0;
1874 crate::core::property_graph::write_meta(&root_str, &meta).expect("downgrade meta");
1875 assert!(
1876 crate::core::property_graph::engine_outdated(&root_str),
1877 "downgraded graph must read as outdated"
1878 );
1879
1880 let analysis = handle(
1882 "analyze",
1883 Some("Models/Engine.cs"),
1884 &root_str,
1885 None,
1886 Some("text"),
1887 );
1888 assert!(
1889 analysis.contains("Services/Motor.cs"),
1890 "stale graph must be rebuilt so the DI consumer surfaces; got: {analysis}"
1891 );
1892 let healed =
1893 crate::core::property_graph::load_meta(&root_str).expect("meta after self-heal");
1894 assert_eq!(
1895 healed.engine_version,
1896 crate::core::property_graph::GRAPH_ENGINE_VERSION,
1897 "self-heal must re-stamp the current engine version"
1898 );
1899 }
1900
1901 #[cfg(feature = "tree-sitter")]
1911 #[test]
1912 fn csharp_expression_position_type_use_is_not_a_leaf() {
1913 let _env = crate::core::data_dir::test_env_lock();
1914 let tmp = tempfile::tempdir().expect("tempdir");
1915 let root = tmp.path();
1916 std::fs::create_dir_all(root.join("Models")).unwrap();
1917 std::fs::create_dir_all(root.join("Attributes")).unwrap();
1918 std::fs::create_dir_all(root.join("Services")).unwrap();
1919
1920 std::fs::write(
1922 root.join("Models/Engine.cs"),
1923 "namespace App.Core;\n\n\
1924 public class Engine\n{\n\
1925 \x20 public static Engine Create() => new Engine();\n\
1926 \x20 public static readonly int Default = 0;\n}\n",
1927 )
1928 .unwrap();
1929 std::fs::write(
1931 root.join("Models/Status.cs"),
1932 "namespace App.Core;\n\npublic enum Status { Active, Inactive }\n",
1933 )
1934 .unwrap();
1935 std::fs::write(
1937 root.join("Attributes/ApiControllerAttribute.cs"),
1938 "using System;\n\nnamespace App.Core;\n\n\
1939 public class ApiControllerAttribute : Attribute { }\n",
1940 )
1941 .unwrap();
1942 std::fs::write(
1944 root.join("Services/Garage.cs"),
1945 "namespace App.Core;\n\n\
1946 [ApiController]\n\
1947 public class Garage\n{\n\
1948 \x20 public void Boot()\n {\n\
1949 \x20 var e = Engine.Create();\n\
1950 \x20 var s = Status.Active;\n }\n}\n",
1951 )
1952 .unwrap();
1953 std::fs::write(
1955 root.join("Services/Logger.cs"),
1956 "namespace App.Core;\n\n\
1957 public class Logger\n{\n public void Log(string m) { }\n}\n",
1958 )
1959 .unwrap();
1960
1961 let root_str = root.to_string_lossy().to_string();
1962 let out = handle("build", None, &root_str, None, Some("text"));
1963 assert!(!out.contains("ERROR"), "graph build failed: {out}");
1964
1965 let graph =
1966 crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
1967 let affected = |file: &str| -> Vec<String> {
1968 graph
1969 .impact_analysis(file, 5)
1970 .expect("impact analysis")
1971 .affected_files
1972 };
1973
1974 let engine_aff = affected("Models/Engine.cs");
1975 assert!(
1976 engine_aff.contains(&"Services/Garage.cs".to_string()),
1977 "static-call consumer (no using/new) must be affected by Engine.cs; got: {engine_aff:?}"
1978 );
1979 assert!(
1980 !engine_aff.contains(&"Services/Logger.cs".to_string()),
1981 "unrelated file must NOT be affected by Engine.cs; got: {engine_aff:?}"
1982 );
1983
1984 let status_aff = affected("Models/Status.cs");
1985 assert!(
1986 status_aff.contains(&"Services/Garage.cs".to_string()),
1987 "enum-value consumer must be affected by Status.cs; got: {status_aff:?}"
1988 );
1989
1990 let attr_aff = affected("Attributes/ApiControllerAttribute.cs");
1991 assert!(
1992 attr_aff.contains(&"Services/Garage.cs".to_string()),
1993 "attribute consumer must be affected by ApiControllerAttribute.cs; got: {attr_aff:?}"
1994 );
1995 }
1996
1997 #[cfg(feature = "tree-sitter")]
2004 #[test]
2005 fn csharp_extension_method_host_is_in_blast_radius() {
2006 let _env = crate::core::data_dir::test_env_lock();
2007 let tmp = tempfile::tempdir().expect("tempdir");
2008 let root = tmp.path();
2009 std::fs::create_dir_all(root.join("Extensions")).unwrap();
2010 std::fs::create_dir_all(root.join("Services")).unwrap();
2011
2012 std::fs::write(
2016 root.join("Extensions/StringExtensions.cs"),
2017 "namespace App.Core;\n\n\
2018 public static class StringExtensions\n{\n\
2019 \x20 public static int WordCount(this string s) => s.Length;\n}\n",
2020 )
2021 .unwrap();
2022 std::fs::write(
2024 root.join("Services/Report.cs"),
2025 "namespace App.Core;\n\n\
2026 public class Report\n{\n\
2027 \x20 public int Count(string text) => text.WordCount();\n}\n",
2028 )
2029 .unwrap();
2030 std::fs::write(
2032 root.join("Services/Logger.cs"),
2033 "namespace App.Core;\n\n\
2034 public class Logger\n{\n public void Log(string m) { }\n}\n",
2035 )
2036 .unwrap();
2037
2038 let root_str = root.to_string_lossy().to_string();
2039 let out = handle("build", None, &root_str, None, Some("text"));
2040 assert!(!out.contains("ERROR"), "graph build failed: {out}");
2041
2042 let graph =
2043 crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
2044 let affected = graph
2045 .impact_analysis("Extensions/StringExtensions.cs", 5)
2046 .expect("impact analysis")
2047 .affected_files;
2048 assert!(
2049 affected.contains(&"Services/Report.cs".to_string()),
2050 "extension-method consumer must be in the host's blast radius; got: {affected:?}"
2051 );
2052 assert!(
2053 !affected.contains(&"Services/Logger.cs".to_string()),
2054 "unrelated file must NOT be affected; got: {affected:?}"
2055 );
2056 }
2057
2058 #[cfg(feature = "tree-sitter")]
2063 #[test]
2064 fn csharp_type_use_namespace_disambiguation() {
2065 let _env = crate::core::data_dir::test_env_lock();
2066 let tmp = tempfile::tempdir().expect("tempdir");
2067 let root = tmp.path();
2068 std::fs::create_dir_all(root.join("Foo")).unwrap();
2069 std::fs::create_dir_all(root.join("Bar")).unwrap();
2070
2071 std::fs::write(
2073 root.join("Foo/Engine.cs"),
2074 "namespace App.Foo;\n\n\
2075 public class Engine\n{\n public int Power { get; set; }\n}\n",
2076 )
2077 .unwrap();
2078 std::fs::write(
2079 root.join("Bar/Engine.cs"),
2080 "namespace App.Bar;\n\n\
2081 public class Engine\n{\n public int Torque { get; set; }\n}\n",
2082 )
2083 .unwrap();
2084 std::fs::write(
2086 root.join("Foo/Garage.cs"),
2087 "namespace App.Foo;\n\n\
2088 public class Garage\n{\n private readonly Engine _engine;\n\n\
2089 \x20 public Garage(Engine engine)\n {\n _engine = engine;\n }\n}\n",
2090 )
2091 .unwrap();
2092
2093 let root_str = root.to_string_lossy().to_string();
2094 let out = handle("build", None, &root_str, None, Some("text"));
2095 assert!(!out.contains("ERROR"), "graph build failed: {out}");
2096
2097 let graph =
2098 crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
2099 let affected = |file: &str| -> Vec<String> {
2100 graph
2101 .impact_analysis(file, 5)
2102 .expect("impact analysis")
2103 .affected_files
2104 };
2105
2106 assert!(
2107 affected("Foo/Engine.cs").contains(&"Foo/Garage.cs".to_string()),
2108 "consumer must depend on the same-namespace Engine; got: {:?}",
2109 affected("Foo/Engine.cs")
2110 );
2111 assert!(
2112 !affected("Bar/Engine.cs").contains(&"Foo/Garage.cs".to_string()),
2113 "consumer must NOT depend on the homonym in another namespace; got: {:?}",
2114 affected("Bar/Engine.cs")
2115 );
2116 }
2117
2118 #[cfg(feature = "tree-sitter")]
2124 #[test]
2125 fn csharp_type_use_namespace_cap_bypass() {
2126 let _env = crate::core::data_dir::test_env_lock();
2127 let tmp = tempfile::tempdir().expect("tempdir");
2128 let root = tmp.path();
2129
2130 let homonym_dirs = ["N1", "N2", "N3", "N4", "N5"];
2132 for d in homonym_dirs {
2133 std::fs::create_dir_all(root.join(d)).unwrap();
2134 std::fs::write(
2135 root.join(d).join("Widget.cs"),
2136 format!(
2137 "namespace App.{d};\n\npublic class Widget\n{{\n public int Id {{ get; set; }}\n}}\n"
2138 ),
2139 )
2140 .unwrap();
2141 }
2142 std::fs::create_dir_all(root.join("Foo")).unwrap();
2144 std::fs::write(
2145 root.join("Foo/Widget.cs"),
2146 "namespace App.Foo;\n\npublic class Widget\n{\n public int Tag { get; set; }\n}\n",
2147 )
2148 .unwrap();
2149 std::fs::write(
2151 root.join("Foo/Dashboard.cs"),
2152 "namespace App.Foo;\n\n\
2153 public class Dashboard\n{\n private readonly Widget _widget;\n\n\
2154 \x20 public Dashboard(Widget widget)\n {\n _widget = widget;\n }\n}\n",
2155 )
2156 .unwrap();
2157
2158 let root_str = root.to_string_lossy().to_string();
2159 let out = handle("build", None, &root_str, None, Some("text"));
2160 assert!(!out.contains("ERROR"), "graph build failed: {out}");
2161
2162 let graph =
2163 crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
2164 let affected = |file: &str| -> Vec<String> {
2165 graph
2166 .impact_analysis(file, 5)
2167 .expect("impact analysis")
2168 .affected_files
2169 };
2170
2171 assert!(
2172 affected("Foo/Widget.cs").contains(&"Foo/Dashboard.cs".to_string()),
2173 "unambiguous same-namespace match must bypass the cap; got: {:?}",
2174 affected("Foo/Widget.cs")
2175 );
2176 assert!(
2178 !affected("N1/Widget.cs").contains(&"Foo/Dashboard.cs".to_string()),
2179 "out-of-namespace homonym must NOT be linked; got: {:?}",
2180 affected("N1/Widget.cs")
2181 );
2182 }
2183
2184 #[cfg(feature = "tree-sitter")]
2193 #[test]
2194 fn csharp_cross_namespace_using_di_blast_radius() {
2195 let _env = crate::core::data_dir::test_env_lock();
2196 let tmp = tempfile::tempdir().expect("tempdir");
2197 let root = tmp.path();
2198 std::fs::create_dir_all(root.join("Models")).unwrap();
2199 std::fs::create_dir_all(root.join("Services")).unwrap();
2200
2201 std::fs::write(
2203 root.join("Models/IEngine.cs"),
2204 "namespace MyApp.Models;\n\npublic interface IEngine\n{\n int Power { get; }\n}\n",
2205 )
2206 .unwrap();
2207 std::fs::write(
2209 root.join("Models/Engine.cs"),
2210 "namespace MyApp.Models;\n\n\
2211 public class Engine : IEngine\n{\n public int Power => 1;\n}\n",
2212 )
2213 .unwrap();
2214 std::fs::write(
2217 root.join("Services/Motor.cs"),
2218 "using MyApp.Models;\n\nnamespace MyApp.Services;\n\n\
2219 public class Motor\n{\n private readonly IEngine _engine;\n\n\
2220 \x20 public Motor(IEngine engine)\n {\n _engine = engine;\n }\n}\n",
2221 )
2222 .unwrap();
2223 std::fs::write(
2225 root.join("Services/Logger.cs"),
2226 "namespace MyApp.Services;\n\n\
2227 public class Logger\n{\n public void Log(string m) { }\n}\n",
2228 )
2229 .unwrap();
2230
2231 let root_str = root.to_string_lossy().to_string();
2232
2233 let build = handle("build", None, &root_str, None, Some("text"));
2235 assert!(!build.contains("ERROR"), "graph build failed: {build}");
2236
2237 let iface = handle(
2238 "analyze",
2239 Some("Models/IEngine.cs"),
2240 &root_str,
2241 None,
2242 Some("text"),
2243 );
2244 assert!(
2245 iface.contains("Models/Engine.cs"),
2246 "implementor (base list) must be impacted by IEngine.cs; got: {iface}"
2247 );
2248 assert!(
2249 iface.contains("Services/Motor.cs"),
2250 "cross-namespace DI consumer (using + interface ctor param) must be \
2251 impacted by IEngine.cs; got: {iface}"
2252 );
2253 assert!(
2254 !iface.contains("Services/Logger.cs"),
2255 "unrelated file must NOT be impacted; got: {iface}"
2256 );
2257 }
2258
2259 #[cfg(feature = "tree-sitter")]
2268 #[test]
2269 fn csharp_analyze_by_class_name_resolves_to_file() {
2270 let _env = crate::core::data_dir::test_env_lock();
2271 let tmp = tempfile::tempdir().expect("tempdir");
2272 let root = tmp.path();
2273 std::fs::create_dir_all(root.join("Models")).unwrap();
2274 std::fs::create_dir_all(root.join("Services")).unwrap();
2275
2276 std::fs::write(
2277 root.join("Models/Engine.cs"),
2278 "namespace App.Core;\n\n\
2279 public class Engine\n{\n public int Power { get; set; }\n}\n",
2280 )
2281 .unwrap();
2282 std::fs::write(
2283 root.join("Services/Motor.cs"),
2284 "namespace App.Core;\n\n\
2285 public class Motor\n{\n private readonly Engine _engine;\n\n\
2286 \x20 public Motor(Engine engine)\n {\n _engine = engine;\n }\n}\n",
2287 )
2288 .unwrap();
2289
2290 let root_str = root.to_string_lossy().to_string();
2291 let build = handle("build", None, &root_str, None, Some("text"));
2292 assert!(!build.contains("ERROR"), "graph build failed: {build}");
2293
2294 let by_name = handle("analyze", Some("Engine"), &root_str, None, Some("text"));
2296 assert!(
2297 by_name.contains("Services/Motor.cs"),
2298 "class-name analyze must resolve 'Engine' to its file and surface the \
2299 DI consumer; got: {by_name}"
2300 );
2301 assert!(
2302 by_name.contains("defined in") && by_name.contains("Models/Engine.cs"),
2303 "class-name analyze must disclose the resolved definer file; got: {by_name}"
2304 );
2305
2306 let by_path = handle(
2308 "analyze",
2309 Some("Models/Engine.cs"),
2310 &root_str,
2311 None,
2312 Some("text"),
2313 );
2314 assert!(
2315 by_path.contains("Services/Motor.cs"),
2316 "file-path analyze must keep working; got: {by_path}"
2317 );
2318
2319 let unknown = handle(
2321 "analyze",
2322 Some("NoSuchThing"),
2323 &root_str,
2324 None,
2325 Some("text"),
2326 );
2327 assert!(
2328 unknown.contains("not a known file or symbol"),
2329 "unknown target must produce the actionable diagnostic; got: {unknown}"
2330 );
2331
2332 let json = handle("analyze", Some("Engine"), &root_str, None, Some("json"));
2334 assert!(
2335 json.contains("\"resolved_from\": \"symbol\""),
2336 "json must mark symbol-resolved analyses; got: {json}"
2337 );
2338 }
2339
2340 #[test]
2347 fn csharp_analyze_by_class_name_when_filename_differs() {
2348 let _env = crate::core::data_dir::test_env_lock();
2349 let tmp = tempfile::tempdir().expect("tempdir");
2350 let root = tmp.path();
2351 std::fs::create_dir_all(root.join("Geometry")).unwrap();
2352 std::fs::create_dir_all(root.join("Rendering")).unwrap();
2353 std::fs::create_dir_all(root.join("Misc")).unwrap();
2354
2355 std::fs::write(
2357 root.join("Geometry/Shapes.cs"),
2358 "namespace App.Geometry;\n\n\
2359 public class ArcPoint\n{\n public double X { get; set; }\n\
2360 \x20 public double Y { get; set; }\n}\n",
2361 )
2362 .unwrap();
2363 std::fs::write(
2365 root.join("Rendering/Canvas.cs"),
2366 "using App.Geometry;\n\nnamespace App.Rendering;\n\n\
2367 public class Canvas\n{\n private readonly ArcPoint _origin;\n\n\
2368 \x20 public Canvas(ArcPoint origin)\n {\n _origin = origin;\n }\n}\n",
2369 )
2370 .unwrap();
2371 std::fs::write(
2373 root.join("Geometry/Grid.cs"),
2374 "namespace App.Geometry;\n\n\
2375 public class Grid\n{\n private ArcPoint _topLeft = new();\n}\n",
2376 )
2377 .unwrap();
2378 std::fs::write(
2380 root.join("Misc/Clock.cs"),
2381 "namespace App.Misc;\n\n\
2382 public class Clock\n{\n public long Ticks { get; set; }\n}\n",
2383 )
2384 .unwrap();
2385
2386 let root_str = root.to_string_lossy().to_string();
2387 let build = handle("build", None, &root_str, None, Some("text"));
2388 assert!(!build.contains("ERROR"), "graph build failed: {build}");
2389
2390 let out = handle("analyze", Some("ArcPoint"), &root_str, None, Some("text"));
2391 assert!(
2392 out.contains("Geometry/Shapes.cs"),
2393 "name must resolve to the (differently named) definer file; got: {out}"
2394 );
2395 assert!(
2396 out.contains("Rendering/Canvas.cs"),
2397 "cross-namespace `using` DI consumer must be in the blast radius; got: {out}"
2398 );
2399 assert!(
2400 out.contains("Geometry/Grid.cs"),
2401 "same-namespace no-`using` consumer must be in the blast radius; got: {out}"
2402 );
2403 assert!(
2404 !out.contains("Misc/Clock.cs"),
2405 "unrelated file must NOT appear (non-vacuous); got: {out}"
2406 );
2407 }
2408}