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 std::path::Path;
9
10pub fn handle(action: &str, path: Option<&str>, root: &str, depth: Option<usize>) -> String {
11    match action {
12        "analyze" => handle_analyze(path, root, depth.unwrap_or(5)),
13        "chain" => handle_chain(path, root),
14        "build" => handle_build(root),
15        "status" => handle_status(root),
16        _ => "Unknown action. Use: analyze, chain, build, status".to_string(),
17    }
18}
19
20fn open_graph(root: &str) -> Result<CodeGraph, String> {
21    CodeGraph::open(Path::new(root)).map_err(|e| format!("Failed to open graph: {e}"))
22}
23
24fn handle_analyze(path: Option<&str>, root: &str, max_depth: usize) -> String {
25    let target = match path {
26        Some(p) => p,
27        None => return "path is required for 'analyze' action".to_string(),
28    };
29
30    let graph = match open_graph(root) {
31        Ok(g) => g,
32        Err(e) => return e,
33    };
34
35    let rel_target = graph_target_key(target, root);
36
37    let node_count = graph.node_count().unwrap_or(0);
38    if node_count == 0 {
39        drop(graph);
40        let build_result = handle_build(root);
41        tracing::info!(
42            "Auto-built graph for impact analysis: {}",
43            &build_result[..build_result.len().min(100)]
44        );
45        let graph = match open_graph(root) {
46            Ok(g) => g,
47            Err(e) => return e,
48        };
49        if graph.node_count().unwrap_or(0) == 0 {
50            return "Graph is empty after auto-build. No supported source files found.".to_string();
51        }
52        let impact = match graph.impact_analysis(&rel_target, max_depth) {
53            Ok(r) => r,
54            Err(e) => return format!("Impact analysis failed: {e}"),
55        };
56        return format_impact(&impact, &rel_target);
57    }
58
59    let impact = match graph.impact_analysis(&rel_target, max_depth) {
60        Ok(r) => r,
61        Err(e) => return format!("Impact analysis failed: {e}"),
62    };
63
64    format_impact(&impact, &rel_target)
65}
66
67fn format_impact(impact: &ImpactResult, target: &str) -> String {
68    if impact.affected_files.is_empty() {
69        let result = format!("No files depend on {target} (leaf node in the dependency graph).");
70        let tokens = count_tokens(&result);
71        return format!("{result}\n[ctx_impact: {tokens} tok]");
72    }
73
74    let mut result = format!(
75        "Impact of changing {target}: {} affected files (depth: {}, edges traversed: {})\n",
76        impact.affected_files.len(),
77        impact.max_depth_reached,
78        impact.edges_traversed
79    );
80
81    let mut sorted = impact.affected_files.clone();
82    sorted.sort();
83
84    for file in &sorted {
85        result.push_str(&format!("  {file}\n"));
86    }
87
88    let tokens = count_tokens(&result);
89    format!("{result}[ctx_impact: {tokens} tok]")
90}
91
92fn handle_chain(path: Option<&str>, root: &str) -> String {
93    let spec = match path {
94        Some(p) => p,
95        None => {
96            return "path is required for 'chain' action (format: from_file->to_file)".to_string()
97        }
98    };
99
100    let (from, to) = match spec.split_once("->") {
101        Some((f, t)) => (f.trim(), t.trim()),
102        None => {
103            return format!(
104                "Invalid chain spec '{spec}'. Use format: from_file->to_file\n\
105                 Example: src/server.rs->src/core/config.rs"
106            )
107        }
108    };
109
110    let graph = match open_graph(root) {
111        Ok(g) => g,
112        Err(e) => return e,
113    };
114
115    let rel_from = graph_target_key(from, root);
116    let rel_to = graph_target_key(to, root);
117
118    match graph.dependency_chain(&rel_from, &rel_to) {
119        Ok(Some(chain)) => format_chain(&chain),
120        Ok(None) => {
121            let result = format!("No dependency path from {rel_from} to {rel_to}");
122            let tokens = count_tokens(&result);
123            format!("{result}\n[ctx_impact chain: {tokens} tok]")
124        }
125        Err(e) => format!("Chain analysis failed: {e}"),
126    }
127}
128
129fn format_chain(chain: &DependencyChain) -> String {
130    let mut result = format!("Dependency chain (depth {}):\n", chain.depth);
131    for (i, step) in chain.path.iter().enumerate() {
132        if i > 0 {
133            result.push_str("  -> ");
134        } else {
135            result.push_str("  ");
136        }
137        result.push_str(step);
138        result.push('\n');
139    }
140    let tokens = count_tokens(&result);
141    format!("{result}[ctx_impact chain: {tokens} tok]")
142}
143
144fn graph_target_key(path: &str, root: &str) -> String {
145    let rel = crate::core::graph_index::graph_relative_key(path, root);
146    let rel_key = crate::core::graph_index::graph_match_key(&rel);
147    if rel_key.is_empty() {
148        crate::core::graph_index::graph_match_key(path)
149    } else {
150        rel_key
151    }
152}
153
154fn handle_build(root: &str) -> String {
155    let graph = match open_graph(root) {
156        Ok(g) => g,
157        Err(e) => return e,
158    };
159
160    if let Err(e) = graph.clear() {
161        return format!("Failed to clear graph: {e}");
162    }
163
164    let root_path = Path::new(root);
165    let walker = ignore::WalkBuilder::new(root_path)
166        .hidden(true)
167        .git_ignore(true)
168        .build();
169
170    let supported_exts = ["rs", "ts", "tsx", "js", "jsx", "py", "go", "java"];
171    let mut file_paths: Vec<String> = Vec::new();
172    let mut file_contents: Vec<(String, String, String)> = Vec::new();
173
174    for entry in walker.flatten() {
175        if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
176            continue;
177        }
178
179        let path = entry.path();
180        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
181
182        if !supported_exts.contains(&ext) {
183            continue;
184        }
185
186        let rel_path = path
187            .strip_prefix(root_path)
188            .unwrap_or(path)
189            .to_string_lossy()
190            .to_string();
191
192        file_paths.push(rel_path.clone());
193
194        if let Ok(content) = std::fs::read_to_string(path) {
195            file_contents.push((rel_path, content, ext.to_string()));
196        }
197    }
198
199    let resolver_ctx =
200        crate::core::import_resolver::ResolverContext::new(root_path, file_paths.clone());
201
202    let mut total_nodes = 0usize;
203    let mut total_edges = 0usize;
204
205    for (rel_path, content, ext) in &file_contents {
206        let file_node_id = match graph.upsert_node(&Node::file(rel_path)) {
207            Ok(id) => id,
208            Err(_) => continue,
209        };
210        total_nodes += 1;
211
212        #[cfg(feature = "embeddings")]
213        {
214            let analysis = crate::core::deep_queries::analyze(content, ext);
215
216            for type_def in &analysis.types {
217                let kind = crate::core::property_graph::NodeKind::Symbol;
218                let sym_node = Node::symbol(&type_def.name, rel_path, kind)
219                    .with_lines(type_def.line, type_def.end_line);
220                if let Ok(sym_id) = graph.upsert_node(&sym_node) {
221                    total_nodes += 1;
222                    let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::Defines));
223                    total_edges += 1;
224                }
225            }
226
227            let resolved = crate::core::import_resolver::resolve_imports(
228                &analysis.imports,
229                rel_path,
230                ext,
231                &resolver_ctx,
232            );
233
234            for imp in &resolved {
235                if imp.is_external {
236                    continue;
237                }
238                if let Some(ref target_path) = imp.resolved_path {
239                    let target_id = match graph.upsert_node(&Node::file(target_path)) {
240                        Ok(id) => id,
241                        Err(_) => continue,
242                    };
243                    let _ =
244                        graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::Imports));
245                    total_edges += 1;
246                }
247            }
248        }
249
250        #[cfg(not(feature = "embeddings"))]
251        {
252            let _ = (&content, &ext, file_node_id);
253        }
254    }
255
256    let result = format!(
257        "Graph built: {total_nodes} nodes, {total_edges} edges from {} files\n\
258         Stored at: {}",
259        file_contents.len(),
260        graph.db_path().display()
261    );
262    let tokens = count_tokens(&result);
263    format!("{result}\n[ctx_impact build: {tokens} tok]")
264}
265
266fn handle_status(root: &str) -> String {
267    let graph = match open_graph(root) {
268        Ok(g) => g,
269        Err(e) => return e,
270    };
271
272    let nodes = graph.node_count().unwrap_or(0);
273    let edges = graph.edge_count().unwrap_or(0);
274
275    if nodes == 0 {
276        return "Graph is empty. Run ctx_impact action='build' to index.".to_string();
277    }
278
279    format!(
280        "Property Graph: {nodes} nodes, {edges} edges\nStored: {}",
281        graph.db_path().display()
282    )
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn format_impact_empty() {
291        let impact = ImpactResult {
292            root_file: "a.rs".to_string(),
293            affected_files: vec![],
294            max_depth_reached: 0,
295            edges_traversed: 0,
296        };
297        let result = format_impact(&impact, "a.rs");
298        assert!(result.contains("No files depend on"));
299    }
300
301    #[test]
302    fn format_impact_with_files() {
303        let impact = ImpactResult {
304            root_file: "a.rs".to_string(),
305            affected_files: vec!["b.rs".to_string(), "c.rs".to_string()],
306            max_depth_reached: 2,
307            edges_traversed: 3,
308        };
309        let result = format_impact(&impact, "a.rs");
310        assert!(result.contains("2 affected files"));
311        assert!(result.contains("b.rs"));
312        assert!(result.contains("c.rs"));
313    }
314
315    #[test]
316    fn format_chain_display() {
317        let chain = DependencyChain {
318            path: vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()],
319            depth: 2,
320        };
321        let result = format_chain(&chain);
322        assert!(result.contains("depth 2"));
323        assert!(result.contains("a.rs"));
324        assert!(result.contains("-> b.rs"));
325        assert!(result.contains("-> c.rs"));
326    }
327
328    #[test]
329    fn handle_missing_path() {
330        let result = handle("analyze", None, "/tmp", None);
331        assert!(result.contains("path is required"));
332    }
333
334    #[test]
335    fn handle_invalid_chain_spec() {
336        let result = handle("chain", Some("no_arrow_here"), "/tmp", None);
337        assert!(result.contains("Invalid chain spec"));
338    }
339
340    #[test]
341    fn handle_unknown_action() {
342        let result = handle("invalid", None, "/tmp", None);
343        assert!(result.contains("Unknown action"));
344    }
345
346    #[test]
347    fn graph_target_key_normalizes_windows_styles() {
348        let target = graph_target_key(r"C:/repo/src/main.rs", r"C:\repo");
349        let expected = if cfg!(windows) {
350            "src/main.rs"
351        } else {
352            "C:/repo/src/main.rs"
353        };
354        assert_eq!(target, expected);
355    }
356}