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!("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
179fn 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 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
233fn 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 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 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 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#[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 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 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}