Skip to main content

lean_ctx/tools/
ctx_graph.rs

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}