1use 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 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
185fn 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 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
239fn 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 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
300const TOOL: &str = "ctx_graph";
302
303fn 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 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 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 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
536fn 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 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 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 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 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#[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 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 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#[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 #[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 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 #[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}