1use crate::core::property_graph::{CodeGraph, DependencyChain, Edge, EdgeKind, ImpactResult, Node};
7use crate::core::tokens::count_tokens;
8use serde_json::{json, Value};
9use std::collections::BTreeSet;
10use std::path::Path;
11use std::process::Stdio;
12
13const GRAPH_SOURCE_EXTS: &[&str] = &["rs", "ts", "tsx", "js", "jsx", "py", "go", "java"];
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16enum OutputFormat {
17 Text,
18 Json,
19}
20
21fn parse_format(format: Option<&str>) -> Result<OutputFormat, String> {
22 let f = format.unwrap_or("text").trim().to_lowercase();
23 match f.as_str() {
24 "text" => Ok(OutputFormat::Text),
25 "json" => Ok(OutputFormat::Json),
26 _ => Err("Error: format must be text|json".to_string()),
27 }
28}
29
30pub fn handle(
31 action: &str,
32 path: Option<&str>,
33 root: &str,
34 depth: Option<usize>,
35 format: Option<&str>,
36) -> String {
37 let fmt = match parse_format(format) {
38 Ok(f) => f,
39 Err(e) => return e,
40 };
41
42 match action {
43 "analyze" => handle_analyze(path, root, depth.unwrap_or(5), fmt),
44 "diff" => handle_diff(root, depth.unwrap_or(5), fmt),
45 "chain" => handle_chain(path, root, fmt),
46 "build" => handle_build(root, fmt),
47 "update" => handle_update(root, fmt),
48 "status" => handle_status(root, fmt),
49 _ => "Unknown action. Use: analyze, diff, chain, build, status, update".to_string(),
50 }
51}
52
53fn open_graph(root: &str) -> Result<CodeGraph, String> {
54 CodeGraph::open(root).map_err(|e| format!("Failed to open graph: {e}"))
55}
56
57fn handle_analyze(path: Option<&str>, root: &str, max_depth: usize, fmt: OutputFormat) -> String {
58 let Some(target) = path else {
59 return "path is required for 'analyze' action".to_string();
60 };
61
62 let graph = match open_graph(root) {
63 Ok(g) => g,
64 Err(e) => return e,
65 };
66
67 let rel_target = graph_target_key(target, root);
68
69 let node_count = graph.node_count().unwrap_or(0);
70 if node_count == 0 {
71 drop(graph);
72 let build_result = handle_build(root, OutputFormat::Text);
73 tracing::info!(
74 "Auto-built graph for impact analysis: {}",
75 &build_result[..build_result.len().min(100)]
76 );
77 let graph = match open_graph(root) {
78 Ok(g) => g,
79 Err(e) => return e,
80 };
81 if graph.node_count().unwrap_or(0) == 0 {
82 return "Graph is empty after auto-build. No supported source files found.".to_string();
83 }
84 let impact = match graph.impact_analysis(&rel_target, max_depth) {
85 Ok(r) => r,
86 Err(e) => return format!("Impact analysis failed: {e}"),
87 };
88 return format_impact(&impact, &rel_target, root, fmt);
89 }
90
91 let impact = match graph.impact_analysis(&rel_target, max_depth) {
92 Ok(r) => r,
93 Err(e) => return format!("Impact analysis failed: {e}"),
94 };
95
96 format_impact(&impact, &rel_target, root, fmt)
97}
98
99fn format_impact(impact: &ImpactResult, target: &str, root: &str, fmt: OutputFormat) -> String {
100 let mut sorted = impact.affected_files.clone();
101 sorted.sort();
102
103 let total = sorted.len();
104 let limit = crate::core::budgets::IMPACT_AFFECTED_FILES_LIMIT.max(1);
105 let truncated = total > limit;
106 if truncated {
107 sorted.truncate(limit);
108 }
109
110 match fmt {
111 OutputFormat::Json => {
112 let v = json!({
113 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
114 "tool": "ctx_impact",
115 "action": "analyze",
116 "project": project_meta(root),
117 "graph": graph_summary(root),
118 "graph_meta": crate::core::property_graph::load_meta(root),
119 "target": target,
120 "max_depth_reached": impact.max_depth_reached,
121 "edges_traversed": impact.edges_traversed,
122 "affected_files_total": total,
123 "affected_files": sorted,
124 "truncated": truncated
125 });
126 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
127 }
128 OutputFormat::Text => {
129 if total == 0 {
130 let result =
131 format!("No files depend on {target} (leaf node in the dependency graph).");
132 let tokens = count_tokens(&result);
133 return format!("{result}\n[ctx_impact: {tokens} tok]");
134 }
135
136 let mut result = format!(
137 "Impact of changing {target}: {total} affected files (depth: {}, edges traversed: {})\n",
138 impact.max_depth_reached, impact.edges_traversed
139 );
140
141 for file in &sorted {
142 result.push_str(&format!(" {file}\n"));
143 }
144 if truncated {
145 result.push_str(&format!(" ... +{} more\n", total - limit));
146 }
147
148 let tokens = count_tokens(&result);
149 format!("{result}[ctx_impact: {tokens} tok]")
150 }
151 }
152}
153
154fn handle_diff(root: &str, max_depth: usize, fmt: OutputFormat) -> String {
155 let changed = git_changed_files(root);
156 if changed.is_empty() {
157 return match fmt {
158 OutputFormat::Json => {
159 let v = json!({
160 "tool": "ctx_impact",
161 "action": "diff",
162 "changed_files": [],
163 "blast_radius": [],
164 "total_affected": 0
165 });
166 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
167 }
168 OutputFormat::Text => "No uncommitted changes found.".to_string(),
169 };
170 }
171
172 let graph = match open_graph(root) {
173 Ok(g) => g,
174 Err(e) => return e,
175 };
176
177 if graph.node_count().unwrap_or(0) == 0 {
178 drop(graph);
179 handle_build(root, OutputFormat::Text);
180 let graph = match open_graph(root) {
181 Ok(g) => g,
182 Err(e) => return e,
183 };
184 return compute_diff_impact(&graph, &changed, root, max_depth, fmt);
185 }
186
187 compute_diff_impact(&graph, &changed, root, max_depth, fmt)
188}
189
190fn git_changed_files(root: &str) -> Vec<String> {
191 let output = std::process::Command::new("git")
192 .args(["diff", "--name-only", "HEAD"])
193 .current_dir(root)
194 .stdout(Stdio::piped())
195 .stderr(Stdio::null())
196 .output();
197
198 let mut files: BTreeSet<String> = BTreeSet::new();
199
200 if let Ok(o) = output {
201 if o.status.success() {
202 for line in String::from_utf8_lossy(&o.stdout).lines() {
203 let trimmed = line.trim();
204 if !trimmed.is_empty() {
205 files.insert(trimmed.to_string());
206 }
207 }
208 }
209 }
210
211 let staged = std::process::Command::new("git")
212 .args(["diff", "--name-only", "--cached"])
213 .current_dir(root)
214 .stdout(Stdio::piped())
215 .stderr(Stdio::null())
216 .output();
217
218 if let Ok(o) = staged {
219 if o.status.success() {
220 for line in String::from_utf8_lossy(&o.stdout).lines() {
221 let trimmed = line.trim();
222 if !trimmed.is_empty() {
223 files.insert(trimmed.to_string());
224 }
225 }
226 }
227 }
228
229 let untracked = std::process::Command::new("git")
230 .args(["ls-files", "--others", "--exclude-standard"])
231 .current_dir(root)
232 .stdout(Stdio::piped())
233 .stderr(Stdio::null())
234 .output();
235
236 if let Ok(o) = untracked {
237 if o.status.success() {
238 for line in String::from_utf8_lossy(&o.stdout).lines() {
239 let trimmed = line.trim();
240 if !trimmed.is_empty() {
241 files.insert(trimmed.to_string());
242 }
243 }
244 }
245 }
246
247 files.into_iter().collect()
248}
249
250fn compute_diff_impact(
251 graph: &CodeGraph,
252 changed: &[String],
253 root: &str,
254 max_depth: usize,
255 fmt: OutputFormat,
256) -> String {
257 let mut all_affected: BTreeSet<String> = BTreeSet::new();
258 let mut per_file: Vec<(String, Vec<String>)> = Vec::new();
259
260 for file in changed {
261 let rel = graph_target_key(file, root);
262 if let Ok(impact) = graph.impact_analysis(&rel, max_depth) {
263 let mut affected: Vec<String> = impact
264 .affected_files
265 .into_iter()
266 .filter(|f| !changed.contains(f))
267 .collect();
268 affected.sort();
269 for a in &affected {
270 all_affected.insert(a.clone());
271 }
272 if !affected.is_empty() {
273 per_file.push((rel, affected));
274 }
275 }
276 }
277
278 match fmt {
279 OutputFormat::Json => {
280 let items: Vec<Value> = per_file
281 .iter()
282 .map(|(file, affected)| {
283 json!({
284 "changed_file": file,
285 "affected": affected,
286 "count": affected.len()
287 })
288 })
289 .collect();
290 let v = json!({
291 "tool": "ctx_impact",
292 "action": "diff",
293 "changed_files": changed,
294 "blast_radius": items,
295 "total_affected": all_affected.len()
296 });
297 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
298 }
299 OutputFormat::Text => {
300 let mut result = format!(
301 "Diff Impact Analysis ({} changed files, {} blast radius)\n\n",
302 changed.len(),
303 all_affected.len()
304 );
305 result.push_str("Changed files:\n");
306 for f in changed.iter().take(30) {
307 result.push_str(&format!(" {f}\n"));
308 }
309
310 if !per_file.is_empty() {
311 result.push_str("\nBlast radius:\n");
312 for (file, affected) in per_file.iter().take(15) {
313 result.push_str(&format!(" {file} -> {} affected\n", affected.len()));
314 for a in affected.iter().take(10) {
315 result.push_str(&format!(" {a}\n"));
316 }
317 if affected.len() > 10 {
318 result.push_str(&format!(" ... +{} more\n", affected.len() - 10));
319 }
320 }
321 }
322
323 let tokens = count_tokens(&result);
324 format!("{result}\n[ctx_impact diff: {tokens} tok]")
325 }
326 }
327}
328
329fn handle_chain(path: Option<&str>, root: &str, fmt: OutputFormat) -> String {
330 let Some(spec) = path else {
331 return "path is required for 'chain' action (format: from_file->to_file)".to_string();
332 };
333
334 let (from, to) = match spec.split_once("->") {
335 Some((f, t)) => (f.trim(), t.trim()),
336 None => {
337 return format!(
338 "Invalid chain spec '{spec}'. Use format: from_file->to_file\n\
339 Example: src/server.rs->src/core/config.rs"
340 )
341 }
342 };
343
344 let graph = match open_graph(root) {
345 Ok(g) => g,
346 Err(e) => return e,
347 };
348
349 let rel_from = graph_target_key(from, root);
350 let rel_to = graph_target_key(to, root);
351
352 match graph.dependency_chain(&rel_from, &rel_to) {
353 Ok(Some(chain)) => format_chain(&chain, root, fmt),
354 Ok(None) => match fmt {
355 OutputFormat::Json => {
356 let v = json!({
357 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
358 "tool": "ctx_impact",
359 "action": "chain",
360 "project": project_meta(root),
361 "graph": graph_summary(root),
362 "graph_meta": crate::core::property_graph::load_meta(root),
363 "from": rel_from,
364 "to": rel_to,
365 "found": false
366 });
367 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
368 }
369 OutputFormat::Text => {
370 let result = format!("No dependency path from {rel_from} to {rel_to}");
371 let tokens = count_tokens(&result);
372 format!("{result}\n[ctx_impact chain: {tokens} tok]")
373 }
374 },
375 Err(e) => format!("Chain analysis failed: {e}"),
376 }
377}
378
379fn format_chain(chain: &DependencyChain, root: &str, fmt: OutputFormat) -> String {
380 match fmt {
381 OutputFormat::Json => {
382 let v = json!({
383 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
384 "tool": "ctx_impact",
385 "action": "chain",
386 "project": project_meta(root),
387 "graph": graph_summary(root),
388 "graph_meta": crate::core::property_graph::load_meta(root),
389 "found": true,
390 "depth": chain.depth,
391 "path": chain.path
392 });
393 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
394 }
395 OutputFormat::Text => {
396 let mut result = format!("Dependency chain (depth {}):\n", chain.depth);
397 for (i, step) in chain.path.iter().enumerate() {
398 if i > 0 {
399 result.push_str(" -> ");
400 } else {
401 result.push_str(" ");
402 }
403 result.push_str(step);
404 result.push('\n');
405 }
406 let tokens = count_tokens(&result);
407 format!("{result}[ctx_impact chain: {tokens} tok]")
408 }
409 }
410}
411
412fn graph_target_key(path: &str, root: &str) -> String {
413 let rel = crate::core::graph_index::graph_relative_key(path, root);
414 let rel_key = crate::core::graph_index::graph_match_key(&rel);
415 if rel_key.is_empty() {
416 crate::core::graph_index::graph_match_key(path)
417 } else {
418 rel_key
419 }
420}
421
422fn walk_supported_sources(root_path: &Path) -> (Vec<String>, Vec<(String, String, String)>) {
423 let walker = ignore::WalkBuilder::new(root_path)
424 .hidden(true)
425 .git_ignore(true)
426 .build();
427
428 let mut file_paths: Vec<String> = Vec::new();
429 let mut file_contents: Vec<(String, String, String)> = Vec::new();
430
431 for entry in walker.flatten() {
432 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
433 continue;
434 }
435
436 let path = entry.path();
437 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
438
439 if !GRAPH_SOURCE_EXTS.contains(&ext) {
440 continue;
441 }
442
443 let rel_path = path
444 .strip_prefix(root_path)
445 .unwrap_or(path)
446 .to_string_lossy()
447 .to_string();
448
449 file_paths.push(rel_path.clone());
450
451 if let Ok(content) = std::fs::read_to_string(path) {
452 file_contents.push((rel_path, content, ext.to_string()));
453 }
454 }
455
456 file_paths.sort();
457 file_paths.dedup();
458 file_contents.sort_by(|a, b| a.0.cmp(&b.0));
459 (file_paths, file_contents)
460}
461
462fn normalize_git_path(line: &str) -> String {
463 line.trim().replace('\\', "/")
464}
465
466fn git_diff_name_only_lines(project_root: &Path, args: &[&str]) -> Option<Vec<String>> {
467 let out = std::process::Command::new("git")
468 .args(args)
469 .current_dir(project_root)
470 .stdout(Stdio::piped())
471 .stderr(Stdio::null())
472 .output()
473 .ok()?;
474 if !out.status.success() {
475 return None;
476 }
477 let s = String::from_utf8(out.stdout).ok()?;
478 Some(
479 s.lines()
480 .map(normalize_git_path)
481 .filter(|l| !l.is_empty())
482 .collect(),
483 )
484}
485
486fn collect_git_changed_paths(project_root: &Path, last_git_head: &str) -> Option<BTreeSet<String>> {
487 let range = format!("{last_git_head}..HEAD");
488 let mut set: BTreeSet<String> = BTreeSet::new();
489 for line in git_diff_name_only_lines(project_root, &["diff", "--name-only", &range])? {
490 set.insert(line);
491 }
492 for line in git_diff_name_only_lines(project_root, &["diff", "--name-only"])? {
493 set.insert(line);
494 }
495 for line in git_diff_name_only_lines(project_root, &["diff", "--name-only", "--cached"])? {
496 set.insert(line);
497 }
498 Some(set)
499}
500
501#[cfg(feature = "embeddings")]
502fn enclosing_symbol_name_for_line(
503 types: &[crate::core::deep_queries::TypeDef],
504 line: usize,
505) -> String {
506 let mut best: Option<(&crate::core::deep_queries::TypeDef, usize)> = None;
507 for t in types {
508 if line >= t.line && line <= t.end_line {
509 let span = t.end_line.saturating_sub(t.line);
510 match best {
511 None => best = Some((t, span)),
512 Some((_, prev_span)) => {
513 if span < prev_span {
514 best = Some((t, span));
515 }
516 }
517 }
518 }
519 }
520 best.map_or_else(|| "<module>".to_string(), |(t, _)| t.name.clone())
521}
522
523#[cfg(feature = "embeddings")]
524fn resolve_call_callee_site(
525 def_index: &std::collections::HashMap<String, Vec<(String, usize, usize)>>,
526 callee: &str,
527 caller_file: &str,
528) -> Option<(String, usize, usize)> {
529 let sites = def_index.get(callee)?;
530 for (f, ls, le) in sites {
531 if f == caller_file {
532 return Some((f.clone(), *ls, *le));
533 }
534 }
535 let mut sorted: Vec<(String, usize, usize)> = sites.clone();
536 sorted.sort_by(|a, b| a.0.cmp(&b.0));
537 sorted.into_iter().next()
538}
539
540#[cfg(feature = "embeddings")]
541fn index_graph_file_embeddings(
542 graph: &CodeGraph,
543 rel_path: &str,
544 ext: &str,
545 analysis: &crate::core::deep_queries::DeepAnalysis,
546 resolver_ctx: &crate::core::import_resolver::ResolverContext,
547 def_index: &std::collections::HashMap<String, Vec<(String, usize, usize)>>,
548) -> (usize, usize) {
549 let mut total_nodes = 0usize;
550 let mut total_edges = 0usize;
551
552 let Ok(file_node_id) = graph.upsert_node(&Node::file(rel_path)) else {
553 return (0, 0);
554 };
555 total_nodes += 1;
556
557 for type_def in &analysis.types {
558 let sym_node = Node::symbol(
559 &type_def.name,
560 rel_path,
561 crate::core::property_graph::NodeKind::Symbol,
562 )
563 .with_lines(type_def.line, type_def.end_line);
564 if let Ok(sym_id) = graph.upsert_node(&sym_node) {
565 total_nodes += 1;
566 let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::Defines));
567 total_edges += 1;
568 if type_def.is_exported {
569 let _ = graph.upsert_edge(&Edge::new(sym_id, file_node_id, EdgeKind::Exports));
570 total_edges += 1;
571 }
572 }
573 }
574
575 let resolved = crate::core::import_resolver::resolve_imports(
576 &analysis.imports,
577 rel_path,
578 ext,
579 resolver_ctx,
580 );
581
582 let mut targets: Vec<String> = resolved
583 .into_iter()
584 .filter(|imp| !imp.is_external)
585 .filter_map(|imp| imp.resolved_path)
586 .collect();
587 targets.sort();
588 targets.dedup();
589
590 for target_path in targets {
591 let Ok(target_id) = graph.upsert_node(&Node::file(&target_path)) else {
592 continue;
593 };
594 let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::Imports));
595 total_edges += 1;
596 }
597
598 for call in &analysis.calls {
599 let caller_name = enclosing_symbol_name_for_line(&analysis.types, call.line);
600 let mut caller_node = Node::symbol(
601 &caller_name,
602 rel_path,
603 crate::core::property_graph::NodeKind::Symbol,
604 );
605 if let Some(t) = analysis.types.iter().find(|t| t.name == caller_name) {
606 caller_node = caller_node.with_lines(t.line, t.end_line);
607 }
608 let Ok(caller_id) = graph.upsert_node(&caller_node) else {
609 continue;
610 };
611 total_nodes += 1;
612
613 let Some((callee_file, c_line, c_end)) =
614 resolve_call_callee_site(def_index, &call.callee, rel_path)
615 else {
616 continue;
617 };
618
619 let callee_node = Node::symbol(
620 &call.callee,
621 &callee_file,
622 crate::core::property_graph::NodeKind::Symbol,
623 )
624 .with_lines(c_line, c_end);
625 let Ok(callee_id) = graph.upsert_node(&callee_node) else {
626 continue;
627 };
628 total_nodes += 1;
629 let _ = graph.upsert_edge(&Edge::new(caller_id, callee_id, EdgeKind::Calls));
630 total_edges += 1;
631
632 if callee_file != rel_path {
633 let Ok(callee_file_id) = graph.upsert_node(&Node::file(&callee_file)) else {
634 continue;
635 };
636 let _ = graph.upsert_edge(&Edge::new(file_node_id, callee_file_id, EdgeKind::Calls));
637 total_edges += 1;
638 }
639 }
640
641 (total_nodes, total_edges)
642}
643
644#[cfg(not(feature = "embeddings"))]
645fn index_graph_file_minimal(
646 graph: &CodeGraph,
647 rel_path: &str,
648 content: &str,
649 ext: &str,
650 resolver_ctx: &crate::core::import_resolver::ResolverContext,
651) -> (usize, usize) {
652 let Ok(file_node_id) = graph.upsert_node(&Node::file(rel_path)) else {
653 return (0, 0);
654 };
655 let mut total_nodes = 1usize;
656 let mut total_edges = 0usize;
657
658 let analysis = crate::core::deep_queries::analyze(content, ext);
659
660 let resolved = crate::core::import_resolver::resolve_imports(
661 &analysis.imports,
662 rel_path,
663 ext,
664 resolver_ctx,
665 );
666
667 let mut targets: Vec<String> = resolved
668 .into_iter()
669 .filter(|imp| !imp.is_external)
670 .filter_map(|imp| imp.resolved_path)
671 .filter(|p| p != rel_path)
672 .collect();
673 targets.sort();
674 targets.dedup();
675
676 for target_path in targets {
677 let Ok(target_id) = graph.upsert_node(&Node::file(&target_path)) else {
678 continue;
679 };
680 total_nodes += 1;
681 let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::Imports));
682 total_edges += 1;
683 }
684
685 for type_def in &analysis.types {
686 if type_def.is_exported {
687 let sym_node = Node::symbol(
688 &type_def.name,
689 rel_path,
690 crate::core::property_graph::NodeKind::Symbol,
691 )
692 .with_lines(type_def.line, type_def.end_line);
693 if let Ok(sym_id) = graph.upsert_node(&sym_node) {
694 total_nodes += 1;
695 let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::Defines));
696 let _ = graph.upsert_edge(&Edge::new(sym_id, file_node_id, EdgeKind::Exports));
697 total_edges += 2;
698 }
699 }
700 }
701
702 let exports: Vec<String> = analysis
703 .types
704 .iter()
705 .filter(|t| t.is_exported)
706 .map(|t| t.name.clone())
707 .collect();
708 let line_count = content.lines().count();
709 let token_count = crate::core::tokens::count_tokens(content);
710 let hash = {
711 use md5::{Digest, Md5};
712 let mut h = Md5::new();
713 h.update(content.as_bytes());
714 format!("{:x}", h.finalize())
715 };
716 let _ = graph.upsert_file_catalog(&crate::core::property_graph::FileCatalogEntry {
717 path: rel_path.to_string(),
718 hash,
719 language: ext.to_string(),
720 line_count,
721 token_count,
722 exports,
723 summary: String::new(),
724 });
725
726 (total_nodes, total_edges)
727}
728
729fn handle_build(root: &str, fmt: OutputFormat) -> String {
730 let t0 = std::time::Instant::now();
731 let root_path = Path::new(root);
732
733 let graph = match open_graph(root) {
734 Ok(g) => g,
735 Err(e) => return e,
736 };
737
738 let incremental_hint: Option<&'static str> = {
739 let nodes_ok = graph.node_count().unwrap_or(0) > 0;
740 let has_head = crate::core::property_graph::load_meta(root)
741 .and_then(|m| m.git_head)
742 .is_some_and(|s| !s.is_empty());
743 if nodes_ok && has_head {
744 Some(
745 "Hint: Graph already indexed — for faster refresh, use ctx_impact action='update' \
746 to apply incremental git-based updates instead of a full rebuild.",
747 )
748 } else {
749 None
750 }
751 };
752
753 if let Err(e) = graph.clear() {
754 return format!("Failed to clear graph: {e}");
755 }
756
757 let (file_paths, file_contents) = walk_supported_sources(root_path);
758
759 let resolver_ctx =
760 crate::core::import_resolver::ResolverContext::new(root_path, file_paths.clone());
761
762 let mut total_nodes = 0usize;
763 let mut total_edges = 0usize;
764
765 #[cfg(feature = "embeddings")]
766 let per_file: Vec<(
767 String,
768 String,
769 String,
770 crate::core::deep_queries::DeepAnalysis,
771 )> = {
772 use rayon::prelude::*;
773 file_contents
774 .par_iter()
775 .map(|(p, c, e)| {
776 (
777 p.clone(),
778 c.clone(),
779 e.clone(),
780 crate::core::deep_queries::analyze(c.as_str(), e.as_str()),
781 )
782 })
783 .collect()
784 };
785
786 #[cfg(feature = "embeddings")]
787 let def_index: std::collections::HashMap<String, Vec<(String, usize, usize)>> = {
788 let mut m: std::collections::HashMap<String, Vec<(String, usize, usize)>> =
789 std::collections::HashMap::new();
790 for (p, _, _, analysis) in &per_file {
791 for t in &analysis.types {
792 m.entry(t.name.clone())
793 .or_default()
794 .push((p.clone(), t.line, t.end_line));
795 }
796 }
797 m
798 };
799
800 #[cfg(feature = "embeddings")]
801 for (rel_path, _content, ext, analysis) in per_file {
802 let (n, e) = index_graph_file_embeddings(
803 &graph,
804 &rel_path,
805 &ext,
806 &analysis,
807 &resolver_ctx,
808 &def_index,
809 );
810 total_nodes += n;
811 total_edges += e;
812 }
813
814 #[cfg(not(feature = "embeddings"))]
815 for (rel_path, content, ext) in &file_contents {
816 let (n, e) = index_graph_file_minimal(&graph, rel_path, content, ext, &resolver_ctx);
817 total_nodes += n;
818 total_edges += e;
819 }
820
821 let build_time_ms = t0.elapsed().as_millis() as u64;
822
823 let db_display = graph.db_path().display();
824 let mut result = format!(
825 "Graph built: {total_nodes} nodes, {total_edges} edges from {} files\n\
826 Stored at: {db_display}\n\
827 Build time: {build_time_ms}ms",
828 file_contents.len(),
829 );
830 if let Some(h) = incremental_hint {
831 result.push('\n');
832 result.push_str(h);
833 }
834
835 let _ = crate::core::property_graph::write_meta(
836 root,
837 &crate::core::property_graph::PropertyGraphMetaV1 {
838 schema_version: 1,
839 built_at: chrono::Utc::now().to_rfc3339(),
840 git_head: git_out(root_path, &["rev-parse", "--short", "HEAD"]),
841 git_dirty: Some(git_dirty(root_path)),
842 nodes: graph.node_count().ok(),
843 edges: graph.edge_count().ok(),
844 files_indexed: Some(file_contents.len()),
845 build_time_ms: Some(build_time_ms),
846 },
847 );
848
849 let tokens = count_tokens(&result);
850 match fmt {
851 OutputFormat::Json => {
852 let mut v = serde_json::json!({
853 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
854 "tool": "ctx_impact",
855 "action": "build",
856 "project": project_meta(root),
857 "graph": graph_summary(root),
858 "graph_meta": crate::core::property_graph::load_meta(root),
859 "indexed_files": file_contents.len(),
860 "nodes": total_nodes,
861 "edges": total_edges,
862 "build_time_ms": build_time_ms,
863 "db_path": graph.db_path().display().to_string()
864 });
865 if let Some(h) = incremental_hint {
866 v.as_object_mut()
867 .map(|m| m.insert("incremental_hint".to_string(), json!(h)));
868 }
869 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
870 }
871 OutputFormat::Text => format!("{result}\n[ctx_impact build: {tokens} tok]"),
872 }
873}
874
875fn handle_update(root: &str, fmt: OutputFormat) -> String {
876 let t0 = std::time::Instant::now();
877 let root_path = Path::new(root);
878
879 let graph = match open_graph(root) {
880 Ok(g) => g,
881 Err(e) => return e,
882 };
883
884 if graph.node_count().unwrap_or(0) == 0 {
885 return handle_build(root, fmt);
886 }
887
888 let Some(meta) = crate::core::property_graph::load_meta(root) else {
889 return handle_build(root, fmt);
890 };
891
892 let Some(last_git_head) = meta.git_head.filter(|s| !s.is_empty()) else {
893 return handle_build(root, fmt);
894 };
895
896 let Some(changed) = collect_git_changed_paths(root_path, &last_git_head) else {
897 return handle_build(root, fmt);
898 };
899
900 let changed_count = changed.len();
901 let (file_paths, file_contents) = walk_supported_sources(root_path);
902 let resolver_ctx =
903 crate::core::import_resolver::ResolverContext::new(root_path, file_paths.clone());
904
905 #[cfg(feature = "embeddings")]
906 let per_file: Vec<(
907 String,
908 String,
909 String,
910 crate::core::deep_queries::DeepAnalysis,
911 )> = file_contents
912 .iter()
913 .map(|(p, c, e)| {
914 (
915 p.clone(),
916 c.clone(),
917 e.clone(),
918 crate::core::deep_queries::analyze(c.as_str(), e.as_str()),
919 )
920 })
921 .collect();
922
923 #[cfg(feature = "embeddings")]
924 let def_index: std::collections::HashMap<String, Vec<(String, usize, usize)>> = {
925 let mut m: std::collections::HashMap<String, Vec<(String, usize, usize)>> =
926 std::collections::HashMap::new();
927 for (p, _, _, analysis) in &per_file {
928 for t in &analysis.types {
929 m.entry(t.name.clone())
930 .or_default()
931 .push((p.clone(), t.line, t.end_line));
932 }
933 }
934 m
935 };
936
937 let mut total_nodes = 0usize;
938 let mut total_edges = 0usize;
939
940 for rel_path in &changed {
941 let p = Path::new(rel_path);
942 let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
943 let supported = GRAPH_SOURCE_EXTS.contains(&ext);
944 let abs = root_path.join(rel_path);
945
946 if !abs.exists() {
947 if supported {
948 let _ = graph.remove_file_nodes(rel_path);
949 }
950 continue;
951 }
952
953 if !supported {
954 continue;
955 }
956
957 if let Err(e) = graph.remove_file_nodes(rel_path) {
958 return format!("Failed to remove old nodes for {rel_path}: {e}");
959 }
960
961 #[cfg(feature = "embeddings")]
962 {
963 let Some((_, _, ext_owned, analysis)) =
964 per_file.iter().find(|(p, _, _, _)| p == rel_path)
965 else {
966 continue;
967 };
968 let (n, e) = index_graph_file_embeddings(
969 &graph,
970 rel_path,
971 ext_owned,
972 analysis,
973 &resolver_ctx,
974 &def_index,
975 );
976 total_nodes += n;
977 total_edges += e;
978 }
979
980 #[cfg(not(feature = "embeddings"))]
981 {
982 let Some((_, content, ext_owned)) = file_contents.iter().find(|t| t.0 == *rel_path)
983 else {
984 continue;
985 };
986 let (n, e) =
987 index_graph_file_minimal(&graph, rel_path, content, ext_owned, &resolver_ctx);
988 total_nodes += n;
989 total_edges += e;
990 }
991 }
992
993 let elapsed_ms = t0.elapsed().as_millis() as u64;
994
995 let _ = crate::core::property_graph::write_meta(
996 root,
997 &crate::core::property_graph::PropertyGraphMetaV1 {
998 schema_version: 1,
999 built_at: chrono::Utc::now().to_rfc3339(),
1000 git_head: git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1001 git_dirty: Some(git_dirty(root_path)),
1002 nodes: graph.node_count().ok(),
1003 edges: graph.edge_count().ok(),
1004 files_indexed: Some(file_contents.len()),
1005 build_time_ms: Some(elapsed_ms),
1006 },
1007 );
1008
1009 let summary = format!(
1010 "Incremental update: {changed_count} files changed, {total_nodes} nodes updated, {total_edges} edges added ({elapsed_ms}ms)"
1011 );
1012
1013 let tokens = count_tokens(&summary);
1014 match fmt {
1015 OutputFormat::Json => {
1016 let v = json!({
1017 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1018 "tool": "ctx_impact",
1019 "action": "update",
1020 "project": project_meta(root),
1021 "graph": graph_summary(root),
1022 "graph_meta": crate::core::property_graph::load_meta(root),
1023 "git_range_from": last_git_head,
1024 "files_changed_reported": changed_count,
1025 "nodes_added": total_nodes,
1026 "edges_added": total_edges,
1027 "update_time_ms": elapsed_ms,
1028 "db_path": graph.db_path().display().to_string()
1029 });
1030 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1031 }
1032 OutputFormat::Text => format!("{summary}\n[ctx_impact update: {tokens} tok]"),
1033 }
1034}
1035
1036fn handle_status(root: &str, fmt: OutputFormat) -> String {
1037 let graph = match open_graph(root) {
1038 Ok(g) => g,
1039 Err(e) => return e,
1040 };
1041
1042 let nodes = graph.node_count().unwrap_or(0);
1043 let edges = graph.edge_count().unwrap_or(0);
1044
1045 if nodes == 0 {
1046 return match fmt {
1047 OutputFormat::Json => {
1048 let v = json!({
1049 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1050 "tool": "ctx_impact",
1051 "action": "status",
1052 "project": project_meta(root),
1053 "graph": graph_summary(root),
1054 "freshness": "empty",
1055 "hint": "Run ctx_impact action='build' to index."
1056 });
1057 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1058 }
1059 OutputFormat::Text => {
1060 "Graph is empty. Run ctx_impact action='build' to index.".to_string()
1061 }
1062 };
1063 }
1064
1065 let root_path = Path::new(root);
1066 let meta = crate::core::property_graph::load_meta(root);
1067 let current_head = git_out(root_path, &["rev-parse", "--short", "HEAD"]);
1068 let current_dirty = git_dirty(root_path);
1069 let stale = meta.as_ref().is_some_and(|m| {
1070 let head_mismatch = match (m.git_head.as_ref(), current_head.as_ref()) {
1071 (Some(a), Some(b)) => a != b,
1072 _ => false,
1073 };
1074 let dirty_mismatch = match (m.git_dirty, Some(current_dirty)) {
1075 (Some(a), Some(b)) => a != b,
1076 _ => false,
1077 };
1078 head_mismatch || dirty_mismatch
1079 });
1080 let freshness = if stale { "stale" } else { "fresh" };
1081
1082 match fmt {
1083 OutputFormat::Json => {
1084 let v = json!({
1085 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1086 "tool": "ctx_impact",
1087 "action": "status",
1088 "project": project_meta(root),
1089 "graph": graph_summary(root),
1090 "freshness": freshness,
1091 "meta": meta
1092 });
1093 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1094 }
1095 OutputFormat::Text => {
1096 let db_display = graph.db_path().display();
1097 let mut out =
1098 format!("Property Graph: {nodes} nodes, {edges} edges\nStored: {db_display}");
1099 if stale {
1100 out.push_str("\nWARNING: graph looks stale (git HEAD / dirty mismatch). Run ctx_impact action='build' to refresh.");
1101 }
1102 out
1103 }
1104 }
1105}
1106
1107fn project_meta(root: &str) -> Value {
1108 let root_hash = crate::core::project_hash::hash_project_root(root);
1109 let identity_hash = crate::core::project_hash::project_identity(root)
1110 .as_deref()
1111 .map(crate::core::hasher::hash_str);
1112
1113 let root_path = Path::new(root);
1114 json!({
1115 "project_root_hash": root_hash,
1116 "project_identity_hash": identity_hash,
1117 "git": {
1118 "head": git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1119 "branch": git_out(root_path, &["rev-parse", "--abbrev-ref", "HEAD"]),
1120 "dirty": git_dirty(root_path)
1121 }
1122 })
1123}
1124
1125fn graph_summary(project_root: &str) -> Value {
1126 let graph_dir = crate::core::property_graph::graph_dir(project_root);
1127 let db_path = graph_dir.join("graph.db");
1128 let db_path_display = db_path.display().to_string();
1129 if !db_path.exists() {
1130 return json!({
1131 "exists": false,
1132 "db_path": db_path_display,
1133 "nodes": null,
1134 "edges": null
1135 });
1136 }
1137 match crate::core::property_graph::CodeGraph::open(project_root) {
1138 Ok(g) => json!({
1139 "exists": true,
1140 "db_path": g.db_path().display().to_string(),
1141 "nodes": g.node_count().ok(),
1142 "edges": g.edge_count().ok()
1143 }),
1144 Err(_) => json!({
1145 "exists": true,
1146 "db_path": db_path_display,
1147 "nodes": null,
1148 "edges": null
1149 }),
1150 }
1151}
1152
1153fn git_dirty(project_root: &Path) -> bool {
1154 let out = std::process::Command::new("git")
1155 .args(["status", "--porcelain"])
1156 .current_dir(project_root)
1157 .stdout(std::process::Stdio::piped())
1158 .stderr(std::process::Stdio::null())
1159 .output();
1160 match out {
1161 Ok(o) if o.status.success() => !o.stdout.is_empty(),
1162 _ => false,
1163 }
1164}
1165
1166fn git_out(project_root: &Path, args: &[&str]) -> Option<String> {
1167 let out = std::process::Command::new("git")
1168 .args(args)
1169 .current_dir(project_root)
1170 .stdout(std::process::Stdio::piped())
1171 .stderr(std::process::Stdio::null())
1172 .output()
1173 .ok()?;
1174 if !out.status.success() {
1175 return None;
1176 }
1177 let s = String::from_utf8(out.stdout).ok()?;
1178 let s = s.trim().to_string();
1179 if s.is_empty() {
1180 None
1181 } else {
1182 Some(s)
1183 }
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188 use super::*;
1189
1190 #[test]
1191 fn format_impact_empty() {
1192 let impact = ImpactResult {
1193 root_file: "a.rs".to_string(),
1194 affected_files: vec![],
1195 max_depth_reached: 0,
1196 edges_traversed: 0,
1197 };
1198 let result = format_impact(&impact, "a.rs", "/tmp", OutputFormat::Text);
1199 assert!(result.contains("No files depend on"));
1200 }
1201
1202 #[test]
1203 fn format_impact_with_files() {
1204 let impact = ImpactResult {
1205 root_file: "a.rs".to_string(),
1206 affected_files: vec!["b.rs".to_string(), "c.rs".to_string()],
1207 max_depth_reached: 2,
1208 edges_traversed: 3,
1209 };
1210 let result = format_impact(&impact, "a.rs", "/tmp", OutputFormat::Text);
1211 assert!(result.contains("2 affected files"));
1212 assert!(result.contains("b.rs"));
1213 assert!(result.contains("c.rs"));
1214 }
1215
1216 #[test]
1217 fn format_chain_display() {
1218 let chain = DependencyChain {
1219 path: vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()],
1220 depth: 2,
1221 };
1222 let result = format_chain(&chain, "/tmp", OutputFormat::Text);
1223 assert!(result.contains("depth 2"));
1224 assert!(result.contains("a.rs"));
1225 assert!(result.contains("-> b.rs"));
1226 assert!(result.contains("-> c.rs"));
1227 }
1228
1229 #[test]
1230 fn handle_missing_path() {
1231 let result = handle("analyze", None, "/tmp", None, None);
1232 assert!(result.contains("path is required"));
1233 }
1234
1235 #[test]
1236 fn handle_invalid_chain_spec() {
1237 let result = handle("chain", Some("no_arrow_here"), "/tmp", None, None);
1238 assert!(result.contains("Invalid chain spec"));
1239 }
1240
1241 #[test]
1242 fn handle_unknown_action() {
1243 let result = handle("invalid", None, "/tmp", None, None);
1244 assert!(result.contains("Unknown action"));
1245 }
1246
1247 #[test]
1248 fn graph_target_key_normalizes_windows_styles() {
1249 let target = graph_target_key(r"C:/repo/src/main.rs", r"C:\repo");
1250 let expected = if cfg!(windows) {
1251 "src/main.rs"
1252 } else {
1253 "C:/repo/src/main.rs"
1254 };
1255 assert_eq!(target, expected);
1256 }
1257}