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