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
8#[allow(clippy::too_many_arguments)]
9pub fn handle(
10    action: &str,
11    path: Option<&str>,
12    root: &str,
13    cache: &mut crate::core::cache::SessionCache,
14    crp_mode: crate::tools::CrpMode,
15    depth: Option<usize>,
16    kind: Option<&str>,
17    to: Option<&str>,
18    format: Option<&str>,
19    since: Option<&str>,
20) -> String {
21    match action {
22        "build" => handle_build(root),
23        "related" => handle_related(path, root),
24        "symbol" => handle_symbol(path, root, cache, crp_mode),
25        "impact" => handle_impact(path, root),
26        "status" => handle_status(root),
27        "enrich" => handle_enrich(root),
28        "context" => handle_context_query(path, root),
29        "diagram" => crate::tools::ctx_graph_diagram::handle(path, depth, kind, root),
30        "neighbors" => crate::tools::ctx_graph_primitives::neighbors(path, root, depth, format),
31        "path" => crate::tools::ctx_graph_primitives::shortest_path(path, to, root, format),
32        "explain" => crate::tools::ctx_graph_primitives::explain(path, root, format),
33        "diff" => crate::tools::ctx_graph_diff::diff(since, root, format),
34        _ => "Unknown action. Use: build, related, symbol, impact, status, enrich, context, \
35diagram, neighbors, path, explain, diff"
36            .to_string(),
37    }
38}
39
40fn handle_build(root: &str) -> String {
41    let index = graph_index::scan(root);
42
43    let mut by_lang: HashMap<&str, (usize, usize)> = HashMap::new();
44    for entry in index.files.values() {
45        let e = by_lang.entry(&entry.language).or_insert((0, 0));
46        e.0 += 1;
47        e.1 += entry.token_count;
48    }
49
50    let mut result = Vec::new();
51    result.push(format!(
52        "Project Graph: {} files, {} symbols, {} edges",
53        index.file_count(),
54        index.symbol_count(),
55        index.edge_count()
56    ));
57
58    let mut langs: Vec<_> = by_lang.iter().collect();
59    langs.sort_by_key(|(_, v)| std::cmp::Reverse(v.1));
60    result.push("\nLanguages:".to_string());
61    for (lang, (count, tokens)) in &langs {
62        result.push(format!("  {lang}: {count} files, {tokens} tok"));
63    }
64
65    let mut import_counts: HashMap<&str, usize> = HashMap::new();
66    for edge in &index.edges {
67        if edge.kind == "import" {
68            *import_counts.entry(&edge.to).or_insert(0) += 1;
69        }
70    }
71    let mut hotspots: Vec<_> = import_counts.iter().collect();
72    hotspots.sort_by_key(|x| std::cmp::Reverse(*x.1));
73
74    if !hotspots.is_empty() {
75        result.push(format!("\nMost imported ({}):", hotspots.len().min(10)));
76        for (module, count) in hotspots.iter().take(10) {
77            result.push(format!("  {module}: imported by {count} files"));
78        }
79    }
80
81    if let Some(dir) = GraphProvider::index_dir(root) {
82        result.push(format!(
83            "\nIndex saved: {}",
84            crate::core::protocol::shorten_path(&dir.to_string_lossy())
85        ));
86    }
87
88    let output = result.join("\n");
89    let tokens = count_tokens(&output);
90    format!("{output}\n[ctx_graph build: {tokens} tok]")
91}
92
93fn handle_related(path: Option<&str>, root: &str) -> String {
94    let Some(target) = path else {
95        return "path is required for 'related' action".to_string();
96    };
97
98    let Some(open) = graph_provider::open_or_build(root) else {
99        return "No graph index found. Run ctx_graph with action='build' first.".to_string();
100    };
101
102    let rel_target = graph_index::graph_relative_key(target, root);
103
104    let related = open.provider.related(&rel_target, 2);
105    if related.is_empty() {
106        return format!(
107            "No related files found for {}",
108            crate::core::protocol::shorten_path(target)
109        );
110    }
111
112    let mut result = format!(
113        "Files related to {} ({}):\n",
114        crate::core::protocol::shorten_path(target),
115        related.len()
116    );
117    for r in &related {
118        result.push_str(&format!("  {}\n", crate::core::protocol::shorten_path(r)));
119    }
120
121    let tokens = count_tokens(&result);
122    format!("{result}[ctx_graph related: {tokens} tok]")
123}
124
125fn handle_symbol(
126    path: Option<&str>,
127    root: &str,
128    cache: &mut crate::core::cache::SessionCache,
129    crp_mode: crate::tools::CrpMode,
130) -> String {
131    let Some(spec) = path else {
132        return "path is required for 'symbol' action (format: <file>::<symbol>, or a bare <symbol> name)".to_string();
133    };
134
135    let Some(open) = graph_provider::open_or_build(root) else {
136        return "No graph index found. Run ctx_graph with action='build' first.".to_string();
137    };
138
139    // Bare symbol name (no `::`): resolve against the symbol table so GDScript
140    // (and every other language) symbols are reachable without a file qualifier
141    // (#314).
142    let Some((file_part, symbol_name)) = spec.split_once("::") else {
143        return resolve_bare_symbol(&open.provider, spec, root, cache, crp_mode);
144    };
145
146    let rel_file = graph_index::graph_relative_key(file_part, root);
147
148    let key = format!("{rel_file}::{symbol_name}");
149    let Some(symbol) = open.provider.get_symbol(&key) else {
150        let available = open
151            .provider
152            .find_symbols(symbol_name, Some(&rel_file), None);
153        if available.is_empty() {
154            return format!(
155                "Symbol '{symbol_name}' not found in {rel_file}. Run ctx_graph action='build' to update the index."
156            );
157        }
158        let names: Vec<String> = available
159            .iter()
160            .take(10)
161            .map(|s| format!("{}::{}", s.file, s.name))
162            .collect();
163        return format!(
164            "Symbol '{symbol_name}' not found in {rel_file}.\nAvailable symbols:\n  {}",
165            names.join("\n  ")
166        );
167    };
168
169    let abs_path = if Path::new(file_part).is_absolute() {
170        file_part.to_string()
171    } else {
172        Path::new(root)
173            .join(rel_file.trim_start_matches(['/', '\\']))
174            .to_string_lossy()
175            .to_string()
176    };
177
178    render_symbol_snippet(&symbol, &abs_path, &rel_file, cache, crp_mode)
179}
180
181/// Resolve a bare symbol name (no `<file>::` qualifier) against the symbol table.
182/// One unambiguous hit renders the snippet; otherwise the candidates are listed
183/// so the caller can disambiguate with `<file>::<symbol>` (#314).
184fn resolve_bare_symbol(
185    provider: &GraphProvider,
186    name: &str,
187    root: &str,
188    cache: &mut crate::core::cache::SessionCache,
189    crp_mode: crate::tools::CrpMode,
190) -> String {
191    let matches = provider.find_symbols(name, None, None);
192    if matches.is_empty() {
193        return format!(
194            "Symbol '{name}' not found. Run ctx_graph action='build' to update the index."
195        );
196    }
197
198    let name_lower = name.to_lowercase();
199    let exact: Vec<&graph_provider::SymbolInfo> = matches
200        .iter()
201        .filter(|s| s.name.to_lowercase() == name_lower)
202        .collect();
203
204    if let [only] = exact.as_slice() {
205        let abs_path = Path::new(root)
206            .join(only.file.trim_start_matches(['/', '\\']))
207            .to_string_lossy()
208            .to_string();
209        return render_symbol_snippet(only, &abs_path, &only.file, cache, crp_mode);
210    }
211
212    // Several identically-named symbols, or only substring hits → list them.
213    let shortlist: Vec<&graph_provider::SymbolInfo> = if exact.is_empty() {
214        matches.iter().collect()
215    } else {
216        exact
217    };
218    let mut lines = vec![format!(
219        "Symbol '{name}' matches {} entries — pick one with `<file>::{name}`:",
220        shortlist.len()
221    )];
222    for s in shortlist.iter().take(15) {
223        lines.push(format!(
224            "  {}::{} ({}, {}:{})",
225            crate::core::protocol::shorten_path(&s.file),
226            s.name,
227            s.kind,
228            s.start_line,
229            s.end_line
230        ));
231    }
232    lines.join("\n")
233}
234
235/// Render a symbol's source snippet with the standard token-savings footer.
236/// Shared by the qualified (`<file>::<symbol>`) and bare-name resolution paths.
237fn render_symbol_snippet(
238    symbol: &graph_provider::SymbolInfo,
239    abs_path: &str,
240    rel_display: &str,
241    cache: &mut crate::core::cache::SessionCache,
242    crp_mode: crate::tools::CrpMode,
243) -> String {
244    let content = match std::fs::read_to_string(abs_path) {
245        Ok(c) => c,
246        Err(e) => return format!("Cannot read {abs_path}: {e}"),
247    };
248
249    let lines: Vec<&str> = content.lines().collect();
250    let start = symbol.start_line.saturating_sub(1);
251    let end = symbol.end_line.min(lines.len());
252
253    if start >= lines.len() {
254        return crate::tools::ctx_read::handle(cache, abs_path, "full", crp_mode);
255    }
256
257    let mut result = format!(
258        "{}::{} ({}:{}-{})\n",
259        crate::core::protocol::shorten_path(rel_display),
260        symbol.name,
261        symbol.kind,
262        symbol.start_line,
263        symbol.end_line
264    );
265
266    for (i, line) in lines[start..end].iter().enumerate() {
267        result.push_str(&format!("{:>4}|{}\n", start + i + 1, line));
268    }
269
270    let tokens = count_tokens(&result);
271    let full_tokens = count_tokens(&content);
272    let saved = full_tokens.saturating_sub(tokens);
273    let pct = if full_tokens > 0 {
274        (saved as f64 / full_tokens as f64 * 100.0).round() as usize
275    } else {
276        0
277    };
278
279    format!("{result}[ctx_graph symbol: {tokens} tok (full file: {full_tokens} tok, -{pct}%)]")
280}
281
282fn file_path_to_module_prefixes(
283    rel_path: &str,
284    project_root: &str,
285    provider: &GraphProvider,
286) -> Vec<String> {
287    let rel_path_slash = graph_index::graph_match_key(rel_path);
288    let without_ext = rel_path_slash
289        .strip_suffix(".rs")
290        .or_else(|| rel_path_slash.strip_suffix(".ts"))
291        .or_else(|| rel_path_slash.strip_suffix(".tsx"))
292        .or_else(|| rel_path_slash.strip_suffix(".js"))
293        .or_else(|| rel_path_slash.strip_suffix(".py"))
294        .or_else(|| rel_path_slash.strip_suffix(".kt"))
295        .or_else(|| rel_path_slash.strip_suffix(".kts"))
296        .or_else(|| rel_path_slash.strip_suffix(".gd"))
297        .unwrap_or(&rel_path_slash);
298
299    let module_path = without_ext
300        .strip_prefix("src/")
301        .unwrap_or(without_ext)
302        .replace('/', "::");
303
304    let module_path = if module_path.ends_with("::mod") {
305        module_path
306            .strip_suffix("::mod")
307            .unwrap_or(&module_path)
308            .to_string()
309    } else {
310        module_path
311    };
312
313    let crate_name = std::fs::read_to_string(Path::new(project_root).join("Cargo.toml"))
314        .or_else(|_| std::fs::read_to_string(Path::new(project_root).join("package.json")))
315        .ok()
316        .and_then(|c| {
317            c.lines()
318                .find(|l| l.contains("\"name\"") || l.starts_with("name"))
319                .and_then(|l| l.split('"').nth(1))
320                .map(|n| n.replace('-', "_"))
321        })
322        .unwrap_or_default();
323
324    let mut prefixes = vec![
325        format!("crate::{module_path}"),
326        format!("super::{module_path}"),
327        module_path.clone(),
328    ];
329    if !crate_name.is_empty() {
330        prefixes.insert(0, format!("{crate_name}::{module_path}"));
331    }
332
333    let ext = Path::new(rel_path)
334        .extension()
335        .and_then(|e| e.to_str())
336        .unwrap_or("");
337    if ext == "gd" {
338        // GDScript import edges resolve to the target script's project-relative
339        // path (e.g. "actors/Player.gd"), not a Rust-style module path, so the
340        // path itself is the key that `graph impact` must match on (#314).
341        prefixes.push(rel_path_slash.clone());
342    }
343    if matches!(ext, "kt" | "kts") {
344        let abs_path = Path::new(project_root).join(rel_path.trim_start_matches(['/', '\\']));
345        if let Ok(content) = std::fs::read_to_string(abs_path)
346            && let Some(package_name) = content.lines().map(str::trim).find_map(|line| {
347                line.strip_prefix("package ")
348                    .map(|rest| rest.trim().trim_end_matches(';').to_string())
349            })
350        {
351            prefixes.push(package_name.clone());
352            if let Some(entry) = provider.get_file_entry(rel_path) {
353                for export in &entry.exports {
354                    prefixes.push(format!("{package_name}.{export}"));
355                }
356            }
357            if let Some(file_stem) = Path::new(rel_path).file_stem().and_then(|s| s.to_str()) {
358                prefixes.push(format!("{package_name}.{file_stem}"));
359            }
360        }
361    }
362
363    prefixes.sort();
364    prefixes.dedup();
365    prefixes
366}
367
368fn edge_matches_file(edge_to: &str, module_prefixes: &[String]) -> bool {
369    module_prefixes.iter().any(|prefix| {
370        edge_to == *prefix
371            || edge_to.starts_with(&format!("{prefix}::"))
372            || edge_to.starts_with(&format!("{prefix},"))
373    })
374}
375
376fn handle_impact(path: Option<&str>, root: &str) -> String {
377    let Some(target) = path else {
378        return "path is required for 'impact' action".to_string();
379    };
380
381    let Some(open) = graph_provider::open_or_build(root) else {
382        return "No graph index found. Run ctx_graph with action='build' first.".to_string();
383    };
384    let gp = &open.provider;
385
386    let rel_target = graph_index::graph_relative_key(target, root);
387    let module_prefixes = file_path_to_module_prefixes(&rel_target, root, gp);
388
389    // Direct importers come from two complementary lookups, merged + deduped:
390    //   1. `dependents(rel_target)` — the import resolver records edges keyed by
391    //      the target's project-relative *file path*, so this is the primary,
392    //      backend-agnostic match (works for Rust/TS/JS/Python/…).
393    //   2. `edge_matches_file` over module-path prefixes — additionally catches
394    //      edges keyed by a module/symbol path rather than a file (Rust `mod`,
395    //      Kotlin package, barrel re-exports), which the file-path match misses.
396    let mut direct: Vec<String> = gp.dependents(&rel_target);
397    for e in gp.edges_by_kind("import") {
398        if edge_matches_file(&e.to, &module_prefixes) && !direct.contains(&e.from) {
399            direct.push(e.from);
400        }
401    }
402    direct.retain(|d| *d != rel_target);
403
404    let mut all_dependents: Vec<String> = direct.clone();
405    for d in &direct {
406        for dep in gp.dependents(d) {
407            if !all_dependents.contains(&dep) && dep != rel_target {
408                all_dependents.push(dep);
409            }
410        }
411    }
412
413    if all_dependents.is_empty() {
414        return format!(
415            "No files depend on {}",
416            crate::core::protocol::shorten_path(target)
417        );
418    }
419
420    let mut result = format!(
421        "Impact of {} ({} dependents):\n",
422        crate::core::protocol::shorten_path(target),
423        all_dependents.len()
424    );
425
426    if !direct.is_empty() {
427        result.push_str(&format!("\nDirect ({}):\n", direct.len()));
428        for d in &direct {
429            result.push_str(&format!("  {}\n", crate::core::protocol::shorten_path(d)));
430        }
431    }
432
433    let indirect: Vec<&String> = all_dependents
434        .iter()
435        .filter(|d| !direct.contains(d))
436        .collect();
437    if !indirect.is_empty() {
438        result.push_str(&format!("\nIndirect ({}):\n", indirect.len()));
439        for d in &indirect {
440            result.push_str(&format!("  {}\n", crate::core::protocol::shorten_path(d)));
441        }
442    }
443
444    let tokens = count_tokens(&result);
445    format!("{result}[ctx_graph impact: {tokens} tok]")
446}
447
448fn handle_status(root: &str) -> String {
449    let Some(open) = graph_provider::open_best_effort(root) else {
450        return "No graph index. Run ctx_graph action='build' to create one.".to_string();
451    };
452    let gp = &open.provider;
453
454    let file_paths = gp.file_paths();
455    let mut by_lang: HashMap<String, usize> = HashMap::new();
456    let mut total_tokens = 0usize;
457    for path in &file_paths {
458        if let Some(entry) = gp.get_file_entry(path) {
459            *by_lang.entry(entry.language).or_insert(0) += 1;
460            total_tokens += entry.token_count;
461        }
462    }
463
464    let mut langs: Vec<_> = by_lang.iter().collect();
465    langs.sort_by_key(|item| std::cmp::Reverse(*item.1));
466    let lang_summary: String = langs
467        .iter()
468        .take(5)
469        .map(|(l, c)| format!("{l}:{c}"))
470        .collect::<Vec<_>>()
471        .join(" ");
472
473    format!(
474        "Graph: {} files, {} symbols, {} edges ({:?}) | {} tok total\nLast scan: {}\nLanguages: {lang_summary}\nStored: {}",
475        gp.file_count(),
476        gp.symbol_count(),
477        gp.edge_count().unwrap_or(0),
478        open.source,
479        total_tokens,
480        gp.last_scan(),
481        GraphProvider::index_dir(root)
482            .map(|d| d.to_string_lossy().to_string())
483            .unwrap_or_default()
484    )
485}
486
487fn resolve_node_name(graph: &crate::core::property_graph::CodeGraph, node_id: i64) -> String {
488    let conn = graph.connection();
489    conn.query_row(
490        "SELECT name FROM nodes WHERE id = ?1",
491        rusqlite::params![node_id],
492        |row| row.get::<_, String>(0),
493    )
494    .unwrap_or_else(|_| format!("node#{node_id}"))
495}
496
497fn handle_enrich(root: &str) -> String {
498    let graph = match crate::core::property_graph::CodeGraph::open(root) {
499        Ok(g) => g,
500        Err(e) => return format!("Failed to open graph: {e}"),
501    };
502
503    match crate::core::graph_enricher::enrich_graph(&graph, Path::new(root), 500) {
504        Ok(stats) => {
505            let node_count = graph.node_count().unwrap_or(0);
506            let edge_count = graph.edge_count().unwrap_or(0);
507            format!(
508                "Graph enriched.\n{}\nTotal: {node_count} nodes, {edge_count} edges",
509                stats.format_summary()
510            )
511        }
512        Err(e) => format!("Enrichment failed: {e}"),
513    }
514}
515
516fn handle_context_query(query: Option<&str>, root: &str) -> String {
517    let Some(query) = query else {
518        return "Usage: ctx_graph action=context path=\"<query or file path>\"".to_string();
519    };
520
521    let graph = match crate::core::property_graph::CodeGraph::open(root) {
522        Ok(g) => g,
523        Err(e) => return format!("Failed to open graph: {e}"),
524    };
525
526    let gp = graph_provider::open_or_build(root);
527    let mut result = Vec::new();
528
529    if let Ok(Some(node)) = graph.get_node_by_path(query) {
530        result.push(format!("## Context for `{query}`\n"));
531
532        if let Some(node_id) = node.id {
533            let edges_out = graph.edges_from(node_id).unwrap_or_default();
534            let edges_in = graph.edges_to(node_id).unwrap_or_default();
535
536            let mut tests: Vec<String> = Vec::new();
537            let mut commits: Vec<String> = Vec::new();
538            let mut knowledge: Vec<String> = Vec::new();
539            let mut imports: Vec<String> = Vec::new();
540            let mut dependents: Vec<String> = Vec::new();
541
542            for edge in &edges_out {
543                let target = resolve_node_name(&graph, edge.target_id);
544                match edge.kind {
545                    crate::core::property_graph::EdgeKind::TestedBy => tests.push(target),
546                    crate::core::property_graph::EdgeKind::ChangedIn => commits.push(target),
547                    crate::core::property_graph::EdgeKind::MentionedIn => {
548                        knowledge.push(target);
549                    }
550                    crate::core::property_graph::EdgeKind::Imports => imports.push(target),
551                    _ => {}
552                }
553            }
554
555            for edge in &edges_in {
556                let source = resolve_node_name(&graph, edge.source_id);
557                if edge.kind == crate::core::property_graph::EdgeKind::Imports {
558                    dependents.push(source);
559                }
560            }
561
562            if !tests.is_empty() {
563                result.push(format!("**Tests ({}):** {}", tests.len(), tests.join(", ")));
564            }
565            if !commits.is_empty() {
566                result.push(format!(
567                    "**Recent commits ({}):** {}",
568                    commits.len(),
569                    commits
570                        .iter()
571                        .take(5)
572                        .cloned()
573                        .collect::<Vec<_>>()
574                        .join(", ")
575                ));
576            }
577            if !knowledge.is_empty() {
578                result.push(format!(
579                    "**Knowledge ({}):** {}",
580                    knowledge.len(),
581                    knowledge.join(", ")
582                ));
583            }
584            if !imports.is_empty() {
585                result.push(format!(
586                    "**Imports ({}):** {}",
587                    imports.len(),
588                    imports
589                        .iter()
590                        .take(10)
591                        .cloned()
592                        .collect::<Vec<_>>()
593                        .join(", ")
594                ));
595            }
596            if !dependents.is_empty() {
597                result.push(format!(
598                    "**Depended on by ({}):** {}",
599                    dependents.len(),
600                    dependents
601                        .iter()
602                        .take(10)
603                        .cloned()
604                        .collect::<Vec<_>>()
605                        .join(", ")
606                ));
607            }
608
609            if let Ok(impact) = graph.impact_analysis(query, 3)
610                && !impact.affected_files.is_empty()
611            {
612                result.push(format!(
613                    "**Impact radius:** {} files within 3 hops",
614                    impact.affected_files.len()
615                ));
616            }
617        }
618    } else {
619        result.push(format!("## Search: `{query}`\n"));
620
621        // Symbol names (e.g. GDScript `_ready`, or any function/type) live in the
622        // GraphIndex, not the PropertyGraph node table, so resolve them here — a
623        // bare concept query should return hits instead of "nothing found" (#314).
624        let mut symbols = gp
625            .as_ref()
626            .map(|o| o.provider.find_symbols(query, None, None))
627            .unwrap_or_default();
628        let q_lower = query.to_lowercase();
629        symbols.sort_by(|a, b| {
630            (a.name.to_lowercase() != q_lower)
631                .cmp(&(b.name.to_lowercase() != q_lower))
632                .then_with(|| a.file.cmp(&b.file))
633                .then_with(|| a.start_line.cmp(&b.start_line))
634        });
635        if !symbols.is_empty() {
636            result.push(format!("**Symbols ({}):**", symbols.len()));
637            for s in symbols.iter().take(15) {
638                result.push(format!(
639                    "  - {}::{} ({}, {}:{})",
640                    crate::core::protocol::shorten_path(&s.file),
641                    s.name,
642                    s.kind,
643                    s.start_line,
644                    s.end_line
645                ));
646            }
647        }
648
649        let related = gp
650            .as_ref()
651            .map(|o| o.provider.related(query, 2))
652            .unwrap_or_default();
653        if !related.is_empty() {
654            result.push(format!("**Related files ({}):**", related.len()));
655            for f in related.iter().take(15) {
656                result.push(format!("  - {f}"));
657            }
658        }
659
660        if symbols.is_empty() && related.is_empty() {
661            result.push("No matching nodes found in graph.".to_string());
662        }
663    }
664
665    result.join("\n")
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn test_edge_matches_file_crate_prefix() {
674        let prefixes = vec![
675            "lean_ctx::core::cache".to_string(),
676            "crate::core::cache".to_string(),
677            "super::core::cache".to_string(),
678            "core::cache".to_string(),
679        ];
680        assert!(edge_matches_file(
681            "lean_ctx::core::cache::SessionCache",
682            &prefixes
683        ));
684        assert!(edge_matches_file(
685            "crate::core::cache::SessionCache",
686            &prefixes
687        ));
688        assert!(edge_matches_file("crate::core::cache", &prefixes));
689        assert!(!edge_matches_file(
690            "lean_ctx::core::config::Config",
691            &prefixes
692        ));
693        assert!(!edge_matches_file("crate::core::cached_reader", &prefixes));
694    }
695
696    #[test]
697    fn test_file_path_to_module_prefixes_rust() {
698        let gp =
699            GraphProvider::GraphIndex(crate::core::graph_index::ProjectIndex::new("/nonexistent"));
700        let prefixes = file_path_to_module_prefixes("src/core/cache.rs", "/nonexistent", &gp);
701        assert!(prefixes.contains(&"crate::core::cache".to_string()));
702        assert!(prefixes.contains(&"core::cache".to_string()));
703    }
704
705    #[test]
706    fn test_file_path_to_module_prefixes_mod_rs() {
707        let gp =
708            GraphProvider::GraphIndex(crate::core::graph_index::ProjectIndex::new("/nonexistent"));
709        let prefixes = file_path_to_module_prefixes("src/core/mod.rs", "/nonexistent", &gp);
710        assert!(prefixes.contains(&"crate::core".to_string()));
711        assert!(!prefixes.iter().any(|p| p.contains("mod")));
712    }
713
714    #[test]
715    fn test_file_path_to_module_prefixes_gd_uses_path() {
716        // GDScript import edges store the resolved project-relative path, so the
717        // path itself must be among the prefixes `graph impact` matches on (#314).
718        let gp =
719            GraphProvider::GraphIndex(crate::core::graph_index::ProjectIndex::new("/nonexistent"));
720        let prefixes = file_path_to_module_prefixes("actors/Player.gd", "/nonexistent", &gp);
721        assert!(
722            prefixes.contains(&"actors/Player.gd".to_string()),
723            "got: {prefixes:?}"
724        );
725    }
726
727    #[test]
728    fn test_edge_matches_file_gd_path() {
729        let prefixes = vec!["actors/Base.gd".to_string()];
730        assert!(edge_matches_file("actors/Base.gd", &prefixes));
731        assert!(!edge_matches_file("actors/Enemy.gd", &prefixes));
732    }
733}
734
735/// End-to-end GDScript graph coverage on a real Godot fixture (#314): four `.gd`
736/// scripts with `_ready` definitions and `res://` import edges, exercised through
737/// the public graph actions (context / impact / bare symbol).
738#[cfg(test)]
739mod gdscript_p0_tests {
740    use super::*;
741
742    fn write(root: &std::path::Path, rel: &str, content: &str) {
743        let p = root.join(rel);
744        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
745        std::fs::write(p, content).unwrap();
746    }
747
748    /// Minimal Godot project: `Player`/`Enemy` extend `Base`, `main` preloads
749    /// `Player`; every script defines `_ready`. Returns (tempdir guard, root).
750    fn godot_fixture() -> (tempfile::TempDir, String) {
751        let tmp = tempfile::tempdir().expect("tempdir");
752        let data = tmp.path().join("data");
753        std::fs::create_dir_all(&data).unwrap();
754        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
755
756        let proj = tmp.path().join("game");
757        std::fs::create_dir_all(&proj).unwrap();
758        write(
759            &proj,
760            "project.godot",
761            "[application]\nconfig/name=\"Fixture\"\n",
762        );
763        write(
764            &proj,
765            "actors/Base.gd",
766            "extends Node\n\nfunc _ready():\n\tpass\n",
767        );
768        write(
769            &proj,
770            "actors/Player.gd",
771            "extends \"res://actors/Base.gd\"\n\nfunc _ready():\n\tprint(\"player\")\n",
772        );
773        write(
774            &proj,
775            "actors/Enemy.gd",
776            "extends \"res://actors/Base.gd\"\n\nfunc _ready():\n\tprint(\"enemy\")\n",
777        );
778        write(
779            &proj,
780            "main.gd",
781            "const Player = preload(\"res://actors/Player.gd\")\n\nfunc _ready():\n\tprint(\"main\")\n",
782        );
783
784        (tmp, proj.to_string_lossy().to_string())
785    }
786
787    #[test]
788    fn context_resolves_gdscript_symbol() {
789        let _lock = crate::core::data_dir::test_env_lock();
790        let (_tmp, root) = godot_fixture();
791        let _ = handle_build(&root);
792        let out = handle_context_query(Some("_ready"), &root);
793        assert!(
794            out.contains("_ready"),
795            "context should surface _ready symbols: {out}"
796        );
797        assert!(!out.contains("No matching nodes found"), "got: {out}");
798        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
799    }
800
801    #[test]
802    fn impact_lists_gdscript_dependents() {
803        let _lock = crate::core::data_dir::test_env_lock();
804        let (_tmp, root) = godot_fixture();
805        let _ = handle_build(&root);
806        let out = handle_impact(Some("actors/Base.gd"), &root);
807        assert!(
808            out.contains("Player.gd"),
809            "Base.gd dependents should include Player: {out}"
810        );
811        assert!(
812            out.contains("Enemy.gd"),
813            "Base.gd dependents should include Enemy: {out}"
814        );
815        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
816    }
817
818    #[test]
819    fn bare_symbol_resolves_gdscript() {
820        let _lock = crate::core::data_dir::test_env_lock();
821        let (_tmp, root) = godot_fixture();
822        let _ = handle_build(&root);
823        let mut cache = crate::core::cache::SessionCache::new();
824        let out = handle_symbol(
825            Some("_ready"),
826            &root,
827            &mut cache,
828            crate::tools::CrpMode::Off,
829        );
830        // Four `_ready` defs → a disambiguation list (or a snippet), never the
831        // pre-#314 "Invalid symbol spec" / "not found" errors.
832        assert!(out.contains("_ready"), "bare symbol should resolve: {out}");
833        assert!(!out.contains("Invalid symbol spec"), "got: {out}");
834        assert!(!out.to_lowercase().contains("not found"), "got: {out}");
835        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
836    }
837}