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