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