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
14use crate::core::git_util::{git_dirty, git_out};
15use crate::tools::graph_meta::{graph_summary, project_meta};
16use crate::tools::output_format::{OutputFormat, parse_format};
17
18const GRAPH_SOURCE_EXTS: &[&str] = &[
23 "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "gd", "cs", "kt", "kts",
24];
25
26pub fn handle(
27 action: &str,
28 path: Option<&str>,
29 root: &str,
30 depth: Option<usize>,
31 format: Option<&str>,
32) -> String {
33 let fmt = match parse_format(format) {
34 Ok(f) => f,
35 Err(e) => return e,
36 };
37
38 match action {
39 "analyze" => handle_analyze(path, root, depth.unwrap_or(5), fmt),
40 "diff" => handle_diff(root, depth.unwrap_or(5), fmt),
41 "chain" => handle_chain(path, root, fmt),
42 "build" => handle_build(root, fmt),
43 "update" => handle_update(root, fmt),
44 "status" => handle_status(root, fmt),
45 "parity" => handle_parity(root, fmt),
46 _ => "Unknown action. Use: analyze, diff, chain, build, status, update, parity".to_string(),
47 }
48}
49
50fn handle_parity(root: &str, fmt: OutputFormat) -> String {
54 let index = crate::core::graph_index::scan_with_content_cache(root).0;
61
62 let report = match crate::core::graph_parity::compare(&index) {
63 Ok(r) => r,
64 Err(e) => return format!("Parity comparison failed: {e}"),
65 };
66
67 match fmt {
68 OutputFormat::Json => {
69 let v = json!({
70 "tool": "ctx_impact",
71 "action": "parity",
72 "lossless": report.is_lossless(),
73 "files": report.files,
74 "symbols": { "gi": report.symbol_count_gi, "pg": report.symbol_count_pg,
75 "matched": report.symbols_matched, "checked": report.symbols_checked },
76 "edges": { "gi": report.edge_count_gi, "pg": report.edge_count_pg,
77 "superset": report.edge_pairs_lossless },
78 "dependencies": { "lossless": report.dependencies_lossless,
79 "checked": report.files_checked, "extra": report.dependencies_extra },
80 "dependents": { "lossless": report.dependents_lossless, "checked": report.files_checked },
81 "divergences": report.divergences,
82 });
83 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
84 }
85 OutputFormat::Text => {
86 let body = crate::core::graph_parity::format_report(&report);
87 let tokens = count_tokens(&body);
88 format!("{body}\n[ctx_impact parity: {tokens} tok]")
89 }
90 }
91}
92
93fn open_graph(root: &str) -> Result<CodeGraph, String> {
94 CodeGraph::open(root).map_err(|e| format!("Failed to open graph: {e}"))
95}
96
97fn open_graph_fresh(root: &str) -> Result<CodeGraph, String> {
104 let graph = open_graph(root)?;
105 let empty = graph.node_count().unwrap_or(0) == 0;
106 let outdated = !empty && crate::core::property_graph::engine_outdated(root);
107 if empty || outdated {
108 drop(graph);
109 let build_result = handle_build(root, OutputFormat::Text);
110 tracing::info!(
111 "Rebuilt property graph before impact query ({}): {}",
112 if empty { "empty" } else { "engine outdated" },
113 &build_result[..build_result.len().min(100)]
114 );
115 return open_graph(root);
116 }
117 Ok(graph)
118}
119
120fn handle_analyze(path: Option<&str>, root: &str, max_depth: usize, fmt: OutputFormat) -> String {
121 let Some(target) = path else {
122 return "path is required for 'analyze' action".to_string();
123 };
124
125 let graph = match open_graph_fresh(root) {
126 Ok(g) => g,
127 Err(e) => return e,
128 };
129
130 if graph.node_count().unwrap_or(0) == 0 {
131 return "Graph is empty after auto-build. No supported source files found.".to_string();
132 }
133
134 let rel_target = graph_target_key(target, root);
135
136 if graph.get_node_by_path(&rel_target).ok().flatten().is_some() {
138 let impact = match graph.impact_analysis(&rel_target, max_depth) {
139 Ok(r) => r,
140 Err(e) => return format!("Impact analysis failed: {e}"),
141 };
142 return format_impact(&impact, &rel_target, root, fmt);
143 }
144
145 let symbol = symbol_query_name(target);
151 if !symbol.is_empty()
152 && let Ok(def_files) = graph.resolve_symbol_def_files(&symbol)
153 && !def_files.is_empty()
154 {
155 return analyze_symbol(&graph, &symbol, &def_files, root, max_depth, fmt);
156 }
157
158 analyze_unresolved(&graph, target, &rel_target, root, fmt)
161}
162
163fn symbol_query_name(target: &str) -> String {
170 let base = target.rsplit(['/', '\\']).next().unwrap_or(target).trim();
171 let stem = base
172 .rsplit_once('.')
173 .filter(|(_, ext)| GRAPH_SOURCE_EXTS.contains(ext))
174 .map_or(base, |(s, _)| s);
175 if stem.is_empty()
176 || stem.contains(|c: char| {
177 c.is_whitespace() || matches!(c, '.' | ':' | '*' | '<' | '>' | '(' | ')' | '/' | '\\')
178 })
179 {
180 return String::new();
181 }
182 stem.to_string()
183}
184
185fn analyze_symbol(
190 graph: &CodeGraph,
191 symbol: &str,
192 def_files: &[String],
193 root: &str,
194 max_depth: usize,
195 fmt: OutputFormat,
196) -> String {
197 let mut affected: BTreeSet<String> = BTreeSet::new();
198 let mut max_depth_reached = 0usize;
199 let mut edges_traversed = 0usize;
200 for f in def_files {
201 if let Ok(r) = graph.impact_analysis(f, max_depth) {
202 max_depth_reached = max_depth_reached.max(r.max_depth_reached);
203 edges_traversed += r.edges_traversed;
204 affected.extend(r.affected_files);
205 }
206 }
207 for f in def_files {
209 affected.remove(f);
210 }
211
212 let mut sorted: Vec<String> = affected.into_iter().collect();
213 let total = sorted.len();
214 let limit = crate::core::budgets::IMPACT_AFFECTED_FILES_LIMIT.max(1);
215 let truncated = total > limit;
216 if truncated {
217 sorted.truncate(limit);
218 }
219
220 match fmt {
221 OutputFormat::Json => {
222 let v = json!({
223 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
224 "tool": "ctx_impact",
225 "action": "analyze",
226 "project": project_meta(root),
227 "graph": graph_summary(root),
228 "graph_meta": crate::core::property_graph::load_meta(root),
229 "target": symbol,
230 "resolved_from": "symbol",
231 "defined_in": def_files,
232 "max_depth_reached": max_depth_reached,
233 "edges_traversed": edges_traversed,
234 "affected_files_total": total,
235 "affected_files": sorted,
236 "truncated": truncated
237 });
238 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
239 }
240 OutputFormat::Text => {
241 let defined = def_files.join(", ");
242 if total == 0 {
243 let result = format!(
244 "No files depend on {symbol} (defined in {defined}); it is a leaf in the dependency graph."
245 );
246 let tokens = count_tokens(&result);
247 return format!("{result}\n[ctx_impact: {tokens} tok]");
248 }
249 let mut result = format!(
250 "Impact of changing {symbol} (defined in {defined}): {total} affected files \
251 (depth: {max_depth_reached}, edges traversed: {edges_traversed})\n"
252 );
253 for file in &sorted {
254 result.push_str(&format!(" {file}\n"));
255 }
256 if truncated {
257 result.push_str(&format!(" ... +{} more\n", total - limit));
258 }
259 let tokens = count_tokens(&result);
260 format!("{result}[ctx_impact: {tokens} tok]")
261 }
262 }
263}
264
265fn analyze_unresolved(
269 graph: &CodeGraph,
270 target: &str,
271 rel_target: &str,
272 root: &str,
273 fmt: OutputFormat,
274) -> String {
275 let files = graph.file_node_count().unwrap_or(0);
276 let symbols = graph.symbol_count().unwrap_or(0);
277 match fmt {
278 OutputFormat::Json => {
279 let v = json!({
280 "tool": "ctx_impact",
281 "action": "analyze",
282 "project": project_meta(root),
283 "graph": graph_summary(root),
284 "target": target,
285 "resolved": false,
286 "indexed_files": files,
287 "indexed_symbols": symbols,
288 "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'."
289 });
290 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
291 }
292 OutputFormat::Text => {
293 let result = format!(
294 "'{target}' is not a known file or symbol in the graph \
295 ({files} files, {symbols} symbols indexed).\n \
296 - As a file: pass a path relative to the project root (looked up '{rel_target}').\n \
297 - As a class/type: check the spelling, or run ctx_impact action='build' to (re)index."
298 );
299 let tokens = count_tokens(&result);
300 format!("{result}\n[ctx_impact: {tokens} tok]")
301 }
302 }
303}
304
305fn format_impact(impact: &ImpactResult, target: &str, root: &str, fmt: OutputFormat) -> String {
306 let mut sorted = impact.affected_files.clone();
307 sorted.sort();
308
309 let total = sorted.len();
310 let limit = crate::core::budgets::IMPACT_AFFECTED_FILES_LIMIT.max(1);
311 let truncated = total > limit;
312 if truncated {
313 sorted.truncate(limit);
314 }
315
316 match fmt {
317 OutputFormat::Json => {
318 let v = json!({
319 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
320 "tool": "ctx_impact",
321 "action": "analyze",
322 "project": project_meta(root),
323 "graph": graph_summary(root),
324 "graph_meta": crate::core::property_graph::load_meta(root),
325 "target": target,
326 "max_depth_reached": impact.max_depth_reached,
327 "edges_traversed": impact.edges_traversed,
328 "affected_files_total": total,
329 "affected_files": sorted,
330 "truncated": truncated
331 });
332 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
333 }
334 OutputFormat::Text => {
335 if total == 0 {
336 let result =
337 format!("No files depend on {target} (leaf node in the dependency graph).");
338 let tokens = count_tokens(&result);
339 return format!("{result}\n[ctx_impact: {tokens} tok]");
340 }
341
342 let mut result = format!(
343 "Impact of changing {target}: {total} affected files (depth: {}, edges traversed: {})\n",
344 impact.max_depth_reached, impact.edges_traversed
345 );
346
347 for file in &sorted {
348 result.push_str(&format!(" {file}\n"));
349 }
350 if truncated {
351 result.push_str(&format!(" ... +{} more\n", total - limit));
352 }
353
354 let tokens = count_tokens(&result);
355 format!("{result}[ctx_impact: {tokens} tok]")
356 }
357 }
358}
359
360fn handle_diff(root: &str, max_depth: usize, fmt: OutputFormat) -> String {
361 let changed = git_changed_files(root);
362 if changed.is_empty() {
363 return match fmt {
364 OutputFormat::Json => {
365 let v = json!({
366 "tool": "ctx_impact",
367 "action": "diff",
368 "changed_files": [],
369 "blast_radius": [],
370 "total_affected": 0
371 });
372 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
373 }
374 OutputFormat::Text => "No uncommitted changes found.".to_string(),
375 };
376 }
377
378 let graph = match open_graph_fresh(root) {
379 Ok(g) => g,
380 Err(e) => return e,
381 };
382
383 compute_diff_impact(&graph, &changed, root, max_depth, fmt)
384}
385
386fn git_changed_files(root: &str) -> Vec<String> {
387 let output = std::process::Command::new("git")
388 .args(["diff", "--name-only", "HEAD"])
389 .current_dir(root)
390 .stdout(Stdio::piped())
391 .stderr(Stdio::null())
392 .output();
393
394 let mut files: BTreeSet<String> = BTreeSet::new();
395
396 if let Ok(o) = output
397 && o.status.success()
398 {
399 for line in String::from_utf8_lossy(&o.stdout).lines() {
400 let trimmed = line.trim();
401 if !trimmed.is_empty() {
402 files.insert(trimmed.to_string());
403 }
404 }
405 }
406
407 let staged = std::process::Command::new("git")
408 .args(["diff", "--name-only", "--cached"])
409 .current_dir(root)
410 .stdout(Stdio::piped())
411 .stderr(Stdio::null())
412 .output();
413
414 if let Ok(o) = staged
415 && o.status.success()
416 {
417 for line in String::from_utf8_lossy(&o.stdout).lines() {
418 let trimmed = line.trim();
419 if !trimmed.is_empty() {
420 files.insert(trimmed.to_string());
421 }
422 }
423 }
424
425 let untracked = std::process::Command::new("git")
426 .args(["ls-files", "--others", "--exclude-standard"])
427 .current_dir(root)
428 .stdout(Stdio::piped())
429 .stderr(Stdio::null())
430 .output();
431
432 if let Ok(o) = untracked
433 && o.status.success()
434 {
435 for line in String::from_utf8_lossy(&o.stdout).lines() {
436 let trimmed = line.trim();
437 if !trimmed.is_empty() {
438 files.insert(trimmed.to_string());
439 }
440 }
441 }
442
443 files.into_iter().collect()
444}
445
446fn compute_diff_impact(
447 graph: &CodeGraph,
448 changed: &[String],
449 root: &str,
450 max_depth: usize,
451 fmt: OutputFormat,
452) -> String {
453 let mut all_affected: BTreeSet<String> = BTreeSet::new();
454 let mut per_file: Vec<(String, Vec<String>)> = Vec::new();
455
456 for file in changed {
457 let rel = graph_target_key(file, root);
458 if let Ok(impact) = graph.impact_analysis(&rel, max_depth) {
459 let mut affected: Vec<String> = impact
460 .affected_files
461 .into_iter()
462 .filter(|f| !changed.contains(f))
463 .collect();
464 affected.sort();
465 for a in &affected {
466 all_affected.insert(a.clone());
467 }
468 if !affected.is_empty() {
469 per_file.push((rel, affected));
470 }
471 }
472 }
473
474 match fmt {
475 OutputFormat::Json => {
476 let items: Vec<Value> = per_file
477 .iter()
478 .map(|(file, affected)| {
479 json!({
480 "changed_file": file,
481 "affected": affected,
482 "count": affected.len()
483 })
484 })
485 .collect();
486 let v = json!({
487 "tool": "ctx_impact",
488 "action": "diff",
489 "changed_files": changed,
490 "blast_radius": items,
491 "total_affected": all_affected.len()
492 });
493 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
494 }
495 OutputFormat::Text => {
496 let mut result = format!(
497 "Diff Impact Analysis ({} changed files, {} blast radius)\n\n",
498 changed.len(),
499 all_affected.len()
500 );
501 result.push_str("Changed files:\n");
502 for f in changed.iter().take(30) {
503 result.push_str(&format!(" {f}\n"));
504 }
505
506 if !per_file.is_empty() {
507 result.push_str("\nBlast radius:\n");
508 for (file, affected) in per_file.iter().take(15) {
509 result.push_str(&format!(" {file} -> {} affected\n", affected.len()));
510 for a in affected.iter().take(10) {
511 result.push_str(&format!(" {a}\n"));
512 }
513 if affected.len() > 10 {
514 result.push_str(&format!(" ... +{} more\n", affected.len() - 10));
515 }
516 }
517 }
518
519 let tokens = count_tokens(&result);
520 format!("{result}\n[ctx_impact diff: {tokens} tok]")
521 }
522 }
523}
524
525fn handle_chain(path: Option<&str>, root: &str, fmt: OutputFormat) -> String {
526 let Some(spec) = path else {
527 return "path is required for 'chain' action (format: from_file->to_file)".to_string();
528 };
529
530 let (from, to) = match spec.split_once("->") {
531 Some((f, t)) => (f.trim(), t.trim()),
532 None => {
533 return format!(
534 "Invalid chain spec '{spec}'. Use format: from_file->to_file\n\
535 Example: src/server.rs->src/core/config.rs"
536 );
537 }
538 };
539
540 let graph = match open_graph_fresh(root) {
541 Ok(g) => g,
542 Err(e) => return e,
543 };
544
545 let rel_from = graph_target_key(from, root);
546 let rel_to = graph_target_key(to, root);
547
548 match graph.dependency_chain(&rel_from, &rel_to) {
549 Ok(Some(chain)) => format_chain(&chain, root, fmt),
550 Ok(None) => match fmt {
551 OutputFormat::Json => {
552 let v = json!({
553 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
554 "tool": "ctx_impact",
555 "action": "chain",
556 "project": project_meta(root),
557 "graph": graph_summary(root),
558 "graph_meta": crate::core::property_graph::load_meta(root),
559 "from": rel_from,
560 "to": rel_to,
561 "found": false
562 });
563 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
564 }
565 OutputFormat::Text => {
566 let result = format!("No dependency path from {rel_from} to {rel_to}");
567 let tokens = count_tokens(&result);
568 format!("{result}\n[ctx_impact chain: {tokens} tok]")
569 }
570 },
571 Err(e) => format!("Chain analysis failed: {e}"),
572 }
573}
574
575fn format_chain(chain: &DependencyChain, root: &str, fmt: OutputFormat) -> String {
576 match fmt {
577 OutputFormat::Json => {
578 let v = json!({
579 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
580 "tool": "ctx_impact",
581 "action": "chain",
582 "project": project_meta(root),
583 "graph": graph_summary(root),
584 "graph_meta": crate::core::property_graph::load_meta(root),
585 "found": true,
586 "depth": chain.depth,
587 "path": chain.path
588 });
589 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
590 }
591 OutputFormat::Text => {
592 let mut result = format!("Dependency chain (depth {}):\n", chain.depth);
593 for (i, step) in chain.path.iter().enumerate() {
594 if i > 0 {
595 result.push_str(" -> ");
596 } else {
597 result.push_str(" ");
598 }
599 result.push_str(step);
600 result.push('\n');
601 }
602 let tokens = count_tokens(&result);
603 format!("{result}[ctx_impact chain: {tokens} tok]")
604 }
605 }
606}
607
608fn graph_target_key(path: &str, root: &str) -> String {
609 let rel = crate::core::index_paths::graph_relative_key(path, root);
610 let rel_key = crate::core::index_paths::graph_match_key(&rel);
611 if rel_key.is_empty() {
612 crate::core::index_paths::graph_match_key(path)
613 } else {
614 rel_key
615 }
616}
617
618fn walk_supported_sources(root_path: &Path) -> (Vec<String>, Vec<(String, String, String)>) {
619 let walker = ignore::WalkBuilder::new(root_path)
620 .hidden(true)
621 .git_ignore(true)
622 .require_git(false)
623 .filter_entry(crate::core::walk_filter::keep_entry)
624 .build();
625
626 let mut file_paths: Vec<String> = Vec::new();
627 let mut file_contents: Vec<(String, String, String)> = Vec::new();
628
629 for entry in walker.flatten() {
630 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
631 continue;
632 }
633
634 let path = entry.path();
635 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
636
637 if !GRAPH_SOURCE_EXTS.contains(&ext) {
638 continue;
639 }
640
641 let rel_path = path
645 .strip_prefix(root_path)
646 .unwrap_or(path)
647 .to_string_lossy()
648 .replace('\\', "/");
649
650 file_paths.push(rel_path.clone());
651
652 if let Ok(content) = std::fs::read_to_string(path) {
653 file_contents.push((rel_path, content, ext.to_string()));
654 }
655 }
656
657 file_paths.sort();
658 file_paths.dedup();
659 file_contents.sort_by(|a, b| a.0.cmp(&b.0));
660 (file_paths, file_contents)
661}
662
663type AnalyzedFile<'a> = (
665 &'a str,
666 &'a str,
667 &'a str,
668 crate::core::deep_queries::DeepAnalysis,
669);
670
671fn analyze_all(
675 file_contents: &[(String, String, String)],
676) -> (Vec<AnalyzedFile<'_>>, DefIndex, ExtMethodIndex) {
677 use rayon::prelude::*;
678 let per_file: Vec<AnalyzedFile<'_>> = file_contents
679 .par_iter()
680 .map(|(p, c, e)| {
681 (
682 p.as_str(),
683 c.as_str(),
684 e.as_str(),
685 crate::core::deep_queries::analyze(c.as_str(), e.as_str()),
686 )
687 })
688 .collect();
689
690 let def_index =
693 crate::core::type_ref_edges::build_def_index(per_file.iter().map(|(p, _, _, a)| (*p, a)));
694 let ext_method_index = crate::core::type_ref_edges::build_ext_method_index(
695 per_file.iter().map(|(p, _, _, a)| (*p, a)),
696 );
697
698 (per_file, def_index, ext_method_index)
699}
700
701fn insert_type_ref_edges(
710 graph: &CodeGraph,
711 file_node_id: i64,
712 rel_path: &str,
713 type_uses: &[crate::core::deep_queries::TypeUse],
714 def_index: &DefIndex,
715 scope: &crate::core::type_ref_edges::ResolveScope,
716) -> usize {
717 let mut added = 0usize;
718 for (target_file, type_name, line_start, line_end) in
719 crate::core::type_ref_edges::type_ref_targets(
720 def_index,
721 type_uses,
722 rel_path,
723 &scope.visible_ns,
724 scope.allow_global_fallback,
725 )
726 {
727 let Ok(target_id) = graph.upsert_node(&Node::file(&target_file)) else {
728 continue;
729 };
730 let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::TypeRef));
731 added += 1;
732
733 let sym_node = Node::symbol(
734 &type_name,
735 &target_file,
736 crate::core::property_graph::NodeKind::Symbol,
737 )
738 .with_lines(line_start, line_end);
739 if let Ok(sym_id) = graph.upsert_node(&sym_node) {
740 let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::TypeRef));
741 added += 1;
742 }
743 }
744 added
745}
746
747fn insert_ext_method_edges(
754 graph: &CodeGraph,
755 file_node_id: i64,
756 rel_path: &str,
757 calls: &[crate::core::deep_queries::CallSite],
758 ext_method_index: &ExtMethodIndex,
759) -> usize {
760 let mut added = 0usize;
761 for (target_file, method_name, line_start, line_end) in
762 crate::core::type_ref_edges::ext_method_targets(ext_method_index, calls, rel_path)
763 {
764 let Ok(target_id) = graph.upsert_node(&Node::file(&target_file)) else {
765 continue;
766 };
767 let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::TypeRef));
768 added += 1;
769
770 let sym_node = Node::symbol(
771 &method_name,
772 &target_file,
773 crate::core::property_graph::NodeKind::Symbol,
774 )
775 .with_lines(line_start, line_end);
776 if let Ok(sym_id) = graph.upsert_node(&sym_node) {
777 let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::TypeRef));
778 added += 1;
779 }
780 }
781 added
782}
783
784fn normalize_git_path(line: &str) -> String {
785 line.trim().replace('\\', "/")
786}
787
788fn git_diff_name_only_lines(project_root: &Path, args: &[&str]) -> Option<Vec<String>> {
789 let out = std::process::Command::new("git")
790 .args(args)
791 .current_dir(project_root)
792 .stdout(Stdio::piped())
793 .stderr(Stdio::null())
794 .output()
795 .ok()?;
796 if !out.status.success() {
797 return None;
798 }
799 let s = String::from_utf8(out.stdout).ok()?;
800 Some(
801 s.lines()
802 .map(normalize_git_path)
803 .filter(|l| !l.is_empty())
804 .collect(),
805 )
806}
807
808fn collect_git_changed_paths(project_root: &Path, last_git_head: &str) -> Option<BTreeSet<String>> {
809 let range = format!("{last_git_head}..HEAD");
810 let mut set: BTreeSet<String> = BTreeSet::new();
811 for line in git_diff_name_only_lines(project_root, &["diff", "--name-only", &range])? {
812 set.insert(line);
813 }
814 for line in git_diff_name_only_lines(project_root, &["diff", "--name-only"])? {
815 set.insert(line);
816 }
817 for line in git_diff_name_only_lines(project_root, &["diff", "--name-only", "--cached"])? {
818 set.insert(line);
819 }
820 Some(set)
821}
822
823#[cfg(feature = "embeddings")]
824fn enclosing_symbol_name_for_line(
825 types: &[crate::core::deep_queries::TypeDef],
826 line: usize,
827) -> String {
828 let mut best: Option<(&crate::core::deep_queries::TypeDef, usize)> = None;
829 for t in types {
830 if line >= t.line && line <= t.end_line {
831 let span = t.end_line.saturating_sub(t.line);
832 match best {
833 None => best = Some((t, span)),
834 Some((_, prev_span)) => {
835 if span < prev_span {
836 best = Some((t, span));
837 }
838 }
839 }
840 }
841 }
842 best.map_or_else(|| "<module>".to_string(), |(t, _)| t.name.clone())
843}
844
845#[cfg(feature = "embeddings")]
846fn resolve_call_callee_site(
847 def_index: &DefIndex,
848 callee: &str,
849 caller_file: &str,
850) -> Option<(String, usize, usize)> {
851 let sites = def_index.get(callee)?;
852 for (f, _ns, ls, le) in sites {
853 if f == caller_file {
854 return Some((f.clone(), *ls, *le));
855 }
856 }
857 let mut sorted: Vec<(String, usize, usize)> = sites
858 .iter()
859 .map(|(f, _ns, ls, le)| (f.clone(), *ls, *le))
860 .collect();
861 sorted.sort_by(|a, b| a.0.cmp(&b.0));
862 sorted.into_iter().next()
863}
864
865#[cfg(feature = "embeddings")]
866fn index_graph_file_embeddings(
867 graph: &CodeGraph,
868 rel_path: &str,
869 ext: &str,
870 analysis: &crate::core::deep_queries::DeepAnalysis,
871 resolver_ctx: &crate::core::import_resolver::ResolverContext,
872 def_index: &DefIndex,
873 ext_method_index: &ExtMethodIndex,
874) -> (usize, usize) {
875 let mut total_nodes = 0usize;
876 let mut total_edges = 0usize;
877
878 let Ok(file_node_id) = graph.upsert_node(&Node::file(rel_path)) else {
879 return (0, 0);
880 };
881 total_nodes += 1;
882
883 for type_def in &analysis.types {
884 let sym_node = Node::symbol(
885 &type_def.name,
886 rel_path,
887 crate::core::property_graph::NodeKind::Symbol,
888 )
889 .with_lines(type_def.line, type_def.end_line);
890 if let Ok(sym_id) = graph.upsert_node(&sym_node) {
891 total_nodes += 1;
892 let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::Defines));
893 total_edges += 1;
894 if type_def.is_exported {
895 let _ = graph.upsert_edge(&Edge::new(sym_id, file_node_id, EdgeKind::Exports));
896 total_edges += 1;
897 }
898 }
899 }
900
901 let resolved = crate::core::import_resolver::resolve_imports(
902 &analysis.imports,
903 rel_path,
904 ext,
905 resolver_ctx,
906 );
907
908 let mut targets: Vec<String> = resolved
909 .into_iter()
910 .filter(|imp| !imp.is_external)
911 .filter_map(|imp| imp.resolved_path)
912 .collect();
913 targets.sort();
914 targets.dedup();
915
916 for target_path in targets {
917 let Ok(target_id) = graph.upsert_node(&Node::file(&target_path)) else {
918 continue;
919 };
920 let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::Imports));
921 total_edges += 1;
922 }
923
924 for call in &analysis.calls {
925 let caller_name = enclosing_symbol_name_for_line(&analysis.types, call.line);
926 let mut caller_node = Node::symbol(
927 &caller_name,
928 rel_path,
929 crate::core::property_graph::NodeKind::Symbol,
930 );
931 if let Some(t) = analysis.types.iter().find(|t| t.name == caller_name) {
932 caller_node = caller_node.with_lines(t.line, t.end_line);
933 }
934 let Ok(caller_id) = graph.upsert_node(&caller_node) else {
935 continue;
936 };
937 total_nodes += 1;
938
939 let Some((callee_file, c_line, c_end)) =
940 resolve_call_callee_site(def_index, &call.callee, rel_path)
941 else {
942 continue;
943 };
944
945 let callee_node = Node::symbol(
946 &call.callee,
947 &callee_file,
948 crate::core::property_graph::NodeKind::Symbol,
949 )
950 .with_lines(c_line, c_end);
951 let Ok(callee_id) = graph.upsert_node(&callee_node) else {
952 continue;
953 };
954 total_nodes += 1;
955 let _ = graph.upsert_edge(&Edge::new(caller_id, callee_id, EdgeKind::Calls));
956 total_edges += 1;
957
958 if callee_file != rel_path {
959 let Ok(callee_file_id) = graph.upsert_node(&Node::file(&callee_file)) else {
960 continue;
961 };
962 let _ = graph.upsert_edge(&Edge::new(file_node_id, callee_file_id, EdgeKind::Calls));
963 total_edges += 1;
964 }
965 }
966
967 let scope = crate::core::type_ref_edges::resolve_scope(rel_path, ext, analysis);
972 total_edges += insert_type_ref_edges(
973 graph,
974 file_node_id,
975 rel_path,
976 &analysis.type_uses,
977 def_index,
978 &scope,
979 );
980 total_edges += insert_ext_method_edges(
982 graph,
983 file_node_id,
984 rel_path,
985 &analysis.calls,
986 ext_method_index,
987 );
988
989 (total_nodes, total_edges)
990}
991
992#[cfg(not(feature = "embeddings"))]
993fn index_graph_file_minimal(
994 graph: &CodeGraph,
995 rel_path: &str,
996 content: &str,
997 ext: &str,
998 analysis: &crate::core::deep_queries::DeepAnalysis,
999 resolver_ctx: &crate::core::import_resolver::ResolverContext,
1000 def_index: &DefIndex,
1001 ext_method_index: &ExtMethodIndex,
1002) -> (usize, usize) {
1003 let Ok(file_node_id) = graph.upsert_node(&Node::file(rel_path)) else {
1004 return (0, 0);
1005 };
1006 let mut total_nodes = 1usize;
1007 let mut total_edges = 0usize;
1008
1009 let resolved = crate::core::import_resolver::resolve_imports(
1010 &analysis.imports,
1011 rel_path,
1012 ext,
1013 resolver_ctx,
1014 );
1015
1016 let mut targets: Vec<String> = resolved
1017 .into_iter()
1018 .filter(|imp| !imp.is_external)
1019 .filter_map(|imp| imp.resolved_path)
1020 .filter(|p| p != rel_path)
1021 .collect();
1022 targets.sort();
1023 targets.dedup();
1024
1025 for target_path in targets {
1026 let Ok(target_id) = graph.upsert_node(&Node::file(&target_path)) else {
1027 continue;
1028 };
1029 total_nodes += 1;
1030 let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::Imports));
1031 total_edges += 1;
1032 }
1033
1034 for type_def in &analysis.types {
1035 if type_def.is_exported {
1036 let sym_node = Node::symbol(
1037 &type_def.name,
1038 rel_path,
1039 crate::core::property_graph::NodeKind::Symbol,
1040 )
1041 .with_lines(type_def.line, type_def.end_line);
1042 if let Ok(sym_id) = graph.upsert_node(&sym_node) {
1043 total_nodes += 1;
1044 let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::Defines));
1045 let _ = graph.upsert_edge(&Edge::new(sym_id, file_node_id, EdgeKind::Exports));
1046 total_edges += 2;
1047 }
1048 }
1049 }
1050
1051 let scope = crate::core::type_ref_edges::resolve_scope(rel_path, ext, analysis);
1054 total_edges += insert_type_ref_edges(
1055 graph,
1056 file_node_id,
1057 rel_path,
1058 &analysis.type_uses,
1059 def_index,
1060 &scope,
1061 );
1062 total_edges += insert_ext_method_edges(
1063 graph,
1064 file_node_id,
1065 rel_path,
1066 &analysis.calls,
1067 ext_method_index,
1068 );
1069
1070 let exports: Vec<String> = analysis
1071 .types
1072 .iter()
1073 .filter(|t| t.is_exported)
1074 .map(|t| t.name.clone())
1075 .collect();
1076 let line_count = content.lines().count();
1077 let token_count = crate::core::tokens::count_tokens(content);
1078 let hash = {
1079 use md5::{Digest, Md5};
1080 let mut h = Md5::new();
1081 h.update(content.as_bytes());
1082 crate::core::agent_identity::hex_encode(&h.finalize())
1083 };
1084 let _ = graph.upsert_file_catalog(&crate::core::property_graph::FileCatalogEntry {
1085 path: rel_path.to_string(),
1086 hash,
1087 language: ext.to_string(),
1088 line_count,
1089 token_count,
1090 exports,
1091 summary: String::new(),
1092 });
1093
1094 (total_nodes, total_edges)
1095}
1096
1097fn handle_build(root: &str, fmt: OutputFormat) -> String {
1098 let t0 = std::time::Instant::now();
1099 let root_path = Path::new(root);
1100
1101 let graph = match open_graph(root) {
1102 Ok(g) => g,
1103 Err(e) => return e,
1104 };
1105
1106 let incremental_hint: Option<&'static str> = {
1107 let nodes_ok = graph.node_count().unwrap_or(0) > 0;
1108 let has_head = crate::core::property_graph::load_meta(root)
1109 .and_then(|m| m.git_head)
1110 .is_some_and(|s| !s.is_empty());
1111 if nodes_ok && has_head {
1112 Some(
1113 "Hint: Graph already indexed — for faster refresh, use ctx_impact action='update' \
1114 to apply incremental git-based updates instead of a full rebuild.",
1115 )
1116 } else {
1117 None
1118 }
1119 };
1120
1121 if let Err(e) = graph.clear() {
1122 return format!("Failed to clear graph: {e}");
1123 }
1124
1125 let (file_paths, file_contents) = walk_supported_sources(root_path);
1126
1127 let cs_contents: std::collections::HashMap<String, String> = file_contents
1128 .iter()
1129 .filter(|(_, _, e)| e.eq_ignore_ascii_case("cs"))
1130 .map(|(p, c, _)| (p.clone(), c.clone()))
1131 .collect();
1132 let resolver_ctx = crate::core::import_resolver::ResolverContext::new(
1133 root_path,
1134 file_paths.clone(),
1135 &cs_contents,
1136 );
1137
1138 let mut total_nodes = 0usize;
1139 let mut total_edges = 0usize;
1140
1141 let (per_file, def_index, ext_method_index) = analyze_all(&file_contents);
1142
1143 #[cfg(feature = "embeddings")]
1144 for (rel_path, _content, ext, analysis) in &per_file {
1145 let (n, e) = index_graph_file_embeddings(
1146 &graph,
1147 rel_path,
1148 ext,
1149 analysis,
1150 &resolver_ctx,
1151 &def_index,
1152 &ext_method_index,
1153 );
1154 total_nodes += n;
1155 total_edges += e;
1156 }
1157
1158 #[cfg(not(feature = "embeddings"))]
1159 for (rel_path, content, ext, analysis) in &per_file {
1160 let (n, e) = index_graph_file_minimal(
1161 &graph,
1162 rel_path,
1163 content,
1164 ext,
1165 analysis,
1166 &resolver_ctx,
1167 &def_index,
1168 &ext_method_index,
1169 );
1170 total_nodes += n;
1171 total_edges += e;
1172 }
1173
1174 let build_time_ms = t0.elapsed().as_millis() as u64;
1175
1176 let db_display = graph.db_path().display();
1177 let mut result = format!(
1178 "Graph built: {total_nodes} nodes, {total_edges} edges from {} files\n\
1179 Stored at: {db_display}\n\
1180 Build time: {build_time_ms}ms",
1181 file_contents.len(),
1182 );
1183 if let Some(h) = incremental_hint {
1184 result.push('\n');
1185 result.push_str(h);
1186 }
1187
1188 let _ = crate::core::property_graph::write_meta(
1189 root,
1190 &crate::core::property_graph::PropertyGraphMetaV1 {
1191 schema_version: 1,
1192 engine_version: crate::core::property_graph::GRAPH_ENGINE_VERSION,
1193 built_with: env!("CARGO_PKG_VERSION").to_string(),
1194 project_root: crate::core::graph_index::normalize_project_root(root),
1195 built_at: chrono::Utc::now().to_rfc3339(),
1196 git_head: git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1197 git_dirty: Some(git_dirty(root_path)),
1198 nodes: graph.node_count().ok(),
1199 edges: graph.edge_count().ok(),
1200 files_indexed: Some(file_contents.len()),
1201 build_time_ms: Some(build_time_ms),
1202 },
1203 );
1204
1205 let tokens = count_tokens(&result);
1206 match fmt {
1207 OutputFormat::Json => {
1208 let mut v = serde_json::json!({
1209 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1210 "tool": "ctx_impact",
1211 "action": "build",
1212 "project": project_meta(root),
1213 "graph": graph_summary(root),
1214 "graph_meta": crate::core::property_graph::load_meta(root),
1215 "indexed_files": file_contents.len(),
1216 "nodes": total_nodes,
1217 "edges": total_edges,
1218 "build_time_ms": build_time_ms,
1219 "db_path": graph.db_path().display().to_string()
1220 });
1221 if let Some(h) = incremental_hint {
1222 v.as_object_mut()
1223 .map(|m| m.insert("incremental_hint".to_string(), json!(h)));
1224 }
1225 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1226 }
1227 OutputFormat::Text => format!("{result}\n[ctx_impact build: {tokens} tok]"),
1228 }
1229}
1230
1231fn handle_update(root: &str, fmt: OutputFormat) -> String {
1232 let t0 = std::time::Instant::now();
1233 let root_path = Path::new(root);
1234
1235 let graph = match open_graph(root) {
1236 Ok(g) => g,
1237 Err(e) => return e,
1238 };
1239
1240 if graph.node_count().unwrap_or(0) == 0 {
1241 return handle_build(root, fmt);
1242 }
1243
1244 let Some(meta) = crate::core::property_graph::load_meta(root) else {
1245 return handle_build(root, fmt);
1246 };
1247
1248 let Some(last_git_head) = meta.git_head.filter(|s| !s.is_empty()) else {
1249 return handle_build(root, fmt);
1250 };
1251
1252 let Some(changed) = collect_git_changed_paths(root_path, &last_git_head) else {
1253 return handle_build(root, fmt);
1254 };
1255
1256 let changed_count = changed.len();
1257 let (file_paths, file_contents) = walk_supported_sources(root_path);
1258 let cs_contents: std::collections::HashMap<String, String> = file_contents
1259 .iter()
1260 .filter(|(_, _, e)| e.eq_ignore_ascii_case("cs"))
1261 .map(|(p, c, _)| (p.clone(), c.clone()))
1262 .collect();
1263 let resolver_ctx = crate::core::import_resolver::ResolverContext::new(
1264 root_path,
1265 file_paths.clone(),
1266 &cs_contents,
1267 );
1268
1269 let (per_file, def_index, ext_method_index) = analyze_all(&file_contents);
1270
1271 let mut total_nodes = 0usize;
1272 let mut total_edges = 0usize;
1273
1274 for rel_path in &changed {
1275 let p = Path::new(rel_path);
1276 let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
1277 let supported = GRAPH_SOURCE_EXTS.contains(&ext);
1278 let abs = root_path.join(rel_path);
1279
1280 if !abs.exists() {
1281 if supported {
1282 let _ = graph.remove_file_nodes(rel_path);
1283 }
1284 continue;
1285 }
1286
1287 if !supported {
1288 continue;
1289 }
1290
1291 if let Err(e) = graph.remove_file_nodes(rel_path) {
1292 return format!("Failed to remove old nodes for {rel_path}: {e}");
1293 }
1294
1295 let Some((_, _content, ext_owned, analysis)) =
1296 per_file.iter().find(|(p, _, _, _)| *p == rel_path)
1297 else {
1298 continue;
1299 };
1300
1301 #[cfg(feature = "embeddings")]
1302 {
1303 let (n, e) = index_graph_file_embeddings(
1304 &graph,
1305 rel_path,
1306 ext_owned,
1307 analysis,
1308 &resolver_ctx,
1309 &def_index,
1310 &ext_method_index,
1311 );
1312 total_nodes += n;
1313 total_edges += e;
1314 }
1315
1316 #[cfg(not(feature = "embeddings"))]
1317 {
1318 let (n, e) = index_graph_file_minimal(
1319 &graph,
1320 rel_path,
1321 _content,
1322 ext_owned,
1323 analysis,
1324 &resolver_ctx,
1325 &def_index,
1326 &ext_method_index,
1327 );
1328 total_nodes += n;
1329 total_edges += e;
1330 }
1331 }
1332
1333 let elapsed_ms = t0.elapsed().as_millis() as u64;
1334
1335 let _ = crate::core::property_graph::write_meta(
1336 root,
1337 &crate::core::property_graph::PropertyGraphMetaV1 {
1338 schema_version: 1,
1339 engine_version: crate::core::property_graph::GRAPH_ENGINE_VERSION,
1340 built_with: env!("CARGO_PKG_VERSION").to_string(),
1341 project_root: crate::core::graph_index::normalize_project_root(root),
1342 built_at: chrono::Utc::now().to_rfc3339(),
1343 git_head: git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1344 git_dirty: Some(git_dirty(root_path)),
1345 nodes: graph.node_count().ok(),
1346 edges: graph.edge_count().ok(),
1347 files_indexed: Some(file_contents.len()),
1348 build_time_ms: Some(elapsed_ms),
1349 },
1350 );
1351
1352 let summary = format!(
1353 "Incremental update: {changed_count} files changed, {total_nodes} nodes updated, {total_edges} edges added ({elapsed_ms}ms)"
1354 );
1355
1356 let tokens = count_tokens(&summary);
1357 match fmt {
1358 OutputFormat::Json => {
1359 let v = json!({
1360 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1361 "tool": "ctx_impact",
1362 "action": "update",
1363 "project": project_meta(root),
1364 "graph": graph_summary(root),
1365 "graph_meta": crate::core::property_graph::load_meta(root),
1366 "git_range_from": last_git_head,
1367 "files_changed_reported": changed_count,
1368 "nodes_added": total_nodes,
1369 "edges_added": total_edges,
1370 "update_time_ms": elapsed_ms,
1371 "db_path": graph.db_path().display().to_string()
1372 });
1373 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1374 }
1375 OutputFormat::Text => format!("{summary}\n[ctx_impact update: {tokens} tok]"),
1376 }
1377}
1378
1379fn handle_status(root: &str, fmt: OutputFormat) -> String {
1380 let graph = match open_graph(root) {
1381 Ok(g) => g,
1382 Err(e) => return e,
1383 };
1384
1385 let nodes = graph.node_count().unwrap_or(0);
1386 let edges = graph.edge_count().unwrap_or(0);
1387
1388 if nodes == 0 {
1389 return match fmt {
1390 OutputFormat::Json => {
1391 let v = json!({
1392 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1393 "tool": "ctx_impact",
1394 "action": "status",
1395 "project": project_meta(root),
1396 "graph": graph_summary(root),
1397 "freshness": "empty",
1398 "hint": "Run ctx_impact action='build' to index."
1399 });
1400 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1401 }
1402 OutputFormat::Text => {
1403 "Graph is empty. Run ctx_impact action='build' to index.".to_string()
1404 }
1405 };
1406 }
1407
1408 let root_path = Path::new(root);
1409 let meta = crate::core::property_graph::load_meta(root);
1410 let current_head = git_out(root_path, &["rev-parse", "--short", "HEAD"]);
1411 let current_dirty = git_dirty(root_path);
1412 let stale = meta.as_ref().is_some_and(|m| {
1413 let head_mismatch = match (m.git_head.as_ref(), current_head.as_ref()) {
1414 (Some(a), Some(b)) => a != b,
1415 _ => false,
1416 };
1417 let dirty_mismatch = match (m.git_dirty, Some(current_dirty)) {
1418 (Some(a), Some(b)) => a != b,
1419 _ => false,
1420 };
1421 head_mismatch || dirty_mismatch
1422 });
1423 let freshness = if stale { "stale" } else { "fresh" };
1424
1425 match fmt {
1426 OutputFormat::Json => {
1427 let v = json!({
1428 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1429 "tool": "ctx_impact",
1430 "action": "status",
1431 "project": project_meta(root),
1432 "graph": graph_summary(root),
1433 "freshness": freshness,
1434 "meta": meta
1435 });
1436 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1437 }
1438 OutputFormat::Text => {
1439 let db_display = graph.db_path().display();
1440 let mut out =
1441 format!("Property Graph: {nodes} nodes, {edges} edges\nStored: {db_display}");
1442 if stale {
1443 out.push_str("\nWARNING: graph looks stale (git HEAD / dirty mismatch). Run ctx_impact action='build' to refresh.");
1444 }
1445 out
1446 }
1447 }
1448}
1449
1450#[cfg(test)]
1451#[path = "ctx_impact_tests.rs"]
1452mod tests;