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