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