Skip to main content

lean_ctx/tools/
ctx_impact.rs

1//! `ctx_impact` — Graph-based impact analysis tool.
2//!
3//! Uses the SQLite-backed Property Graph to answer: "What breaks when file X changes?"
4//! Performs BFS traversal of reverse import edges to find all transitively affected files.
5
6use 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}