Skip to main content

lean_ctx/tools/
ctx_graph.rs

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