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