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 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
181fn 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 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
235fn 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 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 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 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#[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 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 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}