1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::core::graph_index::{self, ProjectIndex};
5use crate::core::tokens::count_tokens;
6
7pub fn handle(
8 action: &str,
9 path: Option<&str>,
10 root: &str,
11 cache: &mut crate::core::cache::SessionCache,
12 crp_mode: crate::tools::CrpMode,
13 depth: Option<usize>,
14 kind: Option<&str>,
15) -> String {
16 match action {
17 "build" => handle_build(root),
18 "related" => handle_related(path, root),
19 "symbol" => handle_symbol(path, root, cache, crp_mode),
20 "impact" => handle_impact(path, root),
21 "status" => handle_status(root),
22 "enrich" => handle_enrich(root),
23 "context" => handle_context_query(path, root),
24 "diagram" => crate::tools::ctx_graph_diagram::handle(path, depth, kind, root),
25 _ => {
26 "Unknown action. Use: build, related, symbol, impact, status, enrich, context, diagram"
27 .to_string()
28 }
29 }
30}
31
32fn handle_build(root: &str) -> String {
33 let index = graph_index::scan(root);
34
35 let mut by_lang: HashMap<&str, (usize, usize)> = HashMap::new();
36 for entry in index.files.values() {
37 let e = by_lang.entry(&entry.language).or_insert((0, 0));
38 e.0 += 1;
39 e.1 += entry.token_count;
40 }
41
42 let mut result = Vec::new();
43 result.push(format!(
44 "Project Graph: {} files, {} symbols, {} edges",
45 index.file_count(),
46 index.symbol_count(),
47 index.edge_count()
48 ));
49
50 let mut langs: Vec<_> = by_lang.iter().collect();
51 langs.sort_by_key(|(_, v)| std::cmp::Reverse(v.1));
52 result.push("\nLanguages:".to_string());
53 for (lang, (count, tokens)) in &langs {
54 result.push(format!(" {lang}: {count} files, {tokens} tok"));
55 }
56
57 let mut import_counts: HashMap<&str, usize> = HashMap::new();
58 for edge in &index.edges {
59 if edge.kind == "import" {
60 *import_counts.entry(&edge.to).or_insert(0) += 1;
61 }
62 }
63 let mut hotspots: Vec<_> = import_counts.iter().collect();
64 hotspots.sort_by_key(|x| std::cmp::Reverse(*x.1));
65
66 if !hotspots.is_empty() {
67 result.push(format!("\nMost imported ({}):", hotspots.len().min(10)));
68 for (module, count) in hotspots.iter().take(10) {
69 result.push(format!(" {module}: imported by {count} files"));
70 }
71 }
72
73 if let Some(dir) = ProjectIndex::index_dir(root) {
74 result.push(format!(
75 "\nIndex saved: {}",
76 crate::core::protocol::shorten_path(&dir.to_string_lossy())
77 ));
78 }
79
80 let output = result.join("\n");
81 let tokens = count_tokens(&output);
82 format!("{output}\n[ctx_graph build: {tokens} tok]")
83}
84
85fn handle_related(path: Option<&str>, root: &str) -> String {
86 let Some(target) = path else {
87 return "path is required for 'related' action".to_string();
88 };
89
90 let Some(index) = ProjectIndex::load(root) else {
91 return "No graph index found. Run ctx_graph with action='build' first.".to_string();
92 };
93
94 let rel_target = graph_index::graph_relative_key(target, root);
95
96 let related = index.get_related(&rel_target, 2);
97 if related.is_empty() {
98 return format!(
99 "No related files found for {}",
100 crate::core::protocol::shorten_path(target)
101 );
102 }
103
104 let mut result = format!(
105 "Files related to {} ({}):\n",
106 crate::core::protocol::shorten_path(target),
107 related.len()
108 );
109 for r in &related {
110 result.push_str(&format!(" {}\n", crate::core::protocol::shorten_path(r)));
111 }
112
113 let tokens = count_tokens(&result);
114 format!("{result}[ctx_graph related: {tokens} tok]")
115}
116
117fn handle_symbol(
118 path: Option<&str>,
119 root: &str,
120 cache: &mut crate::core::cache::SessionCache,
121 crp_mode: crate::tools::CrpMode,
122) -> String {
123 let Some(spec) = path else {
124 return "path is required for 'symbol' action (format: file.rs::function_name)".to_string();
125 };
126
127 let Some((file_part, symbol_name)) = spec.split_once("::") else {
128 return format!("Invalid symbol spec '{spec}'. Use format: file.rs::function_name");
129 };
130
131 let Some(index) = ProjectIndex::load(root) else {
132 return "No graph index found. Run ctx_graph with action='build' first.".to_string();
133 };
134
135 let rel_file = graph_index::graph_relative_key(file_part, root);
136
137 let key = format!("{rel_file}::{symbol_name}");
138 let Some(symbol) = index.get_symbol(&key) else {
139 let available: Vec<&str> = index
140 .symbols
141 .keys()
142 .filter(|k| k.starts_with(&rel_file))
143 .map(std::string::String::as_str)
144 .take(10)
145 .collect();
146 if available.is_empty() {
147 return format!("Symbol '{symbol_name}' not found in {rel_file}. Run ctx_graph action='build' to update the index.");
148 }
149 return format!(
150 "Symbol '{symbol_name}' not found in {rel_file}.\nAvailable symbols:\n {}",
151 available.join("\n ")
152 );
153 };
154
155 let abs_path = if Path::new(file_part).is_absolute() {
156 file_part.to_string()
157 } else {
158 Path::new(root)
159 .join(rel_file.trim_start_matches(['/', '\\']))
160 .to_string_lossy()
161 .to_string()
162 };
163
164 let content = match std::fs::read_to_string(&abs_path) {
165 Ok(c) => c,
166 Err(e) => return format!("Cannot read {abs_path}: {e}"),
167 };
168
169 let lines: Vec<&str> = content.lines().collect();
170 let start = symbol.start_line.saturating_sub(1);
171 let end = symbol.end_line.min(lines.len());
172
173 if start >= lines.len() {
174 return crate::tools::ctx_read::handle(cache, &abs_path, "full", crp_mode);
175 }
176
177 let mut result = format!(
178 "{}::{} ({}:{}-{})\n",
179 crate::core::protocol::shorten_path(&rel_file),
180 symbol_name,
181 symbol.kind,
182 symbol.start_line,
183 symbol.end_line
184 );
185
186 for (i, line) in lines[start..end].iter().enumerate() {
187 result.push_str(&format!("{:>4}|{}\n", start + i + 1, line));
188 }
189
190 let tokens = count_tokens(&result);
191 let full_tokens = count_tokens(&content);
192 let saved = full_tokens.saturating_sub(tokens);
193 let pct = if full_tokens > 0 {
194 (saved as f64 / full_tokens as f64 * 100.0).round() as usize
195 } else {
196 0
197 };
198
199 format!("{result}[ctx_graph symbol: {tokens} tok (full file: {full_tokens} tok, -{pct}%)]")
200}
201
202fn file_path_to_module_prefixes(
203 rel_path: &str,
204 project_root: &str,
205 index: &ProjectIndex,
206) -> Vec<String> {
207 let rel_path_slash = graph_index::graph_match_key(rel_path);
208 let without_ext = rel_path_slash
209 .strip_suffix(".rs")
210 .or_else(|| rel_path_slash.strip_suffix(".ts"))
211 .or_else(|| rel_path_slash.strip_suffix(".tsx"))
212 .or_else(|| rel_path_slash.strip_suffix(".js"))
213 .or_else(|| rel_path_slash.strip_suffix(".py"))
214 .or_else(|| rel_path_slash.strip_suffix(".kt"))
215 .or_else(|| rel_path_slash.strip_suffix(".kts"))
216 .unwrap_or(&rel_path_slash);
217
218 let module_path = without_ext
219 .strip_prefix("src/")
220 .unwrap_or(without_ext)
221 .replace('/', "::");
222
223 let module_path = if module_path.ends_with("::mod") {
224 module_path
225 .strip_suffix("::mod")
226 .unwrap_or(&module_path)
227 .to_string()
228 } else {
229 module_path
230 };
231
232 let crate_name = std::fs::read_to_string(Path::new(project_root).join("Cargo.toml"))
233 .or_else(|_| std::fs::read_to_string(Path::new(project_root).join("package.json")))
234 .ok()
235 .and_then(|c| {
236 c.lines()
237 .find(|l| l.contains("\"name\"") || l.starts_with("name"))
238 .and_then(|l| l.split('"').nth(1))
239 .map(|n| n.replace('-', "_"))
240 })
241 .unwrap_or_default();
242
243 let mut prefixes = vec![
244 format!("crate::{module_path}"),
245 format!("super::{module_path}"),
246 module_path.clone(),
247 ];
248 if !crate_name.is_empty() {
249 prefixes.insert(0, format!("{crate_name}::{module_path}"));
250 }
251
252 let ext = Path::new(rel_path)
253 .extension()
254 .and_then(|e| e.to_str())
255 .unwrap_or("");
256 if matches!(ext, "kt" | "kts") {
257 let abs_path = Path::new(project_root).join(rel_path.trim_start_matches(['/', '\\']));
258 if let Ok(content) = std::fs::read_to_string(abs_path) {
259 if let Some(package_name) = content.lines().map(str::trim).find_map(|line| {
260 line.strip_prefix("package ")
261 .map(|rest| rest.trim().trim_end_matches(';').to_string())
262 }) {
263 prefixes.push(package_name.clone());
264 if let Some(entry) = index.files.get(rel_path) {
265 for export in &entry.exports {
266 prefixes.push(format!("{package_name}.{export}"));
267 }
268 }
269 if let Some(file_stem) = Path::new(rel_path).file_stem().and_then(|s| s.to_str()) {
270 prefixes.push(format!("{package_name}.{file_stem}"));
271 }
272 }
273 }
274 }
275
276 prefixes.sort();
277 prefixes.dedup();
278 prefixes
279}
280
281fn edge_matches_file(edge_to: &str, module_prefixes: &[String]) -> bool {
282 module_prefixes.iter().any(|prefix| {
283 edge_to == *prefix
284 || edge_to.starts_with(&format!("{prefix}::"))
285 || edge_to.starts_with(&format!("{prefix},"))
286 })
287}
288
289fn handle_impact(path: Option<&str>, root: &str) -> String {
290 let Some(target) = path else {
291 return "path is required for 'impact' action".to_string();
292 };
293
294 let Some(index) = ProjectIndex::load(root) else {
295 return "No graph index found. Run ctx_graph with action='build' first.".to_string();
296 };
297
298 let rel_target = graph_index::graph_relative_key(target, root);
299
300 let module_prefixes = file_path_to_module_prefixes(&rel_target, root, &index);
301
302 let direct: Vec<&str> = index
303 .edges
304 .iter()
305 .filter(|e| e.kind == "import" && edge_matches_file(&e.to, &module_prefixes))
306 .map(|e| e.from.as_str())
307 .collect();
308
309 let mut all_dependents: Vec<String> = direct
310 .iter()
311 .map(std::string::ToString::to_string)
312 .collect();
313 for d in &direct {
314 for dep in index.get_reverse_deps(d, 1) {
315 if !all_dependents.contains(&dep) && dep != rel_target {
316 all_dependents.push(dep);
317 }
318 }
319 }
320
321 if all_dependents.is_empty() {
322 return format!(
323 "No files depend on {}",
324 crate::core::protocol::shorten_path(target)
325 );
326 }
327
328 let mut result = format!(
329 "Impact of {} ({} dependents):\n",
330 crate::core::protocol::shorten_path(target),
331 all_dependents.len()
332 );
333
334 if !direct.is_empty() {
335 result.push_str(&format!("\nDirect ({}):\n", direct.len()));
336 for d in &direct {
337 result.push_str(&format!(" {}\n", crate::core::protocol::shorten_path(d)));
338 }
339 }
340
341 let indirect: Vec<&String> = all_dependents
342 .iter()
343 .filter(|d| !direct.contains(&d.as_str()))
344 .collect();
345 if !indirect.is_empty() {
346 result.push_str(&format!("\nIndirect ({}):\n", indirect.len()));
347 for d in &indirect {
348 result.push_str(&format!(" {}\n", crate::core::protocol::shorten_path(d)));
349 }
350 }
351
352 let tokens = count_tokens(&result);
353 format!("{result}[ctx_graph impact: {tokens} tok]")
354}
355
356fn handle_status(root: &str) -> String {
357 let Some(index) = ProjectIndex::load(root) else {
358 return "No graph index. Run ctx_graph action='build' to create one.".to_string();
359 };
360
361 let mut by_lang: HashMap<&str, usize> = HashMap::new();
362 let mut total_tokens = 0usize;
363 for entry in index.files.values() {
364 *by_lang.entry(&entry.language).or_insert(0) += 1;
365 total_tokens += entry.token_count;
366 }
367
368 let mut langs: Vec<_> = by_lang.iter().collect();
369 langs.sort_by_key(|item| std::cmp::Reverse(*item.1));
370 let lang_summary: String = langs
371 .iter()
372 .take(5)
373 .map(|(l, c)| format!("{l}:{c}"))
374 .collect::<Vec<_>>()
375 .join(" ");
376
377 format!(
378 "Graph: {} files, {} symbols, {} edges | {} tok total\nLast scan: {}\nLanguages: {lang_summary}\nStored: {}",
379 index.file_count(),
380 index.symbol_count(),
381 index.edge_count(),
382 total_tokens,
383 index.last_scan,
384 ProjectIndex::index_dir(root)
385 .map(|d| d.to_string_lossy().to_string())
386 .unwrap_or_default()
387 )
388}
389
390fn resolve_node_name(graph: &crate::core::property_graph::CodeGraph, node_id: i64) -> String {
391 let conn = graph.connection();
392 conn.query_row(
393 "SELECT name FROM nodes WHERE id = ?1",
394 rusqlite::params![node_id],
395 |row| row.get::<_, String>(0),
396 )
397 .unwrap_or_else(|_| format!("node#{node_id}"))
398}
399
400fn handle_enrich(root: &str) -> String {
401 let project_root = Path::new(root);
402 let graph = match crate::core::property_graph::CodeGraph::open(project_root) {
403 Ok(g) => g,
404 Err(e) => return format!("Failed to open graph: {e}"),
405 };
406
407 match crate::core::graph_enricher::enrich_graph(&graph, project_root, 500) {
408 Ok(stats) => {
409 let node_count = graph.node_count().unwrap_or(0);
410 let edge_count = graph.edge_count().unwrap_or(0);
411 format!(
412 "Graph enriched.\n{}\nTotal: {node_count} nodes, {edge_count} edges",
413 stats.format_summary()
414 )
415 }
416 Err(e) => format!("Enrichment failed: {e}"),
417 }
418}
419
420fn handle_context_query(query: Option<&str>, root: &str) -> String {
421 let Some(query) = query else {
422 return "Usage: ctx_graph action=context path=\"<query or file path>\"".to_string();
423 };
424
425 let project_root = Path::new(root);
426 let graph = match crate::core::property_graph::CodeGraph::open(project_root) {
427 Ok(g) => g,
428 Err(e) => return format!("Failed to open graph: {e}"),
429 };
430
431 let index = graph_index::load_or_build(root);
432 let mut result = Vec::new();
433
434 if let Ok(Some(node)) = graph.get_node_by_path(query) {
435 result.push(format!("## Context for `{query}`\n"));
436
437 if let Some(node_id) = node.id {
438 let edges_out = graph.edges_from(node_id).unwrap_or_default();
439 let edges_in = graph.edges_to(node_id).unwrap_or_default();
440
441 let mut tests: Vec<String> = Vec::new();
442 let mut commits: Vec<String> = Vec::new();
443 let mut knowledge: Vec<String> = Vec::new();
444 let mut imports: Vec<String> = Vec::new();
445 let mut dependents: Vec<String> = Vec::new();
446
447 for edge in &edges_out {
448 let target = resolve_node_name(&graph, edge.target_id);
449 match edge.kind {
450 crate::core::property_graph::EdgeKind::TestedBy => tests.push(target),
451 crate::core::property_graph::EdgeKind::ChangedIn => commits.push(target),
452 crate::core::property_graph::EdgeKind::MentionedIn => {
453 knowledge.push(target);
454 }
455 crate::core::property_graph::EdgeKind::Imports => imports.push(target),
456 _ => {}
457 }
458 }
459
460 for edge in &edges_in {
461 let source = resolve_node_name(&graph, edge.source_id);
462 if edge.kind == crate::core::property_graph::EdgeKind::Imports {
463 dependents.push(source);
464 }
465 }
466
467 if !tests.is_empty() {
468 result.push(format!("**Tests ({}):** {}", tests.len(), tests.join(", ")));
469 }
470 if !commits.is_empty() {
471 result.push(format!(
472 "**Recent commits ({}):** {}",
473 commits.len(),
474 commits
475 .iter()
476 .take(5)
477 .cloned()
478 .collect::<Vec<_>>()
479 .join(", ")
480 ));
481 }
482 if !knowledge.is_empty() {
483 result.push(format!(
484 "**Knowledge ({}):** {}",
485 knowledge.len(),
486 knowledge.join(", ")
487 ));
488 }
489 if !imports.is_empty() {
490 result.push(format!(
491 "**Imports ({}):** {}",
492 imports.len(),
493 imports
494 .iter()
495 .take(10)
496 .cloned()
497 .collect::<Vec<_>>()
498 .join(", ")
499 ));
500 }
501 if !dependents.is_empty() {
502 result.push(format!(
503 "**Depended on by ({}):** {}",
504 dependents.len(),
505 dependents
506 .iter()
507 .take(10)
508 .cloned()
509 .collect::<Vec<_>>()
510 .join(", ")
511 ));
512 }
513
514 if let Ok(impact) = graph.impact_analysis(query, 3) {
515 if !impact.affected_files.is_empty() {
516 result.push(format!(
517 "**Impact radius:** {} files within 3 hops",
518 impact.affected_files.len()
519 ));
520 }
521 }
522 }
523 } else {
524 result.push(format!("## Search: `{query}`\n"));
525 let related = index.get_related(query, 2);
526 if related.is_empty() {
527 result.push("No matching nodes found in graph.".to_string());
528 } else {
529 result.push(format!("**Related files ({}):**", related.len()));
530 for f in related.iter().take(15) {
531 result.push(format!(" - {f}"));
532 }
533 }
534 }
535
536 result.join("\n")
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
544 fn test_edge_matches_file_crate_prefix() {
545 let prefixes = vec![
546 "lean_ctx::core::cache".to_string(),
547 "crate::core::cache".to_string(),
548 "super::core::cache".to_string(),
549 "core::cache".to_string(),
550 ];
551 assert!(edge_matches_file(
552 "lean_ctx::core::cache::SessionCache",
553 &prefixes
554 ));
555 assert!(edge_matches_file(
556 "crate::core::cache::SessionCache",
557 &prefixes
558 ));
559 assert!(edge_matches_file("crate::core::cache", &prefixes));
560 assert!(!edge_matches_file(
561 "lean_ctx::core::config::Config",
562 &prefixes
563 ));
564 assert!(!edge_matches_file("crate::core::cached_reader", &prefixes));
565 }
566
567 #[test]
568 fn test_file_path_to_module_prefixes_rust() {
569 let index = ProjectIndex::new("/nonexistent");
570 let prefixes = file_path_to_module_prefixes("src/core/cache.rs", "/nonexistent", &index);
571 assert!(prefixes.contains(&"crate::core::cache".to_string()));
572 assert!(prefixes.contains(&"core::cache".to_string()));
573 }
574
575 #[test]
576 fn test_file_path_to_module_prefixes_mod_rs() {
577 let index = ProjectIndex::new("/nonexistent");
578 let prefixes = file_path_to_module_prefixes("src/core/mod.rs", "/nonexistent", &index);
579 assert!(prefixes.contains(&"crate::core".to_string()));
580 assert!(!prefixes.iter().any(|p| p.contains("mod")));
581 }
582}