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 handle_impact(path: Option<&str>, root: &str) -> String {
213    let target = match path {
214        Some(p) => p,
215        None => return "path is required for 'impact' action".to_string(),
216    };
217
218    let index = match ProjectIndex::load(root) {
219        Some(idx) => idx,
220        None => {
221            return "No graph index found. Run ctx_graph with action='build' first.".to_string()
222        }
223    };
224
225    let rel_target = target
226        .strip_prefix(root)
227        .unwrap_or(target)
228        .trim_start_matches('/');
229
230    let dependents = index.get_reverse_deps(rel_target, 2);
231    if dependents.is_empty() {
232        return format!(
233            "No files depend on {}",
234            crate::core::protocol::shorten_path(target)
235        );
236    }
237
238    let direct: Vec<&str> = index
239        .edges
240        .iter()
241        .filter(|e| e.to == rel_target && e.kind == "import")
242        .map(|e| e.from.as_str())
243        .collect();
244
245    let mut result = format!(
246        "Impact of {} ({} dependents):\n",
247        crate::core::protocol::shorten_path(target),
248        dependents.len()
249    );
250
251    if !direct.is_empty() {
252        result.push_str(&format!("\nDirect ({}):\n", direct.len()));
253        for d in &direct {
254            result.push_str(&format!("  {}\n", crate::core::protocol::shorten_path(d)));
255        }
256    }
257
258    let indirect: Vec<&String> = dependents
259        .iter()
260        .filter(|d| !direct.contains(&d.as_str()))
261        .collect();
262    if !indirect.is_empty() {
263        result.push_str(&format!("\nIndirect ({}):\n", indirect.len()));
264        for d in &indirect {
265            result.push_str(&format!("  {}\n", crate::core::protocol::shorten_path(d)));
266        }
267    }
268
269    let tokens = count_tokens(&result);
270    format!("{result}[ctx_graph impact: {tokens} tok]")
271}
272
273fn handle_status(root: &str) -> String {
274    let index = match ProjectIndex::load(root) {
275        Some(idx) => idx,
276        None => return "No graph index. Run ctx_graph action='build' to create one.".to_string(),
277    };
278
279    let mut by_lang: HashMap<&str, usize> = HashMap::new();
280    let mut total_tokens = 0usize;
281    for entry in index.files.values() {
282        *by_lang.entry(&entry.language).or_insert(0) += 1;
283        total_tokens += entry.token_count;
284    }
285
286    let mut langs: Vec<_> = by_lang.iter().collect();
287    langs.sort_by(|a, b| b.1.cmp(a.1));
288    let lang_summary: String = langs
289        .iter()
290        .take(5)
291        .map(|(l, c)| format!("{l}:{c}"))
292        .collect::<Vec<_>>()
293        .join(" ");
294
295    format!(
296        "Graph: {} files, {} symbols, {} edges | {} tok total\nLast scan: {}\nLanguages: {lang_summary}\nStored: {}",
297        index.file_count(),
298        index.symbol_count(),
299        index.edge_count(),
300        total_tokens,
301        index.last_scan,
302        ProjectIndex::index_dir(root)
303            .map(|d| d.to_string_lossy().to_string())
304            .unwrap_or_default()
305    )
306}