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