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