Skip to main content

lean_ctx_server/tools/
ctx_graph.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::collections::HashMap;
3use std::path::Path;
4
5use lean_ctx::core::graph_index;
6use lean_ctx::core::graph_provider::{self, GraphProvider};
7use lean_ctx::core::tokens::count_tokens;
8
9#[allow(clippy::too_many_arguments)]
10pub fn handle(
11    action: &str,
12    path: Option<&str>,
13    root: &str,
14    cache: &mut lean_ctx::core::cache::SessionCache,
15    crp_mode: crate::tools::CrpMode,
16    depth: Option<usize>,
17    kind: Option<&str>,
18    to: Option<&str>,
19    format: Option<&str>,
20    since: Option<&str>,
21) -> String {
22    match action {
23        "build" => handle_build(root),
24        "related" => handle_related(path, root),
25        "symbol" => handle_symbol(path, root, cache, crp_mode),
26        "impact" => handle_impact(path, root),
27        "status" => handle_status(root),
28        "enrich" => handle_enrich(root),
29        "context" => handle_context_query(path, root),
30        "diagram" => crate::tools::ctx_graph_diagram::handle(path, depth, kind, root),
31        "neighbors" => crate::tools::ctx_graph_primitives::neighbors(path, root, depth, format),
32        "path" => crate::tools::ctx_graph_primitives::shortest_path(path, to, root, format),
33        "explain" => crate::tools::ctx_graph_primitives::explain(path, root, format),
34        "diff" => crate::tools::ctx_graph_diff::diff(since, root, format),
35        _ => "Unknown action. Use: build, related, symbol, impact, status, enrich, context, \
36diagram, neighbors, path, explain, diff"
37            .to_string(),
38    }
39}
40
41fn handle_build(root: &str) -> String {
42    let index = graph_index::scan(root);
43
44    let mut by_lang: HashMap<&str, (usize, usize)> = HashMap::new();
45    for entry in index.files.values() {
46        let e = by_lang.entry(&entry.language).or_insert((0, 0));
47        e.0 += 1;
48        e.1 += entry.token_count;
49    }
50
51    let mut result = Vec::new();
52    result.push(format!(
53        "Project Graph: {} files, {} symbols, {} edges",
54        index.file_count(),
55        index.symbol_count(),
56        index.edge_count()
57    ));
58
59    let mut langs: Vec<_> = by_lang.iter().collect();
60    langs.sort_by_key(|(_, v)| std::cmp::Reverse(v.1));
61    result.push("\nLanguages:".to_string());
62    for (lang, (count, tokens)) in &langs {
63        result.push(format!("  {lang}: {count} files, {tokens} tok"));
64    }
65
66    let mut import_counts: HashMap<&str, usize> = HashMap::new();
67    for edge in &index.edges {
68        if edge.kind == "import" {
69            *import_counts.entry(&edge.to).or_insert(0) += 1;
70        }
71    }
72    let mut hotspots: Vec<_> = import_counts.iter().collect();
73    hotspots.sort_by_key(|x| std::cmp::Reverse(*x.1));
74
75    if !hotspots.is_empty() {
76        result.push(format!("\nMost imported ({}):", hotspots.len().min(10)));
77        for (module, count) in hotspots.iter().take(10) {
78            result.push(format!("  {module}: imported by {count} files"));
79        }
80    }
81
82    if let Some(dir) = GraphProvider::index_dir(root) {
83        result.push(format!(
84            "\nIndex saved: {}",
85            lean_ctx::core::protocol::shorten_path(&dir.to_string_lossy())
86        ));
87    }
88
89    let output = result.join("\n");
90    let tokens = count_tokens(&output);
91    format!("{output}\n[ctx_graph build: {tokens} tok]")
92}
93
94fn handle_related(path: Option<&str>, root: &str) -> String {
95    let Some(target) = path else {
96        return "path is required for 'related' action".to_string();
97    };
98
99    let Some(open) = graph_provider::open_or_build(root) else {
100        return "No graph index found. Run ctx_graph with action='build' first.".to_string();
101    };
102
103    let rel_target = graph_index::graph_relative_key(target, root);
104
105    let related = open.provider.related(&rel_target, 2);
106    if related.is_empty() {
107        return format!(
108            "No related files found for {}",
109            lean_ctx::core::protocol::shorten_path(target)
110        );
111    }
112
113    let mut result = format!(
114        "Files related to {} ({}):\n",
115        lean_ctx::core::protocol::shorten_path(target),
116        related.len()
117    );
118    for r in &related {
119        result.push_str(&format!(
120            "  {}\n",
121            lean_ctx::core::protocol::shorten_path(r)
122        ));
123    }
124
125    let tokens = count_tokens(&result);
126    format!("{result}[ctx_graph related: {tokens} tok]")
127}
128
129fn handle_symbol(
130    path: Option<&str>,
131    root: &str,
132    cache: &mut lean_ctx::core::cache::SessionCache,
133    crp_mode: crate::tools::CrpMode,
134) -> String {
135    let Some(spec) = path else {
136        return "path is required for 'symbol' action (format: <file>::<symbol>, or a bare <symbol> name)".to_string();
137    };
138
139    let Some(open) = graph_provider::open_or_build(root) else {
140        return "No graph index found. Run ctx_graph with action='build' first.".to_string();
141    };
142
143    // Bare symbol name (no `::`): resolve against the symbol table so GDScript
144    // (and every other language) symbols are reachable without a file qualifier
145    // (#314).
146    let Some((file_part, symbol_name)) = spec.split_once("::") else {
147        return resolve_bare_symbol(&open.provider, spec, root, cache, crp_mode);
148    };
149
150    let rel_file = graph_index::graph_relative_key(file_part, root);
151
152    let key = format!("{rel_file}::{symbol_name}");
153    let Some(symbol) = open.provider.get_symbol(&key) else {
154        let available = open
155            .provider
156            .find_symbols(symbol_name, Some(&rel_file), None);
157        if available.is_empty() {
158            return format!(
159                "Symbol '{symbol_name}' not found in {rel_file}. Run ctx_graph action='build' to update the index."
160            );
161        }
162        let names: Vec<String> = available
163            .iter()
164            .take(10)
165            .map(|s| format!("{}::{}", s.file, s.name))
166            .collect();
167        return format!(
168            "Symbol '{symbol_name}' not found in {rel_file}.\nAvailable symbols:\n  {}",
169            names.join("\n  ")
170        );
171    };
172
173    let abs_path = if Path::new(file_part).is_absolute() {
174        file_part.to_string()
175    } else {
176        Path::new(root)
177            .join(rel_file.trim_start_matches(['/', '\\']))
178            .to_string_lossy()
179            .to_string()
180    };
181
182    render_symbol_snippet(&symbol, &abs_path, &rel_file, root, cache, crp_mode)
183}
184
185/// Resolve a bare symbol name (no `<file>::` qualifier) against the symbol table.
186/// One unambiguous hit renders the snippet; otherwise the candidates are listed
187/// so the caller can disambiguate with `<file>::<symbol>` (#314).
188fn resolve_bare_symbol(
189    provider: &GraphProvider,
190    name: &str,
191    root: &str,
192    cache: &mut lean_ctx::core::cache::SessionCache,
193    crp_mode: crate::tools::CrpMode,
194) -> String {
195    let matches = provider.find_symbols(name, None, None);
196    if matches.is_empty() {
197        return format!(
198            "Symbol '{name}' not found. Run ctx_graph action='build' to update the index."
199        );
200    }
201
202    let name_lower = name.to_lowercase();
203    let exact: Vec<&graph_provider::SymbolInfo> = matches
204        .iter()
205        .filter(|s| s.name.to_lowercase() == name_lower)
206        .collect();
207
208    if let [only] = exact.as_slice() {
209        let abs_path = Path::new(root)
210            .join(only.file.trim_start_matches(['/', '\\']))
211            .to_string_lossy()
212            .to_string();
213        return render_symbol_snippet(only, &abs_path, &only.file, root, cache, crp_mode);
214    }
215
216    // Several identically-named symbols, or only substring hits → list them.
217    let shortlist: Vec<&graph_provider::SymbolInfo> = if exact.is_empty() {
218        matches.iter().collect()
219    } else {
220        exact
221    };
222    let mut lines = vec![format!(
223        "Symbol '{name}' matches {} entries — pick one with `<file>::{name}`:",
224        shortlist.len()
225    )];
226    for s in shortlist.iter().take(15) {
227        lines.push(format!(
228            "  {}::{} ({}, {}:{})",
229            lean_ctx::core::protocol::shorten_path(&s.file),
230            s.name,
231            s.kind,
232            s.start_line,
233            s.end_line
234        ));
235    }
236    lines.join("\n")
237}
238
239/// Render a symbol's source snippet with the standard token-savings footer.
240/// Shared by the qualified (`<file>::<symbol>`) and bare-name resolution paths.
241///
242/// The graph index is a discovery hint, not an authorization: the snippet is
243/// cut from the source as the common rooted admitted reader delivers it NOW
244/// (G01), under `root`. A source that is withheld yields its content-free
245/// reason, never the lines the index points at.
246fn render_symbol_snippet(
247    symbol: &graph_provider::SymbolInfo,
248    abs_path: &str,
249    rel_display: &str,
250    root: &str,
251    cache: &mut lean_ctx::core::cache::SessionCache,
252    crp_mode: crate::tools::CrpMode,
253) -> String {
254    use crate::tools::admitted_sources::SourceRead;
255    let mut remaining = lean_ctx::core::limits::max_read_bytes();
256    let content = match crate::tools::admitted_sources::read(abs_path, root, TOOL, &mut remaining) {
257        SourceRead::Admitted(read) => read.content,
258        SourceRead::Withheld(reason) | SourceRead::Unavailable(reason) => {
259            return format!("Cannot read {abs_path}: {reason}");
260        }
261    };
262
263    let lines: Vec<&str> = content.lines().collect();
264    let start = symbol.start_line.saturating_sub(1);
265    let end = symbol.end_line.min(lines.len());
266
267    if start >= lines.len() {
268        // The read path admits only inside a request scope; bind it so this
269        // fallback is admitted for every caller (MCP dispatch and the CLI).
270        return crate::tools::admitted_sources::as_agent_acquisition(root, || {
271            crate::tools::ctx_read::handle(cache, abs_path, "full", crp_mode)
272        });
273    }
274
275    let mut result = format!(
276        "{}::{} ({}:{}-{})\n",
277        lean_ctx::core::protocol::shorten_path(rel_display),
278        symbol.name,
279        symbol.kind,
280        symbol.start_line,
281        symbol.end_line
282    );
283
284    for (i, line) in lines[start..end].iter().enumerate() {
285        result.push_str(&format!("{:>4}|{}\n", start + i + 1, line));
286    }
287
288    let tokens = count_tokens(&result);
289    let full_tokens = count_tokens(&content);
290    let saved = full_tokens.saturating_sub(tokens);
291    let pct = if full_tokens > 0 {
292        (saved as f64 / full_tokens as f64 * 100.0).round() as usize
293    } else {
294        0
295    };
296
297    format!("{result}[ctx_graph symbol: {tokens} tok (full file: {full_tokens} tok, -{pct}%)]")
298}
299
300/// Tool identity the sources are read for (path jail, role rules, audit).
301const TOOL: &str = "ctx_graph";
302
303/// The admitted text of `path` (relative to `root`, or absolute inside it),
304/// for derivations that only steer matching and are never rendered. `None`
305/// when the source is absent, unreadable or withheld.
306fn admitted_text(path: &str, root: &str) -> Option<String> {
307    let mut remaining = lean_ctx::core::limits::max_read_bytes();
308    match crate::tools::admitted_sources::read(path, root, TOOL, &mut remaining) {
309        crate::tools::admitted_sources::SourceRead::Admitted(read) => Some(read.content),
310        crate::tools::admitted_sources::SourceRead::Withheld(_)
311        | crate::tools::admitted_sources::SourceRead::Unavailable(_) => None,
312    }
313}
314
315fn file_path_to_module_prefixes(
316    rel_path: &str,
317    project_root: &str,
318    provider: &GraphProvider,
319) -> Vec<String> {
320    let rel_path_slash = graph_index::graph_match_key(rel_path);
321    let without_ext = rel_path_slash
322        .strip_suffix(".rs")
323        .or_else(|| rel_path_slash.strip_suffix(".ts"))
324        .or_else(|| rel_path_slash.strip_suffix(".tsx"))
325        .or_else(|| rel_path_slash.strip_suffix(".js"))
326        .or_else(|| rel_path_slash.strip_suffix(".py"))
327        .or_else(|| rel_path_slash.strip_suffix(".kt"))
328        .or_else(|| rel_path_slash.strip_suffix(".kts"))
329        .or_else(|| rel_path_slash.strip_suffix(".gd"))
330        .unwrap_or(&rel_path_slash);
331
332    let module_path = without_ext
333        .strip_prefix("src/")
334        .unwrap_or(without_ext)
335        .replace('/', "::");
336
337    let module_path = if module_path.ends_with("::mod") {
338        module_path
339            .strip_suffix("::mod")
340            .unwrap_or(&module_path)
341            .to_string()
342    } else {
343        module_path
344    };
345
346    // Manifest and source are read through the admitted reader like any other
347    // source: a withheld manifest contributes no crate name.
348    let crate_name = admitted_text("Cargo.toml", project_root)
349        .or_else(|| admitted_text("package.json", project_root))
350        .and_then(|c| {
351            c.lines()
352                .find(|l| l.contains("\"name\"") || l.starts_with("name"))
353                .and_then(|l| l.split('"').nth(1))
354                .map(|n| n.replace('-', "_"))
355        })
356        .unwrap_or_default();
357
358    let mut prefixes = vec![
359        format!("crate::{module_path}"),
360        format!("super::{module_path}"),
361        module_path.clone(),
362    ];
363    if !crate_name.is_empty() {
364        prefixes.insert(0, format!("{crate_name}::{module_path}"));
365    }
366
367    let ext = Path::new(rel_path)
368        .extension()
369        .and_then(|e| e.to_str())
370        .unwrap_or("");
371    if ext == "gd" {
372        // GDScript import edges resolve to the target script's project-relative
373        // path (e.g. "actors/Player.gd"), not a Rust-style module path, so the
374        // path itself is the key that `graph impact` must match on (#314).
375        prefixes.push(rel_path_slash.clone());
376    }
377    if matches!(ext, "kt" | "kts") {
378        if let Some(content) = admitted_text(rel_path.trim_start_matches(['/', '\\']), project_root)
379            && let Some(package_name) = content.lines().map(str::trim).find_map(|line| {
380                line.strip_prefix("package ")
381                    .map(|rest| rest.trim().trim_end_matches(';').to_string())
382            })
383        {
384            prefixes.push(package_name.clone());
385            if let Some(entry) = provider.get_file_entry(rel_path) {
386                for export in &entry.exports {
387                    prefixes.push(format!("{package_name}.{export}"));
388                }
389            }
390            if let Some(file_stem) = Path::new(rel_path).file_stem().and_then(|s| s.to_str()) {
391                prefixes.push(format!("{package_name}.{file_stem}"));
392            }
393        }
394    }
395
396    prefixes.sort();
397    prefixes.dedup();
398    prefixes
399}
400
401fn edge_matches_file(edge_to: &str, module_prefixes: &[String]) -> bool {
402    module_prefixes.iter().any(|prefix| {
403        edge_to == *prefix
404            || edge_to.starts_with(&format!("{prefix}::"))
405            || edge_to.starts_with(&format!("{prefix},"))
406    })
407}
408
409fn handle_impact(path: Option<&str>, root: &str) -> String {
410    let Some(target) = path else {
411        return "path is required for 'impact' action".to_string();
412    };
413
414    let Some(open) = graph_provider::open_or_build(root) else {
415        return "No graph index found. Run ctx_graph with action='build' first.".to_string();
416    };
417    let gp = &open.provider;
418
419    let rel_target = graph_index::graph_relative_key(target, root);
420    let module_prefixes = file_path_to_module_prefixes(&rel_target, root, gp);
421
422    // Direct importers come from two complementary lookups, merged + deduped:
423    //   1. `dependents(rel_target)` — the import resolver records edges keyed by
424    //      the target's project-relative *file path*, so this is the primary,
425    //      backend-agnostic match (works for Rust/TS/JS/Python/…).
426    //   2. `edge_matches_file` over module-path prefixes — additionally catches
427    //      edges keyed by a module/symbol path rather than a file (Rust `mod`,
428    //      Kotlin package, barrel re-exports), which the file-path match misses.
429    let mut direct: Vec<String> = gp.dependents(&rel_target);
430    for e in gp.edges_by_kind("import") {
431        if edge_matches_file(&e.to, &module_prefixes) && !direct.contains(&e.from) {
432            direct.push(e.from);
433        }
434    }
435    direct.retain(|d| *d != rel_target);
436
437    let mut all_dependents: Vec<String> = direct.clone();
438    for d in &direct {
439        for dep in gp.dependents(d) {
440            if !all_dependents.contains(&dep) && dep != rel_target {
441                all_dependents.push(dep);
442            }
443        }
444    }
445
446    if all_dependents.is_empty() {
447        return format!(
448            "No files depend on {}",
449            lean_ctx::core::protocol::shorten_path(target)
450        );
451    }
452
453    let mut result = format!(
454        "Impact of {} ({} dependents):\n",
455        lean_ctx::core::protocol::shorten_path(target),
456        all_dependents.len()
457    );
458
459    if !direct.is_empty() {
460        result.push_str(&format!("\nDirect ({}):\n", direct.len()));
461        for d in &direct {
462            result.push_str(&format!(
463                "  {}\n",
464                lean_ctx::core::protocol::shorten_path(d)
465            ));
466        }
467    }
468
469    let indirect: Vec<&String> = all_dependents
470        .iter()
471        .filter(|d| !direct.contains(d))
472        .collect();
473    if !indirect.is_empty() {
474        result.push_str(&format!("\nIndirect ({}):\n", indirect.len()));
475        for d in &indirect {
476            result.push_str(&format!(
477                "  {}\n",
478                lean_ctx::core::protocol::shorten_path(d)
479            ));
480        }
481    }
482
483    let tokens = count_tokens(&result);
484    format!("{result}[ctx_graph impact: {tokens} tok]")
485}
486
487fn handle_status(root: &str) -> String {
488    let Some(open) = graph_provider::open_best_effort(root) else {
489        return "No graph index. Run ctx_graph action='build' to create one.".to_string();
490    };
491    let gp = &open.provider;
492
493    let file_paths = gp.file_paths();
494    let mut by_lang: HashMap<String, usize> = HashMap::new();
495    let mut total_tokens = 0usize;
496    for path in &file_paths {
497        if let Some(entry) = gp.get_file_entry(path) {
498            *by_lang.entry(entry.language).or_insert(0) += 1;
499            total_tokens += entry.token_count;
500        }
501    }
502
503    let mut langs: Vec<_> = by_lang.iter().collect();
504    langs.sort_by_key(|item| std::cmp::Reverse(*item.1));
505    let lang_summary: String = langs
506        .iter()
507        .take(5)
508        .map(|(l, c)| format!("{l}:{c}"))
509        .collect::<Vec<_>>()
510        .join(" ");
511
512    let semantic = lean_ctx::core::property_graph::CodeGraph::open(root).map_or_else(
513        |_| String::new(),
514        |g| {
515            let mode = lean_ctx::core::config::SemanticMode::for_project(root);
516            format!(
517                "\n{}",
518                lean_ctx::core::semantic::status_line(&g, mode, root)
519            )
520        },
521    );
522    format!(
523        "Graph: {} files, {} symbols, {} edges ({:?}) | {} tok total\nLast scan: {}\nLanguages: {lang_summary}{semantic}\nStored: {}",
524        gp.file_count(),
525        gp.symbol_count(),
526        gp.edge_count().unwrap_or(0),
527        open.source,
528        total_tokens,
529        gp.last_scan(),
530        GraphProvider::index_dir(root)
531            .map(|d| d.to_string_lossy().to_string())
532            .unwrap_or_default()
533    )
534}
535
536/// The name of the edge end `node_id`, when that reference may be served now
537/// (G04): a file or module node while its file is served, a symbol or test
538/// node while its file still serves the name. `None`: the end is skipped as
539/// if it were not in the graph. A node without a file on record (commit,
540/// knowledge, issue, CI run) follows the originless rule (`serves_node_parts`).
541fn served_node_name(
542    graph: &lean_ctx::core::property_graph::CodeGraph,
543    node_id: i64,
544) -> Option<String> {
545    let conn = graph.connection();
546    let Ok((name, kind, file_path)) = conn.query_row(
547        "SELECT n.name, n.kind, p.path FROM nodes n JOIN paths p ON p.id = n.file_id \
548         WHERE n.id = ?1",
549        rusqlite::params![node_id],
550        |row| {
551            Ok((
552                row.get::<_, String>(0)?,
553                row.get::<_, String>(1)?,
554                row.get::<_, String>(2)?,
555            ))
556        },
557    ) else {
558        return Some(format!("node#{node_id}"));
559    };
560    let served = graph.serves_node_parts(&kind, &file_path, &name, None);
561    served.then_some(name)
562}
563
564fn handle_enrich(root: &str) -> String {
565    let graph = match lean_ctx::core::property_graph::CodeGraph::open(root) {
566        Ok(g) => g,
567        Err(e) => return format!("Failed to open graph: {e}"),
568    };
569
570    match lean_ctx::core::graph_enricher::enrich_graph(&graph, Path::new(root), 500) {
571        Ok(stats) => {
572            let node_count = graph.node_count().unwrap_or(0);
573            let edge_count = graph.edge_count().unwrap_or(0);
574            format!(
575                "Graph enriched.\n{}\nTotal: {node_count} nodes, {edge_count} edges",
576                stats.format_summary()
577            )
578        }
579        Err(e) => format!("Enrichment failed: {e}"),
580    }
581}
582
583fn handle_context_query(query: Option<&str>, root: &str) -> String {
584    let Some(query) = query else {
585        return "Usage: ctx_graph action=context path=\"<query or file path>\"".to_string();
586    };
587
588    let graph = match lean_ctx::core::property_graph::CodeGraph::open(root) {
589        Ok(g) => g,
590        Err(e) => return format!("Failed to open graph: {e}"),
591    };
592
593    let gp = graph_provider::open_or_build(root);
594    let mut result = Vec::new();
595
596    // A node whose file is no longer served is treated as not in the graph.
597    if let Ok(Some(node)) = graph.get_node_by_path(query)
598        && graph.serves_file(&node.file_path)
599    {
600        result.push(format!("## Context for `{query}`\n"));
601
602        if let Some(node_id) = node.id {
603            let edges_out = graph.edges_from(node_id).unwrap_or_default();
604            let edges_in = graph.edges_to(node_id).unwrap_or_default();
605
606            let mut tests: Vec<String> = Vec::new();
607            let mut commits: Vec<String> = Vec::new();
608            let mut knowledge: Vec<String> = Vec::new();
609            let mut imports: Vec<String> = Vec::new();
610            let mut dependents: Vec<String> = Vec::new();
611
612            for edge in &edges_out {
613                let Some(target) = served_node_name(&graph, edge.target_id) else {
614                    continue;
615                };
616                match edge.kind {
617                    lean_ctx::core::property_graph::EdgeKind::TestedBy => tests.push(target),
618                    lean_ctx::core::property_graph::EdgeKind::ChangedIn => commits.push(target),
619                    lean_ctx::core::property_graph::EdgeKind::MentionedIn => {
620                        knowledge.push(target);
621                    }
622                    lean_ctx::core::property_graph::EdgeKind::Imports => imports.push(target),
623                    _ => {}
624                }
625            }
626
627            for edge in &edges_in {
628                if edge.kind == lean_ctx::core::property_graph::EdgeKind::Imports
629                    && let Some(source) = served_node_name(&graph, edge.source_id)
630                {
631                    dependents.push(source);
632                }
633            }
634
635            if !tests.is_empty() {
636                result.push(format!("**Tests ({}):** {}", tests.len(), tests.join(", ")));
637            }
638            if !commits.is_empty() {
639                result.push(format!(
640                    "**Recent commits ({}):** {}",
641                    commits.len(),
642                    commits
643                        .iter()
644                        .take(5)
645                        .cloned()
646                        .collect::<Vec<_>>()
647                        .join(", ")
648                ));
649            }
650            if !knowledge.is_empty() {
651                result.push(format!(
652                    "**Knowledge ({}):** {}",
653                    knowledge.len(),
654                    knowledge.join(", ")
655                ));
656            }
657            if !imports.is_empty() {
658                result.push(format!(
659                    "**Imports ({}):** {}",
660                    imports.len(),
661                    imports
662                        .iter()
663                        .take(10)
664                        .cloned()
665                        .collect::<Vec<_>>()
666                        .join(", ")
667                ));
668            }
669            if !dependents.is_empty() {
670                result.push(format!(
671                    "**Depended on by ({}):** {}",
672                    dependents.len(),
673                    dependents
674                        .iter()
675                        .take(10)
676                        .cloned()
677                        .collect::<Vec<_>>()
678                        .join(", ")
679                ));
680            }
681
682            if let Ok(impact) = graph.impact_analysis(query, 3) {
683                // Only files that are served now count toward the radius.
684                let affected = impact
685                    .affected_files
686                    .iter()
687                    .filter(|file| graph.serves_file(file))
688                    .count();
689                if affected > 0 {
690                    result.push(format!("**Impact radius:** {affected} files within 3 hops"));
691                }
692            }
693        }
694    } else {
695        result.push(format!("## Search: `{query}`\n"));
696
697        // Symbol names (e.g. GDScript `_ready`, or any function/type) live in the
698        // GraphIndex, not the PropertyGraph node table, so resolve them here — a
699        // bare concept query should return hits instead of "nothing found" (#314).
700        let mut symbols = gp
701            .as_ref()
702            .map(|o| o.provider.find_symbols(query, None, None))
703            .unwrap_or_default();
704        let q_lower = query.to_lowercase();
705        symbols.sort_by(|a, b| {
706            (a.name.to_lowercase() != q_lower)
707                .cmp(&(b.name.to_lowercase() != q_lower))
708                .then_with(|| a.file.cmp(&b.file))
709                .then_with(|| a.start_line.cmp(&b.start_line))
710        });
711        if !symbols.is_empty() {
712            result.push(format!("**Symbols ({}):**", symbols.len()));
713            for s in symbols.iter().take(15) {
714                result.push(format!(
715                    "  - {}::{} ({}, {}:{})",
716                    lean_ctx::core::protocol::shorten_path(&s.file),
717                    s.name,
718                    s.kind,
719                    s.start_line,
720                    s.end_line
721                ));
722            }
723        }
724
725        let related = gp
726            .as_ref()
727            .map(|o| o.provider.related(query, 2))
728            .unwrap_or_default();
729        if !related.is_empty() {
730            result.push(format!("**Related files ({}):**", related.len()));
731            for f in related.iter().take(15) {
732                result.push(format!("  - {f}"));
733            }
734        }
735
736        if symbols.is_empty() && related.is_empty() {
737            result.push("No matching nodes found in graph.".to_string());
738        }
739    }
740
741    result.join("\n")
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    #[test]
749    fn test_edge_matches_file_crate_prefix() {
750        let prefixes = vec![
751            "lean_ctx::core::cache".to_string(),
752            "crate::core::cache".to_string(),
753            "super::core::cache".to_string(),
754            "core::cache".to_string(),
755        ];
756        assert!(edge_matches_file(
757            "lean_ctx::core::cache::SessionCache",
758            &prefixes
759        ));
760        assert!(edge_matches_file(
761            "crate::core::cache::SessionCache",
762            &prefixes
763        ));
764        assert!(edge_matches_file("crate::core::cache", &prefixes));
765        assert!(!edge_matches_file(
766            "lean_ctx::core::config::Config",
767            &prefixes
768        ));
769        assert!(!edge_matches_file("crate::core::cached_reader", &prefixes));
770    }
771
772    #[test]
773    fn test_file_path_to_module_prefixes_rust() {
774        let gp = GraphProvider::GraphIndex(lean_ctx::core::graph_index::ProjectIndex::new(
775            "/nonexistent",
776        ));
777        let prefixes = file_path_to_module_prefixes("src/core/cache.rs", "/nonexistent", &gp);
778        assert!(prefixes.contains(&"crate::core::cache".to_string()));
779        assert!(prefixes.contains(&"core::cache".to_string()));
780    }
781
782    #[test]
783    fn test_file_path_to_module_prefixes_mod_rs() {
784        let gp = GraphProvider::GraphIndex(lean_ctx::core::graph_index::ProjectIndex::new(
785            "/nonexistent",
786        ));
787        let prefixes = file_path_to_module_prefixes("src/core/mod.rs", "/nonexistent", &gp);
788        assert!(prefixes.contains(&"crate::core".to_string()));
789        assert!(!prefixes.iter().any(|p| p.contains("mod")));
790    }
791
792    #[test]
793    fn test_file_path_to_module_prefixes_gd_uses_path() {
794        // GDScript import edges store the resolved project-relative path, so the
795        // path itself must be among the prefixes `graph impact` matches on (#314).
796        let gp = GraphProvider::GraphIndex(lean_ctx::core::graph_index::ProjectIndex::new(
797            "/nonexistent",
798        ));
799        let prefixes = file_path_to_module_prefixes("actors/Player.gd", "/nonexistent", &gp);
800        assert!(
801            prefixes.contains(&"actors/Player.gd".to_string()),
802            "got: {prefixes:?}"
803        );
804    }
805
806    #[test]
807    fn test_edge_matches_file_gd_path() {
808        let prefixes = vec!["actors/Base.gd".to_string()];
809        assert!(edge_matches_file("actors/Base.gd", &prefixes));
810        assert!(!edge_matches_file("actors/Enemy.gd", &prefixes));
811    }
812}
813
814/// End-to-end GDScript graph coverage on a real Godot fixture (#314): four `.gd`
815/// scripts with `_ready` definitions and `res://` import edges, exercised through
816/// the public graph actions (context / impact / bare symbol).
817#[cfg(test)]
818mod gdscript_p0_tests {
819    use super::*;
820
821    fn write(root: &std::path::Path, rel: &str, content: &str) {
822        let p = root.join(rel);
823        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
824        std::fs::write(p, content).unwrap();
825    }
826
827    /// Minimal Godot project: `Player`/`Enemy` extend `Base`, `main` preloads
828    /// `Player`; every script defines `_ready`. Returns (tempdir guard, root).
829    fn godot_fixture() -> (tempfile::TempDir, String) {
830        let tmp = tempfile::tempdir().expect("tempdir");
831        let data = tmp.path().join("data");
832        std::fs::create_dir_all(&data).unwrap();
833        lean_ctx::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
834
835        let proj = tmp.path().join("game");
836        std::fs::create_dir_all(&proj).unwrap();
837        write(
838            &proj,
839            "project.godot",
840            "[application]\nconfig/name=\"Fixture\"\n",
841        );
842        write(
843            &proj,
844            "actors/Base.gd",
845            "extends Node\n\nfunc _ready():\n\tpass\n",
846        );
847        write(
848            &proj,
849            "actors/Player.gd",
850            "extends \"res://actors/Base.gd\"\n\nfunc _ready():\n\tprint(\"player\")\n",
851        );
852        write(
853            &proj,
854            "actors/Enemy.gd",
855            "extends \"res://actors/Base.gd\"\n\nfunc _ready():\n\tprint(\"enemy\")\n",
856        );
857        write(
858            &proj,
859            "main.gd",
860            "const Player = preload(\"res://actors/Player.gd\")\n\nfunc _ready():\n\tprint(\"main\")\n",
861        );
862
863        (tmp, proj.to_string_lossy().to_string())
864    }
865
866    #[test]
867    fn context_resolves_gdscript_symbol() {
868        let _lock = lean_ctx::core::data_dir::test_env_lock();
869        let (_tmp, root) = godot_fixture();
870        let _ = handle_build(&root);
871        let out = handle_context_query(Some("_ready"), &root);
872        assert!(
873            out.contains("_ready"),
874            "context should surface _ready symbols: {out}"
875        );
876        assert!(!out.contains("No matching nodes found"), "got: {out}");
877        lean_ctx::test_env::remove_var("LEAN_CTX_DATA_DIR");
878    }
879
880    #[test]
881    fn impact_lists_gdscript_dependents() {
882        let _lock = lean_ctx::core::data_dir::test_env_lock();
883        let (_tmp, root) = godot_fixture();
884        let _ = handle_build(&root);
885        let out = handle_impact(Some("actors/Base.gd"), &root);
886        assert!(
887            out.contains("Player.gd"),
888            "Base.gd dependents should include Player: {out}"
889        );
890        assert!(
891            out.contains("Enemy.gd"),
892            "Base.gd dependents should include Enemy: {out}"
893        );
894        lean_ctx::test_env::remove_var("LEAN_CTX_DATA_DIR");
895    }
896
897    #[test]
898    fn bare_symbol_resolves_gdscript() {
899        let _lock = lean_ctx::core::data_dir::test_env_lock();
900        let (_tmp, root) = godot_fixture();
901        let _ = handle_build(&root);
902        let mut cache = lean_ctx::core::cache::SessionCache::new();
903        let out = handle_symbol(
904            Some("_ready"),
905            &root,
906            &mut cache,
907            crate::tools::CrpMode::Off,
908        );
909        // Four `_ready` defs → a disambiguation list (or a snippet), never the
910        // pre-#314 "Invalid symbol spec" / "not found" errors.
911        assert!(out.contains("_ready"), "bare symbol should resolve: {out}");
912        assert!(!out.contains("Invalid symbol spec"), "got: {out}");
913        assert!(!out.to_lowercase().contains("not found"), "got: {out}");
914        lean_ctx::test_env::remove_var("LEAN_CTX_DATA_DIR");
915    }
916}
917
918/// G01: what `ctx_graph` renders from a source is cut from the admitted text.
919#[cfg(test)]
920mod admission_tests {
921    use super::*;
922    use lean_ctx::core::policy::runtime::TestPolicyOverride;
923
924    fn policy(body: &str) -> lean_ctx::core::policy::ResolvedPolicy {
925        lean_ctx::core::policy::load(&format!(
926            "name = \"ctx-graph-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n{body}"
927        ))
928        .unwrap()
929    }
930
931    fn symbol(
932        file: &str,
933        name: &str,
934        start_line: usize,
935        end_line: usize,
936    ) -> graph_provider::SymbolInfo {
937        graph_provider::SymbolInfo {
938            name: name.to_owned(),
939            file: file.to_owned(),
940            kind: "fn".to_owned(),
941            start_line,
942            end_line,
943            is_exported: true,
944        }
945    }
946
947    /// The old snippet renderer read the file raw (`fs::read_to_string`): a
948    /// value a project rule masks was printed, and a source the policy blocks
949    /// was printed in full. The index entry alone must authorize neither.
950    #[test]
951    fn a_symbol_snippet_is_cut_from_the_admitted_source_or_withheld() {
952        let _data = lean_ctx::core::data_dir::isolated_data_dir();
953        let project = tempfile::tempdir().unwrap();
954        let root = project.path().to_string_lossy().into_owned();
955        let billing = project.path().join("billing.rs");
956        std::fs::write(
957            &billing,
958            "pub fn charge() {\n    let customer = \"CUS-1234\";\n}\n",
959        )
960        .unwrap();
961        let billing = billing.to_string_lossy().into_owned();
962        let charge = symbol("billing.rs", "charge", 1, 3);
963        let mut cache = lean_ctx::core::cache::SessionCache::new();
964        let off = crate::tools::CrpMode::Off;
965        {
966            // Control: a source no rule touches is rendered as before.
967            let _community = TestPolicyOverride::set(None);
968            let out =
969                render_symbol_snippet(&charge, &billing, "billing.rs", &root, &mut cache, off);
970            assert!(
971                out.contains("   2|    let customer = \"CUS-1234\";\n"),
972                "{out}"
973            );
974            assert!(out.contains("[ctx_graph symbol: "), "{out}");
975        }
976        {
977            let _policy =
978                TestPolicyOverride::set(Some(policy("[redaction]\ncustomer = \"CUS-[0-9]{4}\"\n")));
979            let out =
980                render_symbol_snippet(&charge, &billing, "billing.rs", &root, &mut cache, off);
981            assert!(!out.contains("CUS-1234"), "{out}");
982            assert!(out.contains("[REDACTED:customer]"), "{out}");
983        }
984        {
985            let plan = project.path().join("plan.rs");
986            std::fs::write(
987                &plan,
988                "CONFIDENTIAL\npub fn hidden() {\n    body_marker();\n}\n",
989            )
990            .unwrap();
991            let _policy = TestPolicyOverride::set(Some(policy(
992                "[filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
993            )));
994            let out = render_symbol_snippet(
995                &symbol("plan.rs", "hidden", 2, 4),
996                &plan.to_string_lossy(),
997                "plan.rs",
998                &root,
999                &mut cache,
1000                off,
1001            );
1002            assert!(out.starts_with("Cannot read "), "{out}");
1003            assert!(out.contains("withheld"), "{out}");
1004            assert!(!out.contains("body_marker"), "{out}");
1005            assert!(
1006                cache.get_full_content(&plan.to_string_lossy()).is_none(),
1007                "a withheld source must not be in the session cache"
1008            );
1009        }
1010        assert!(
1011            cache.get_full_content(&billing).is_none(),
1012            "a snippet does not seed the session cache"
1013        );
1014    }
1015
1016    /// A manifest the policy blocks contributes no crate name to matching.
1017    #[test]
1018    fn a_withheld_manifest_contributes_no_crate_prefix() {
1019        let _data = lean_ctx::core::data_dir::isolated_data_dir();
1020        let project = tempfile::tempdir().unwrap();
1021        let root = project.path().to_string_lossy().into_owned();
1022        std::fs::write(
1023            project.path().join("Cargo.toml"),
1024            "# CONFIDENTIAL\n[package]\nname = \"orbital-core\"\n",
1025        )
1026        .unwrap();
1027        let gp = GraphProvider::GraphIndex(lean_ctx::core::graph_index::ProjectIndex::new(&root));
1028        {
1029            let _community = TestPolicyOverride::set(None);
1030            let prefixes = file_path_to_module_prefixes("src/core/cache.rs", &root, &gp);
1031            assert!(
1032                prefixes.contains(&"orbital_core::core::cache".to_string()),
1033                "{prefixes:?}"
1034            );
1035        }
1036        let _policy = TestPolicyOverride::set(Some(policy(
1037            "[filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
1038        )));
1039        let prefixes = file_path_to_module_prefixes("src/core/cache.rs", &root, &gp);
1040        assert!(
1041            !prefixes
1042                .iter()
1043                .any(|prefix| prefix.contains("orbital_core")),
1044            "{prefixes:?}"
1045        );
1046        assert!(prefixes.contains(&"crate::core::cache".to_string()));
1047    }
1048}