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        "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}