1use 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}