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    let import_edges = gp.edges_by_kind("import");
390    let direct: Vec<&str> = import_edges
391        .iter()
392        .filter(|e| edge_matches_file(&e.to, &module_prefixes))
393        .map(|e| e.from.as_str())
394        .collect();
395
396    let mut all_dependents: Vec<String> = direct
397        .iter()
398        .map(std::string::ToString::to_string)
399        .collect();
400    for d in &direct {
401        for dep in gp.dependents(d) {
402            if !all_dependents.contains(&dep) && dep != rel_target {
403                all_dependents.push(dep);
404            }
405        }
406    }
407
408    if all_dependents.is_empty() {
409        return format!(
410            "No files depend on {}",
411            crate::core::protocol::shorten_path(target)
412        );
413    }
414
415    let mut result = format!(
416        "Impact of {} ({} dependents):\n",
417        crate::core::protocol::shorten_path(target),
418        all_dependents.len()
419    );
420
421    if !direct.is_empty() {
422        result.push_str(&format!("\nDirect ({}):\n", direct.len()));
423        for d in &direct {
424            result.push_str(&format!("  {}\n", crate::core::protocol::shorten_path(d)));
425        }
426    }
427
428    let indirect: Vec<&String> = all_dependents
429        .iter()
430        .filter(|d| !direct.contains(&d.as_str()))
431        .collect();
432    if !indirect.is_empty() {
433        result.push_str(&format!("\nIndirect ({}):\n", indirect.len()));
434        for d in &indirect {
435            result.push_str(&format!("  {}\n", crate::core::protocol::shorten_path(d)));
436        }
437    }
438
439    let tokens = count_tokens(&result);
440    format!("{result}[ctx_graph impact: {tokens} tok]")
441}
442
443fn handle_status(root: &str) -> String {
444    let Some(open) = graph_provider::open_best_effort(root) else {
445        return "No graph index. Run ctx_graph action='build' to create one.".to_string();
446    };
447    let gp = &open.provider;
448
449    let file_paths = gp.file_paths();
450    let mut by_lang: HashMap<String, usize> = HashMap::new();
451    let mut total_tokens = 0usize;
452    for path in &file_paths {
453        if let Some(entry) = gp.get_file_entry(path) {
454            *by_lang.entry(entry.language).or_insert(0) += 1;
455            total_tokens += entry.token_count;
456        }
457    }
458
459    let mut langs: Vec<_> = by_lang.iter().collect();
460    langs.sort_by_key(|item| std::cmp::Reverse(*item.1));
461    let lang_summary: String = langs
462        .iter()
463        .take(5)
464        .map(|(l, c)| format!("{l}:{c}"))
465        .collect::<Vec<_>>()
466        .join(" ");
467
468    format!(
469        "Graph: {} files, {} symbols, {} edges ({:?}) | {} tok total\nLast scan: {}\nLanguages: {lang_summary}\nStored: {}",
470        gp.file_count(),
471        gp.symbol_count(),
472        gp.edge_count().unwrap_or(0),
473        open.source,
474        total_tokens,
475        gp.last_scan(),
476        GraphProvider::index_dir(root)
477            .map(|d| d.to_string_lossy().to_string())
478            .unwrap_or_default()
479    )
480}
481
482fn resolve_node_name(graph: &crate::core::property_graph::CodeGraph, node_id: i64) -> String {
483    let conn = graph.connection();
484    conn.query_row(
485        "SELECT name FROM nodes WHERE id = ?1",
486        rusqlite::params![node_id],
487        |row| row.get::<_, String>(0),
488    )
489    .unwrap_or_else(|_| format!("node#{node_id}"))
490}
491
492fn handle_enrich(root: &str) -> String {
493    let graph = match crate::core::property_graph::CodeGraph::open(root) {
494        Ok(g) => g,
495        Err(e) => return format!("Failed to open graph: {e}"),
496    };
497
498    match crate::core::graph_enricher::enrich_graph(&graph, Path::new(root), 500) {
499        Ok(stats) => {
500            let node_count = graph.node_count().unwrap_or(0);
501            let edge_count = graph.edge_count().unwrap_or(0);
502            format!(
503                "Graph enriched.\n{}\nTotal: {node_count} nodes, {edge_count} edges",
504                stats.format_summary()
505            )
506        }
507        Err(e) => format!("Enrichment failed: {e}"),
508    }
509}
510
511fn handle_context_query(query: Option<&str>, root: &str) -> String {
512    let Some(query) = query else {
513        return "Usage: ctx_graph action=context path=\"<query or file path>\"".to_string();
514    };
515
516    let graph = match crate::core::property_graph::CodeGraph::open(root) {
517        Ok(g) => g,
518        Err(e) => return format!("Failed to open graph: {e}"),
519    };
520
521    let gp = graph_provider::open_or_build(root);
522    let mut result = Vec::new();
523
524    if let Ok(Some(node)) = graph.get_node_by_path(query) {
525        result.push(format!("## Context for `{query}`\n"));
526
527        if let Some(node_id) = node.id {
528            let edges_out = graph.edges_from(node_id).unwrap_or_default();
529            let edges_in = graph.edges_to(node_id).unwrap_or_default();
530
531            let mut tests: Vec<String> = Vec::new();
532            let mut commits: Vec<String> = Vec::new();
533            let mut knowledge: Vec<String> = Vec::new();
534            let mut imports: Vec<String> = Vec::new();
535            let mut dependents: Vec<String> = Vec::new();
536
537            for edge in &edges_out {
538                let target = resolve_node_name(&graph, edge.target_id);
539                match edge.kind {
540                    crate::core::property_graph::EdgeKind::TestedBy => tests.push(target),
541                    crate::core::property_graph::EdgeKind::ChangedIn => commits.push(target),
542                    crate::core::property_graph::EdgeKind::MentionedIn => {
543                        knowledge.push(target);
544                    }
545                    crate::core::property_graph::EdgeKind::Imports => imports.push(target),
546                    _ => {}
547                }
548            }
549
550            for edge in &edges_in {
551                let source = resolve_node_name(&graph, edge.source_id);
552                if edge.kind == crate::core::property_graph::EdgeKind::Imports {
553                    dependents.push(source);
554                }
555            }
556
557            if !tests.is_empty() {
558                result.push(format!("**Tests ({}):** {}", tests.len(), tests.join(", ")));
559            }
560            if !commits.is_empty() {
561                result.push(format!(
562                    "**Recent commits ({}):** {}",
563                    commits.len(),
564                    commits
565                        .iter()
566                        .take(5)
567                        .cloned()
568                        .collect::<Vec<_>>()
569                        .join(", ")
570                ));
571            }
572            if !knowledge.is_empty() {
573                result.push(format!(
574                    "**Knowledge ({}):** {}",
575                    knowledge.len(),
576                    knowledge.join(", ")
577                ));
578            }
579            if !imports.is_empty() {
580                result.push(format!(
581                    "**Imports ({}):** {}",
582                    imports.len(),
583                    imports
584                        .iter()
585                        .take(10)
586                        .cloned()
587                        .collect::<Vec<_>>()
588                        .join(", ")
589                ));
590            }
591            if !dependents.is_empty() {
592                result.push(format!(
593                    "**Depended on by ({}):** {}",
594                    dependents.len(),
595                    dependents
596                        .iter()
597                        .take(10)
598                        .cloned()
599                        .collect::<Vec<_>>()
600                        .join(", ")
601                ));
602            }
603
604            if let Ok(impact) = graph.impact_analysis(query, 3)
605                && !impact.affected_files.is_empty()
606            {
607                result.push(format!(
608                    "**Impact radius:** {} files within 3 hops",
609                    impact.affected_files.len()
610                ));
611            }
612        }
613    } else {
614        result.push(format!("## Search: `{query}`\n"));
615
616        // Symbol names (e.g. GDScript `_ready`, or any function/type) live in the
617        // GraphIndex, not the PropertyGraph node table, so resolve them here — a
618        // bare concept query should return hits instead of "nothing found" (#314).
619        let mut symbols = gp
620            .as_ref()
621            .map(|o| o.provider.find_symbols(query, None, None))
622            .unwrap_or_default();
623        let q_lower = query.to_lowercase();
624        symbols.sort_by(|a, b| {
625            (a.name.to_lowercase() != q_lower)
626                .cmp(&(b.name.to_lowercase() != q_lower))
627                .then_with(|| a.file.cmp(&b.file))
628                .then_with(|| a.start_line.cmp(&b.start_line))
629        });
630        if !symbols.is_empty() {
631            result.push(format!("**Symbols ({}):**", symbols.len()));
632            for s in symbols.iter().take(15) {
633                result.push(format!(
634                    "  - {}::{} ({}, {}:{})",
635                    crate::core::protocol::shorten_path(&s.file),
636                    s.name,
637                    s.kind,
638                    s.start_line,
639                    s.end_line
640                ));
641            }
642        }
643
644        let related = gp
645            .as_ref()
646            .map(|o| o.provider.related(query, 2))
647            .unwrap_or_default();
648        if !related.is_empty() {
649            result.push(format!("**Related files ({}):**", related.len()));
650            for f in related.iter().take(15) {
651                result.push(format!("  - {f}"));
652            }
653        }
654
655        if symbols.is_empty() && related.is_empty() {
656            result.push("No matching nodes found in graph.".to_string());
657        }
658    }
659
660    result.join("\n")
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    #[test]
668    fn test_edge_matches_file_crate_prefix() {
669        let prefixes = vec![
670            "lean_ctx::core::cache".to_string(),
671            "crate::core::cache".to_string(),
672            "super::core::cache".to_string(),
673            "core::cache".to_string(),
674        ];
675        assert!(edge_matches_file(
676            "lean_ctx::core::cache::SessionCache",
677            &prefixes
678        ));
679        assert!(edge_matches_file(
680            "crate::core::cache::SessionCache",
681            &prefixes
682        ));
683        assert!(edge_matches_file("crate::core::cache", &prefixes));
684        assert!(!edge_matches_file(
685            "lean_ctx::core::config::Config",
686            &prefixes
687        ));
688        assert!(!edge_matches_file("crate::core::cached_reader", &prefixes));
689    }
690
691    #[test]
692    fn test_file_path_to_module_prefixes_rust() {
693        let gp =
694            GraphProvider::GraphIndex(crate::core::graph_index::ProjectIndex::new("/nonexistent"));
695        let prefixes = file_path_to_module_prefixes("src/core/cache.rs", "/nonexistent", &gp);
696        assert!(prefixes.contains(&"crate::core::cache".to_string()));
697        assert!(prefixes.contains(&"core::cache".to_string()));
698    }
699
700    #[test]
701    fn test_file_path_to_module_prefixes_mod_rs() {
702        let gp =
703            GraphProvider::GraphIndex(crate::core::graph_index::ProjectIndex::new("/nonexistent"));
704        let prefixes = file_path_to_module_prefixes("src/core/mod.rs", "/nonexistent", &gp);
705        assert!(prefixes.contains(&"crate::core".to_string()));
706        assert!(!prefixes.iter().any(|p| p.contains("mod")));
707    }
708
709    #[test]
710    fn test_file_path_to_module_prefixes_gd_uses_path() {
711        // GDScript import edges store the resolved project-relative path, so the
712        // path itself must be among the prefixes `graph impact` matches on (#314).
713        let gp =
714            GraphProvider::GraphIndex(crate::core::graph_index::ProjectIndex::new("/nonexistent"));
715        let prefixes = file_path_to_module_prefixes("actors/Player.gd", "/nonexistent", &gp);
716        assert!(
717            prefixes.contains(&"actors/Player.gd".to_string()),
718            "got: {prefixes:?}"
719        );
720    }
721
722    #[test]
723    fn test_edge_matches_file_gd_path() {
724        let prefixes = vec!["actors/Base.gd".to_string()];
725        assert!(edge_matches_file("actors/Base.gd", &prefixes));
726        assert!(!edge_matches_file("actors/Enemy.gd", &prefixes));
727    }
728}
729
730/// End-to-end GDScript graph coverage on a real Godot fixture (#314): four `.gd`
731/// scripts with `_ready` definitions and `res://` import edges, exercised through
732/// the public graph actions (context / impact / bare symbol).
733#[cfg(test)]
734mod gdscript_p0_tests {
735    use super::*;
736
737    fn write(root: &std::path::Path, rel: &str, content: &str) {
738        let p = root.join(rel);
739        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
740        std::fs::write(p, content).unwrap();
741    }
742
743    /// Minimal Godot project: `Player`/`Enemy` extend `Base`, `main` preloads
744    /// `Player`; every script defines `_ready`. Returns (tempdir guard, root).
745    fn godot_fixture() -> (tempfile::TempDir, String) {
746        let tmp = tempfile::tempdir().expect("tempdir");
747        let data = tmp.path().join("data");
748        std::fs::create_dir_all(&data).unwrap();
749        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
750
751        let proj = tmp.path().join("game");
752        std::fs::create_dir_all(&proj).unwrap();
753        write(
754            &proj,
755            "project.godot",
756            "[application]\nconfig/name=\"Fixture\"\n",
757        );
758        write(
759            &proj,
760            "actors/Base.gd",
761            "extends Node\n\nfunc _ready():\n\tpass\n",
762        );
763        write(
764            &proj,
765            "actors/Player.gd",
766            "extends \"res://actors/Base.gd\"\n\nfunc _ready():\n\tprint(\"player\")\n",
767        );
768        write(
769            &proj,
770            "actors/Enemy.gd",
771            "extends \"res://actors/Base.gd\"\n\nfunc _ready():\n\tprint(\"enemy\")\n",
772        );
773        write(
774            &proj,
775            "main.gd",
776            "const Player = preload(\"res://actors/Player.gd\")\n\nfunc _ready():\n\tprint(\"main\")\n",
777        );
778
779        (tmp, proj.to_string_lossy().to_string())
780    }
781
782    #[test]
783    fn context_resolves_gdscript_symbol() {
784        let _lock = crate::core::data_dir::test_env_lock();
785        let (_tmp, root) = godot_fixture();
786        let _ = handle_build(&root);
787        let out = handle_context_query(Some("_ready"), &root);
788        assert!(
789            out.contains("_ready"),
790            "context should surface _ready symbols: {out}"
791        );
792        assert!(!out.contains("No matching nodes found"), "got: {out}");
793        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
794    }
795
796    #[test]
797    fn impact_lists_gdscript_dependents() {
798        let _lock = crate::core::data_dir::test_env_lock();
799        let (_tmp, root) = godot_fixture();
800        let _ = handle_build(&root);
801        let out = handle_impact(Some("actors/Base.gd"), &root);
802        assert!(
803            out.contains("Player.gd"),
804            "Base.gd dependents should include Player: {out}"
805        );
806        assert!(
807            out.contains("Enemy.gd"),
808            "Base.gd dependents should include Enemy: {out}"
809        );
810        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
811    }
812
813    #[test]
814    fn bare_symbol_resolves_gdscript() {
815        let _lock = crate::core::data_dir::test_env_lock();
816        let (_tmp, root) = godot_fixture();
817        let _ = handle_build(&root);
818        let mut cache = crate::core::cache::SessionCache::new();
819        let out = handle_symbol(
820            Some("_ready"),
821            &root,
822            &mut cache,
823            crate::tools::CrpMode::Off,
824        );
825        // Four `_ready` defs → a disambiguation list (or a snippet), never the
826        // pre-#314 "Invalid symbol spec" / "not found" errors.
827        assert!(out.contains("_ready"), "bare symbol should resolve: {out}");
828        assert!(!out.contains("Invalid symbol spec"), "got: {out}");
829        assert!(!out.to_lowercase().contains("not found"), "got: {out}");
830        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
831    }
832}