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