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) -> String {
14 match action {
15 "build" => handle_build(root),
16 "related" => handle_related(path, root),
17 "symbol" => handle_symbol(path, root, cache, crp_mode),
18 "impact" => handle_impact(path, root),
19 "status" => handle_status(root),
20 _ => "Unknown action. Use: build, related, symbol, impact, status".to_string(),
21 }
22}
23
24fn handle_build(root: &str) -> String {
25 let index = graph_index::scan(root);
26
27 let mut by_lang: HashMap<&str, (usize, usize)> = HashMap::new();
28 for entry in index.files.values() {
29 let e = by_lang.entry(&entry.language).or_insert((0, 0));
30 e.0 += 1;
31 e.1 += entry.token_count;
32 }
33
34 let mut result = Vec::new();
35 result.push(format!(
36 "Project Graph: {} files, {} symbols, {} edges",
37 index.file_count(),
38 index.symbol_count(),
39 index.edge_count()
40 ));
41
42 let mut langs: Vec<_> = by_lang.iter().collect();
43 langs.sort_by_key(|(_, v)| std::cmp::Reverse(v.1));
44 result.push("\nLanguages:".to_string());
45 for (lang, (count, tokens)) in &langs {
46 result.push(format!(" {lang}: {count} files, {tokens} tok"));
47 }
48
49 let mut import_counts: HashMap<&str, usize> = HashMap::new();
50 for edge in &index.edges {
51 if edge.kind == "import" {
52 *import_counts.entry(&edge.to).or_insert(0) += 1;
53 }
54 }
55 let mut hotspots: Vec<_> = import_counts.iter().collect();
56 hotspots.sort_by_key(|x| std::cmp::Reverse(*x.1));
57
58 if !hotspots.is_empty() {
59 result.push(format!("\nMost imported ({}):", hotspots.len().min(10)));
60 for (module, count) in hotspots.iter().take(10) {
61 result.push(format!(" {module}: imported by {count} files"));
62 }
63 }
64
65 if let Some(dir) = ProjectIndex::index_dir(root) {
66 result.push(format!(
67 "\nIndex saved: {}",
68 crate::core::protocol::shorten_path(&dir.to_string_lossy())
69 ));
70 }
71
72 let output = result.join("\n");
73 let tokens = count_tokens(&output);
74 format!("{output}\n[ctx_graph build: {tokens} tok]")
75}
76
77fn handle_related(path: Option<&str>, root: &str) -> String {
78 let Some(target) = path else {
79 return "path is required for 'related' action".to_string();
80 };
81
82 let Some(index) = ProjectIndex::load(root) else {
83 return "No graph index found. Run ctx_graph with action='build' first.".to_string();
84 };
85
86 let rel_target = graph_index::graph_relative_key(target, root);
87
88 let related = index.get_related(&rel_target, 2);
89 if related.is_empty() {
90 return format!(
91 "No related files found for {}",
92 crate::core::protocol::shorten_path(target)
93 );
94 }
95
96 let mut result = format!(
97 "Files related to {} ({}):\n",
98 crate::core::protocol::shorten_path(target),
99 related.len()
100 );
101 for r in &related {
102 result.push_str(&format!(" {}\n", crate::core::protocol::shorten_path(r)));
103 }
104
105 let tokens = count_tokens(&result);
106 format!("{result}[ctx_graph related: {tokens} tok]")
107}
108
109fn handle_symbol(
110 path: Option<&str>,
111 root: &str,
112 cache: &mut crate::core::cache::SessionCache,
113 crp_mode: crate::tools::CrpMode,
114) -> String {
115 let Some(spec) = path else {
116 return "path is required for 'symbol' action (format: file.rs::function_name)".to_string();
117 };
118
119 let Some((file_part, symbol_name)) = spec.split_once("::") else {
120 return format!("Invalid symbol spec '{spec}'. Use format: file.rs::function_name");
121 };
122
123 let Some(index) = ProjectIndex::load(root) else {
124 return "No graph index found. Run ctx_graph with action='build' first.".to_string();
125 };
126
127 let rel_file = graph_index::graph_relative_key(file_part, root);
128
129 let key = format!("{rel_file}::{symbol_name}");
130 let Some(symbol) = index.get_symbol(&key) else {
131 let available: Vec<&str> = index
132 .symbols
133 .keys()
134 .filter(|k| k.starts_with(&rel_file))
135 .map(std::string::String::as_str)
136 .take(10)
137 .collect();
138 if available.is_empty() {
139 return format!("Symbol '{symbol_name}' not found in {rel_file}. Run ctx_graph action='build' to update the index.");
140 }
141 return format!(
142 "Symbol '{symbol_name}' not found in {rel_file}.\nAvailable symbols:\n {}",
143 available.join("\n ")
144 );
145 };
146
147 let abs_path = if Path::new(file_part).is_absolute() {
148 file_part.to_string()
149 } else {
150 Path::new(root)
151 .join(rel_file.trim_start_matches(['/', '\\']))
152 .to_string_lossy()
153 .to_string()
154 };
155
156 let content = match std::fs::read_to_string(&abs_path) {
157 Ok(c) => c,
158 Err(e) => return format!("Cannot read {abs_path}: {e}"),
159 };
160
161 let lines: Vec<&str> = content.lines().collect();
162 let start = symbol.start_line.saturating_sub(1);
163 let end = symbol.end_line.min(lines.len());
164
165 if start >= lines.len() {
166 return crate::tools::ctx_read::handle(cache, &abs_path, "full", crp_mode);
167 }
168
169 let mut result = format!(
170 "{}::{} ({}:{}-{})\n",
171 crate::core::protocol::shorten_path(&rel_file),
172 symbol_name,
173 symbol.kind,
174 symbol.start_line,
175 symbol.end_line
176 );
177
178 for (i, line) in lines[start..end].iter().enumerate() {
179 result.push_str(&format!("{:>4}|{}\n", start + i + 1, line));
180 }
181
182 let tokens = count_tokens(&result);
183 let full_tokens = count_tokens(&content);
184 let saved = full_tokens.saturating_sub(tokens);
185 let pct = if full_tokens > 0 {
186 (saved as f64 / full_tokens as f64 * 100.0).round() as usize
187 } else {
188 0
189 };
190
191 format!("{result}[ctx_graph symbol: {tokens} tok (full file: {full_tokens} tok, -{pct}%)]")
192}
193
194fn file_path_to_module_prefixes(
195 rel_path: &str,
196 project_root: &str,
197 index: &ProjectIndex,
198) -> Vec<String> {
199 let rel_path_slash = graph_index::graph_match_key(rel_path);
200 let without_ext = rel_path_slash
201 .strip_suffix(".rs")
202 .or_else(|| rel_path_slash.strip_suffix(".ts"))
203 .or_else(|| rel_path_slash.strip_suffix(".tsx"))
204 .or_else(|| rel_path_slash.strip_suffix(".js"))
205 .or_else(|| rel_path_slash.strip_suffix(".py"))
206 .or_else(|| rel_path_slash.strip_suffix(".kt"))
207 .or_else(|| rel_path_slash.strip_suffix(".kts"))
208 .unwrap_or(&rel_path_slash);
209
210 let module_path = without_ext
211 .strip_prefix("src/")
212 .unwrap_or(without_ext)
213 .replace('/', "::");
214
215 let module_path = if module_path.ends_with("::mod") {
216 module_path
217 .strip_suffix("::mod")
218 .unwrap_or(&module_path)
219 .to_string()
220 } else {
221 module_path
222 };
223
224 let crate_name = std::fs::read_to_string(Path::new(project_root).join("Cargo.toml"))
225 .or_else(|_| std::fs::read_to_string(Path::new(project_root).join("package.json")))
226 .ok()
227 .and_then(|c| {
228 c.lines()
229 .find(|l| l.contains("\"name\"") || l.starts_with("name"))
230 .and_then(|l| l.split('"').nth(1))
231 .map(|n| n.replace('-', "_"))
232 })
233 .unwrap_or_default();
234
235 let mut prefixes = vec![
236 format!("crate::{module_path}"),
237 format!("super::{module_path}"),
238 module_path.clone(),
239 ];
240 if !crate_name.is_empty() {
241 prefixes.insert(0, format!("{crate_name}::{module_path}"));
242 }
243
244 let ext = Path::new(rel_path)
245 .extension()
246 .and_then(|e| e.to_str())
247 .unwrap_or("");
248 if matches!(ext, "kt" | "kts") {
249 let abs_path = Path::new(project_root).join(rel_path.trim_start_matches(['/', '\\']));
250 if let Ok(content) = std::fs::read_to_string(abs_path) {
251 if let Some(package_name) = content.lines().map(str::trim).find_map(|line| {
252 line.strip_prefix("package ")
253 .map(|rest| rest.trim().trim_end_matches(';').to_string())
254 }) {
255 prefixes.push(package_name.clone());
256 if let Some(entry) = index.files.get(rel_path) {
257 for export in &entry.exports {
258 prefixes.push(format!("{package_name}.{export}"));
259 }
260 }
261 if let Some(file_stem) = Path::new(rel_path).file_stem().and_then(|s| s.to_str()) {
262 prefixes.push(format!("{package_name}.{file_stem}"));
263 }
264 }
265 }
266 }
267
268 prefixes.sort();
269 prefixes.dedup();
270 prefixes
271}
272
273fn edge_matches_file(edge_to: &str, module_prefixes: &[String]) -> bool {
274 module_prefixes.iter().any(|prefix| {
275 edge_to == *prefix
276 || edge_to.starts_with(&format!("{prefix}::"))
277 || edge_to.starts_with(&format!("{prefix},"))
278 })
279}
280
281fn handle_impact(path: Option<&str>, root: &str) -> String {
282 let Some(target) = path else {
283 return "path is required for 'impact' action".to_string();
284 };
285
286 let Some(index) = ProjectIndex::load(root) else {
287 return "No graph index found. Run ctx_graph with action='build' first.".to_string();
288 };
289
290 let rel_target = graph_index::graph_relative_key(target, root);
291
292 let module_prefixes = file_path_to_module_prefixes(&rel_target, root, &index);
293
294 let direct: Vec<&str> = index
295 .edges
296 .iter()
297 .filter(|e| e.kind == "import" && edge_matches_file(&e.to, &module_prefixes))
298 .map(|e| e.from.as_str())
299 .collect();
300
301 let mut all_dependents: Vec<String> = direct
302 .iter()
303 .map(std::string::ToString::to_string)
304 .collect();
305 for d in &direct {
306 for dep in index.get_reverse_deps(d, 1) {
307 if !all_dependents.contains(&dep) && dep != rel_target {
308 all_dependents.push(dep);
309 }
310 }
311 }
312
313 if all_dependents.is_empty() {
314 return format!(
315 "No files depend on {}",
316 crate::core::protocol::shorten_path(target)
317 );
318 }
319
320 let mut result = format!(
321 "Impact of {} ({} dependents):\n",
322 crate::core::protocol::shorten_path(target),
323 all_dependents.len()
324 );
325
326 if !direct.is_empty() {
327 result.push_str(&format!("\nDirect ({}):\n", direct.len()));
328 for d in &direct {
329 result.push_str(&format!(" {}\n", crate::core::protocol::shorten_path(d)));
330 }
331 }
332
333 let indirect: Vec<&String> = all_dependents
334 .iter()
335 .filter(|d| !direct.contains(&d.as_str()))
336 .collect();
337 if !indirect.is_empty() {
338 result.push_str(&format!("\nIndirect ({}):\n", indirect.len()));
339 for d in &indirect {
340 result.push_str(&format!(" {}\n", crate::core::protocol::shorten_path(d)));
341 }
342 }
343
344 let tokens = count_tokens(&result);
345 format!("{result}[ctx_graph impact: {tokens} tok]")
346}
347
348fn handle_status(root: &str) -> String {
349 let Some(index) = ProjectIndex::load(root) else {
350 return "No graph index. Run ctx_graph action='build' to create one.".to_string();
351 };
352
353 let mut by_lang: HashMap<&str, usize> = HashMap::new();
354 let mut total_tokens = 0usize;
355 for entry in index.files.values() {
356 *by_lang.entry(&entry.language).or_insert(0) += 1;
357 total_tokens += entry.token_count;
358 }
359
360 let mut langs: Vec<_> = by_lang.iter().collect();
361 langs.sort_by(|a, b| b.1.cmp(a.1));
362 let lang_summary: String = langs
363 .iter()
364 .take(5)
365 .map(|(l, c)| format!("{l}:{c}"))
366 .collect::<Vec<_>>()
367 .join(" ");
368
369 format!(
370 "Graph: {} files, {} symbols, {} edges | {} tok total\nLast scan: {}\nLanguages: {lang_summary}\nStored: {}",
371 index.file_count(),
372 index.symbol_count(),
373 index.edge_count(),
374 total_tokens,
375 index.last_scan,
376 ProjectIndex::index_dir(root)
377 .map(|d| d.to_string_lossy().to_string())
378 .unwrap_or_default()
379 )
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[test]
387 fn test_edge_matches_file_crate_prefix() {
388 let prefixes = vec![
389 "lean_ctx::core::cache".to_string(),
390 "crate::core::cache".to_string(),
391 "super::core::cache".to_string(),
392 "core::cache".to_string(),
393 ];
394 assert!(edge_matches_file(
395 "lean_ctx::core::cache::SessionCache",
396 &prefixes
397 ));
398 assert!(edge_matches_file(
399 "crate::core::cache::SessionCache",
400 &prefixes
401 ));
402 assert!(edge_matches_file("crate::core::cache", &prefixes));
403 assert!(!edge_matches_file(
404 "lean_ctx::core::config::Config",
405 &prefixes
406 ));
407 assert!(!edge_matches_file("crate::core::cached_reader", &prefixes));
408 }
409
410 #[test]
411 fn test_file_path_to_module_prefixes_rust() {
412 let index = ProjectIndex::new("/nonexistent");
413 let prefixes = file_path_to_module_prefixes("src/core/cache.rs", "/nonexistent", &index);
414 assert!(prefixes.contains(&"crate::core::cache".to_string()));
415 assert!(prefixes.contains(&"core::cache".to_string()));
416 }
417
418 #[test]
419 fn test_file_path_to_module_prefixes_mod_rs() {
420 let index = ProjectIndex::new("/nonexistent");
421 let prefixes = file_path_to_module_prefixes("src/core/mod.rs", "/nonexistent", &index);
422 assert!(prefixes.contains(&"crate::core".to_string()));
423 assert!(!prefixes.iter().any(|p| p.contains("mod")));
424 }
425}