1pub mod go;
7pub mod python;
8pub mod rust;
9pub mod types;
10pub mod typescript;
11
12use std::collections::{BTreeMap, BTreeSet};
13use std::path::{Path, PathBuf};
14
15use async_trait::async_trait;
16use serde_json::json;
17
18use super::{truncate_head, truncate_line, Tool, ToolContext, ToolOutput, TruncationResult};
19use crate::error::{Error, Result};
20use types::*;
21
22const MAX_OUTPUT_LINES: usize = 2000;
23const MAX_OUTPUT_BYTES: usize = 50 * 1024;
24const MAX_LINE_CHARS: usize = 500;
25
26const BLOCK_KINDS: &[&str] = &[
28 "function_item",
30 "impl_item",
31 "struct_item",
32 "enum_item",
33 "trait_item",
34 "mod_item",
35 "const_item",
36 "static_item",
37 "type_item",
38 "macro_definition",
39 "function_declaration",
41 "method_definition",
42 "class_declaration",
43 "interface_declaration",
44 "type_alias_declaration",
45 "enum_declaration",
46 "export_statement",
47 "lexical_declaration",
48 "variable_declaration",
49 "arrow_function",
50 "function_definition",
52 "class_definition",
53 "decorated_definition",
54 "function_declaration",
56 "method_declaration",
57 "type_declaration",
58 "type_spec",
59];
60
61pub struct ScanTool;
62
63#[async_trait]
64impl Tool for ScanTool {
65 fn name(&self) -> &str {
66 "scan"
67 }
68
69 fn label(&self) -> &str {
70 "Scan Code Structure"
71 }
72
73 fn description(&self) -> &str {
74 "Analyze code structure with tree-sitter. Use before broad text search when you need symbols, definitions, file skeletons/outlines, or coherent code blocks; extract exact blocks with file:line, file:start-end, or file#symbol."
75 }
76
77 fn parameters(&self) -> serde_json::Value {
78 json!({
79 "type": "object",
80 "properties": {
81 "action": {
82 "type": "string",
83 "enum": ["extract", "build", "scan"],
84 "description": "Operation to perform: 'scan' outlines a directory as compact skeletons, 'build' outlines specific files as compact skeletons, and 'extract' returns exact code blocks from file:line, file:start-end, or file#symbol targets."
85 },
86 "files": {
87 "type": "array",
88 "items": { "type": "string" },
89 "description": "Files to analyze for action='build', or extraction targets for action='extract'. Extract target forms: file#symbol, file:start-end, file:line. Examples: src/lib.rs#Agent, src/lib.rs:40-80, src/lib.rs:42."
90 },
91 "directory": {
92 "type": "string",
93 "description": "Directory to structurally scan when action='scan'. Defaults to the current workspace."
94 },
95 "task": {
96 "type": "string",
97 "description": "Optional natural-language focus for the scan, e.g. 'find auth entrypoints' or 'summarize provider implementations'."
98 }
99 },
100 "required": ["action"]
101 })
102 }
103
104 fn is_readonly(&self) -> bool {
105 true
106 }
107
108 async fn execute(
109 &self,
110 _call_id: &str,
111 params: serde_json::Value,
112 ctx: ToolContext,
113 ) -> Result<ToolOutput> {
114 let action = match params["action"].as_str() {
115 Some(a) => a,
116 None => return Ok(ToolOutput::error("missing 'action' parameter")),
117 };
118
119 let mut files = match action {
120 "extract" => {
121 let files = match params["files"].as_array() {
122 Some(f) if !f.is_empty() => f,
123 _ => {
124 return Ok(ToolOutput::error(
125 "'files' array required for extract action",
126 ))
127 }
128 };
129 let targets: Vec<String> = files
131 .iter()
132 .filter_map(|v| v.as_str().map(|s| s.to_string()))
133 .collect();
134 return Ok(execute_extract(&targets, &ctx));
135 }
136 "build" => {
137 let files = match params["files"].as_array() {
138 Some(f) if !f.is_empty() => f,
139 _ => return Ok(ToolOutput::error("'files' array required for build action")),
140 };
141 let mut resolved = Vec::with_capacity(files.len());
142 for file in files {
143 match file.as_str() {
144 Some(f) => resolved.push(crate::tools::resolve_path(&ctx.cwd, f)),
145 None => return Ok(ToolOutput::error("'files' must contain strings")),
146 }
147 }
148 resolved
149 }
150 "scan" => {
151 let dir = params["directory"]
152 .as_str()
153 .map(|d| crate::tools::resolve_path(&ctx.cwd, d))
154 .unwrap_or_else(|| ctx.cwd.clone());
155 collect_source_files(&dir)?
156 }
157 _ => return Ok(ToolOutput::error(format!("unknown action: {action}"))),
158 };
159
160 files.sort();
161 files.dedup();
162
163 if files.is_empty() {
164 return Ok(ToolOutput::text("No supported source files found."));
165 }
166
167 let result = extract_files(&files, &ctx.cwd);
168 let task = params["task"].as_str();
169 let output = format_result(&result, &files, &ctx.cwd, action, task);
170
171 Ok(ToolOutput::text(truncate_output(output)))
172 }
173}
174
175fn extract_files(files: &[PathBuf], cwd: &Path) -> ScanResult {
178 let mut result = ScanResult::default();
179
180 for file in files {
181 let source = match std::fs::read_to_string(file) {
182 Ok(s) => s,
183 Err(_) => continue,
184 };
185
186 if source.as_bytes().contains(&0) {
188 continue;
189 }
190
191 let rel = file
192 .strip_prefix(cwd)
193 .unwrap_or(file)
194 .to_string_lossy()
195 .to_string();
196
197 let ext = file
198 .extension()
199 .and_then(|e| e.to_str())
200 .unwrap_or_default();
201
202 match ext {
203 "rs" => rust::parse(&source, &rel, &mut result),
204 "ts" => {
205 if !rel.ends_with(".d.ts") {
206 typescript::parse(&source, &rel, false, &mut result);
207 }
208 }
209 "tsx" => typescript::parse(&source, &rel, true, &mut result),
210 "py" => python::parse(&source, &rel, &mut result),
211 "go" => go::parse(&source, &rel, &mut result),
212 _ => {}
214 }
215 }
216
217 result
218}
219
220fn collect_source_files(root: &Path) -> Result<Vec<PathBuf>> {
223 if root.is_file() {
224 return Ok(if is_supported(root) {
225 vec![root.to_path_buf()]
226 } else {
227 Vec::new()
228 });
229 }
230
231 if !root.exists() {
232 return Err(Error::Tool(format!(
233 "scan path not found: {}",
234 root.display()
235 )));
236 }
237
238 let mut files = Vec::new();
239 for entry in walkdir::WalkDir::new(root)
240 .follow_links(false)
241 .into_iter()
242 .filter_map(|e| e.ok())
243 .filter(|e| e.file_type().is_file())
244 .filter(|e| !is_skip_dir(e.path()))
245 {
246 if is_supported(entry.path()) {
247 files.push(entry.path().to_path_buf());
248 }
249 }
250
251 Ok(files)
252}
253
254fn is_supported(path: &Path) -> bool {
255 matches!(
256 path.extension().and_then(|e| e.to_str()),
257 Some("rs" | "ts" | "tsx" | "py" | "go")
258 )
259}
260
261fn is_skip_dir(path: &Path) -> bool {
262 const SKIP: &[&str] = &[
263 "target",
264 "node_modules",
265 ".git",
266 "__pycache__",
267 ".venv",
268 "venv",
269 "vendor",
270 "dist",
271 "build",
272 ".next",
273 "coverage",
274 ];
275 path.components().any(|c| {
276 if let std::path::Component::Normal(name) = c {
277 SKIP.contains(&name.to_string_lossy().as_ref())
278 } else {
279 false
280 }
281 })
282}
283
284fn format_result(
287 result: &ScanResult,
288 files: &[PathBuf],
289 cwd: &Path,
290 action: &str,
291 task: Option<&str>,
292) -> String {
293 let mut sections = Vec::new();
294 sections.push(format!("Action: {action}"));
295 if let Some(task) = task {
296 sections.push(format!("Task: {task}"));
297 }
298 sections.push(format!("Files analyzed: {}", files.len()));
299 sections.push("Output: compact code skeleton with symbol kind and line ranges. Use `scan extract` targets like file#symbol, file:start-end, or file:line for exact code.".to_string());
300
301 let mut file_types: BTreeMap<&str, Vec<&TypeInfo>> = BTreeMap::new();
303 let mut file_functions: BTreeMap<&str, Vec<&FunctionInfo>> = BTreeMap::new();
304
305 for t in result.types.values() {
306 let file = source_file(&t.source);
307 file_types.entry(file).or_default().push(t);
308 }
309
310 for f in result.functions.values() {
311 let file = source_file(&f.source);
312 file_functions.entry(file).or_default().push(f);
313 }
314
315 let all_files: BTreeSet<&str> = file_types
316 .keys()
317 .chain(file_functions.keys())
318 .copied()
319 .collect();
320
321 for file in &all_files {
322 let rel = display_path(file, cwd);
323 let mut lines = vec![rel];
324
325 if let Some(types) = file_types.get(file) {
326 lines.push(format!(" Types ({}):", types.len()));
327 for t in types {
328 lines.push(format!(" - {}", format_type(t)));
329 }
330 }
331
332 if let Some(funcs) = file_functions.get(file) {
333 let standalone: Vec<_> = funcs
335 .iter()
336 .filter(|f| !f.name.contains("::") && !is_qualified_name(&f.name))
337 .filter(|f| !f.is_test)
338 .collect();
339 if !standalone.is_empty() {
340 lines.push(format!(" Functions ({}):", standalone.len()));
341 for f in standalone {
342 lines.push(format!(" - {}", format_function(f)));
343 }
344 }
345 }
346
347 if lines.len() > 1 {
348 sections.push(lines.join("\n"));
349 }
350 }
351
352 sections.join("\n\n")
353}
354
355fn format_type(t: &TypeInfo) -> String {
356 let vis = format_visibility(&t.visibility);
357 let kind = match t.kind {
358 TypeKind::Struct => "struct",
359 TypeKind::Enum => "enum",
360 TypeKind::Trait => "trait",
361 TypeKind::Interface => "interface",
362 TypeKind::Class => "class",
363 TypeKind::TypeAlias => "type",
364 TypeKind::Union => "union",
365 TypeKind::Protocol => "protocol",
366 };
367
368 let mut out = format!("{vis}{kind} {}", t.name);
369
370 match t.kind {
371 TypeKind::Struct | TypeKind::Class => {
372 if !t.fields.is_empty() {
373 let names: Vec<&str> = t.fields.iter().map(|f| f.name.as_str()).collect();
374 if names.len() <= 6 {
375 out.push_str(&format!(" {{ {} }}", names.join(", ")));
376 } else {
377 let shown = &names[..5];
378 out.push_str(&format!(
379 " {{ {}, ... +{} }}",
380 shown.join(", "),
381 names.len() - 5
382 ));
383 }
384 }
385 }
386 TypeKind::Enum => {
387 if !t.variants.is_empty() {
388 if t.variants.len() <= 6 {
389 out.push_str(&format!(" {{ {} }}", t.variants.join(", ")));
390 } else {
391 let shown: Vec<&str> = t.variants[..5].iter().map(|s| s.as_str()).collect();
392 out.push_str(&format!(
393 " {{ {}, ... +{} }}",
394 shown.join(", "),
395 t.variants.len() - 5
396 ));
397 }
398 }
399 }
400 TypeKind::Trait | TypeKind::Interface | TypeKind::Protocol => {
401 if !t.methods.is_empty() {
402 if t.methods.len() <= 6 {
403 out.push_str(&format!(" {{ {} }}", t.methods.join(", ")));
404 } else {
405 let shown: Vec<&str> = t.methods[..5].iter().map(|s| s.as_str()).collect();
406 out.push_str(&format!(
407 " {{ {}, ... +{} }}",
408 shown.join(", "),
409 t.methods.len() - 5
410 ));
411 }
412 }
413 }
414 _ => {}
415 }
416
417 if !t.implements.is_empty() {
418 out.push_str(&format!(" [{}]", t.implements.join(", ")));
419 }
420
421 out.push_str(&format!(" @ {}", t.source));
422
423 out
424}
425
426fn format_function(f: &FunctionInfo) -> String {
427 let vis = format_visibility(&f.visibility);
428 let mut out = if !f.signature.is_empty() {
429 format!("{vis}{}", f.signature)
430 } else {
431 format!("{vis}fn {}", f.name)
432 };
433 out.push_str(&format!(" @ {}", f.source));
434 out
435}
436
437fn format_visibility(vis: &Visibility) -> &'static str {
438 match vis {
439 Visibility::Public => "pub ",
440 Visibility::Internal => "pub(crate) ",
441 Visibility::Private => "",
442 }
443}
444
445fn source_file(source: &str) -> &str {
446 source.rsplit_once(':').map(|(f, _)| f).unwrap_or(source)
448}
449
450fn display_path(path: &str, cwd: &Path) -> String {
451 let cwd_str = cwd.to_string_lossy();
452 path.strip_prefix(cwd_str.as_ref())
453 .map(|p| p.strip_prefix('/').unwrap_or(p))
454 .unwrap_or(path)
455 .to_string()
456}
457
458fn is_qualified_name(name: &str) -> bool {
459 name.contains("::")
461}
462
463fn truncate_output(text: String) -> String {
464 if text.is_empty() {
465 return text;
466 }
467
468 let truncated_lines = text
469 .lines()
470 .map(|line| truncate_line(line, MAX_LINE_CHARS))
471 .collect::<Vec<_>>()
472 .join("\n");
473
474 let TruncationResult {
475 content,
476 truncated,
477 output_lines,
478 total_lines,
479 temp_file,
480 ..
481 } = truncate_head(&truncated_lines, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES);
482
483 if !truncated {
484 return content;
485 }
486
487 let mut result = content;
488 result.push_str(&format!(
489 "\n[Output truncated: showing first {output_lines} of {total_lines} lines{}]",
490 temp_file
491 .as_ref()
492 .map(|p| format!(". Full output saved to {}", p.display()))
493 .unwrap_or_default()
494 ));
495 result
496}
497
498struct CodeBlock {
499 file: PathBuf,
500 start_line: usize,
501 end_line: usize,
502 kind: Option<String>,
503 symbol: Option<String>,
504 language: Option<String>,
505 truncated: bool,
506 code: String,
507}
508
509enum Locator {
510 Line(usize),
511 Range(usize, usize),
512 Symbol(String),
513}
514
515fn execute_extract(targets: &[String], ctx: &ToolContext) -> ToolOutput {
516 let mut blocks = Vec::new();
517
518 for target in targets {
519 let Some((file, locator)) = parse_extract_target(target) else {
520 continue;
521 };
522
523 let path = crate::tools::resolve_path(&ctx.cwd, &file);
524 let Some(content) = read_text_file(&path) else {
525 blocks.push(CodeBlock {
526 file: PathBuf::from(&file),
527 start_line: 0,
528 end_line: 0,
529 kind: None,
530 symbol: None,
531 language: language_for_path(Path::new(&file)).map(str::to_string),
532 truncated: false,
533 code: format!("Error: could not read {file}"),
534 });
535 continue;
536 };
537
538 let rel_path = path.strip_prefix(&ctx.cwd).unwrap_or(&path).to_path_buf();
539
540 match locator {
541 Locator::Line(line) => {
542 let line_idx = line.saturating_sub(1);
543 if let Some(extracted) = extract_blocks_at_lines(&content, &path, &[line_idx]) {
544 for mut block in extracted {
545 block.file = rel_path.clone();
546 blocks.push(block);
547 }
548 } else {
549 let lines: Vec<&str> = content.lines().collect();
550 let start = line_idx.saturating_sub(5);
551 let end = (line_idx + 6).min(lines.len());
552 blocks.push(CodeBlock {
553 file: rel_path.clone(),
554 start_line: start + 1,
555 end_line: end,
556 kind: None,
557 symbol: None,
558 language: language_for_path(&path).map(str::to_string),
559 truncated: false,
560 code: lines[start..end].join("\n"),
561 });
562 }
563 }
564 Locator::Range(start, end) => {
565 let lines: Vec<&str> = content.lines().collect();
566 let s = start.saturating_sub(1).min(lines.len());
567 let e = end.min(lines.len());
568 blocks.push(CodeBlock {
569 file: rel_path.clone(),
570 start_line: s + 1,
571 end_line: e,
572 kind: None,
573 symbol: None,
574 language: language_for_path(&path).map(str::to_string),
575 truncated: false,
576 code: lines[s..e].join("\n"),
577 });
578 }
579 Locator::Symbol(name) => {
580 if let Some(found) = extract_symbol(&content, &path, &name) {
581 blocks.push(CodeBlock {
582 file: rel_path.clone(),
583 ..found
584 });
585 } else {
586 blocks.push(CodeBlock {
587 file: rel_path.clone(),
588 start_line: 0,
589 end_line: 0,
590 kind: None,
591 symbol: Some(name.clone()),
592 language: language_for_path(&path).map(str::to_string),
593 truncated: false,
594 code: format!("Symbol '{name}' not found in {file}"),
595 });
596 }
597 }
598 }
599 }
600
601 if blocks.is_empty() {
602 return ToolOutput::text("No code blocks found.");
603 }
604
605 ToolOutput::text(truncate_output(format_blocks(&blocks)))
606}
607
608fn parse_extract_target(target: &str) -> Option<(String, Locator)> {
609 if let Some(hash_pos) = target.rfind('#') {
610 let file = target[..hash_pos].to_string();
611 let symbol = target[hash_pos + 1..].to_string();
612 if !file.is_empty() && !symbol.is_empty() {
613 return Some((file, Locator::Symbol(symbol)));
614 }
615 }
616
617 if let Some(colon_pos) = target.rfind(':') {
618 let file = target[..colon_pos].to_string();
619 let suffix = &target[colon_pos + 1..];
620 if !file.is_empty() && !suffix.is_empty() {
621 if let Some(dash_pos) = suffix.find('-') {
622 let start = suffix[..dash_pos].parse::<usize>().ok()?;
623 let end = suffix[dash_pos + 1..].parse::<usize>().ok()?;
624 return Some((file, Locator::Range(start, end)));
625 } else if let Ok(line) = suffix.parse::<usize>() {
626 return Some((file, Locator::Line(line)));
627 }
628 }
629 }
630
631 None
632}
633
634fn read_text_file(path: &Path) -> Option<String> {
635 let bytes = std::fs::read(path).ok()?;
636 if bytes.contains(&0) {
637 return None;
638 }
639 Some(String::from_utf8_lossy(&bytes).into_owned())
640}
641
642fn get_parser(path: &Path) -> Option<tree_sitter::Parser> {
643 let ext = path.extension()?.to_str()?;
644 let language = match ext {
645 "rs" => tree_sitter_rust::LANGUAGE.into(),
646 "ts" | "tsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
647 "js" | "jsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
648 "py" => tree_sitter_python::LANGUAGE.into(),
649 "go" => tree_sitter_go::LANGUAGE.into(),
650 _ => return None,
651 };
652 let mut parser = tree_sitter::Parser::new();
653 parser.set_language(&language).ok()?;
654 Some(parser)
655}
656
657fn extract_blocks_at_lines(
658 source: &str,
659 path: &Path,
660 match_lines: &[usize],
661) -> Option<Vec<CodeBlock>> {
662 let mut parser = get_parser(path)?;
663 let tree = parser.parse(source, None)?;
664 let root = tree.root_node();
665 let lines: Vec<&str> = source.lines().collect();
666
667 let mut blocks = Vec::new();
668 let mut seen_ranges = std::collections::HashSet::new();
669
670 for &line_idx in match_lines {
671 if let Some(node) = find_enclosing_block(root, line_idx) {
672 let start = node.start_position().row;
673 let end = node.end_position().row;
674 let range = (start, end);
675 if seen_ranges.insert(range) {
676 let s = start.min(lines.len());
677 let e = (end + 1).min(lines.len());
678 blocks.push(CodeBlock {
679 file: PathBuf::new(),
680 start_line: start + 1,
681 end_line: end + 1,
682 kind: Some(node.kind().to_string()),
683 symbol: None,
684 language: language_for_path(path).map(str::to_string),
685 truncated: false,
686 code: lines[s..e].join("\n"),
687 });
688 }
689 }
690 }
691
692 Some(blocks)
693}
694
695fn find_enclosing_block(root: tree_sitter::Node, target_line: usize) -> Option<tree_sitter::Node> {
696 let mut best: Option<tree_sitter::Node> = None;
697 find_enclosing_block_recursive(root, target_line, &mut best);
698 best
699}
700
701fn find_enclosing_block_recursive<'a>(
702 node: tree_sitter::Node<'a>,
703 target_line: usize,
704 best: &mut Option<tree_sitter::Node<'a>>,
705) {
706 let start = node.start_position().row;
707 let end = node.end_position().row;
708
709 if target_line < start || target_line > end {
710 return;
711 }
712
713 if BLOCK_KINDS.contains(&node.kind()) {
714 *best = Some(node);
715 }
716
717 let mut cursor = node.walk();
718 let children: Vec<_> = node.children(&mut cursor).collect();
719 for child in children {
720 find_enclosing_block_recursive(child, target_line, best);
721 }
722}
723
724fn extract_symbol(source: &str, path: &Path, name: &str) -> Option<CodeBlock> {
725 let mut parser = get_parser(path)?;
726 let tree = parser.parse(source, None)?;
727 let root = tree.root_node();
728 let lines: Vec<&str> = source.lines().collect();
729
730 let node = find_symbol_node(root, source, name)?;
731 let start = node.start_position().row;
732 let end = node.end_position().row;
733 let s = start.min(lines.len());
734 let e = (end + 1).min(lines.len());
735
736 Some(CodeBlock {
737 file: PathBuf::new(),
738 start_line: start + 1,
739 end_line: end + 1,
740 kind: Some(node.kind().to_string()),
741 symbol: Some(name.to_string()),
742 language: language_for_path(path).map(str::to_string),
743 truncated: false,
744 code: lines[s..e].join("\n"),
745 })
746}
747
748fn find_symbol_node<'a>(
749 node: tree_sitter::Node<'a>,
750 source: &str,
751 name: &str,
752) -> Option<tree_sitter::Node<'a>> {
753 if BLOCK_KINDS.contains(&node.kind()) && node_has_name(node, source, name) {
754 return Some(node);
755 }
756
757 let mut cursor = node.walk();
758 let children: Vec<_> = node.children(&mut cursor).collect();
759 for child in children {
760 if let Some(found) = find_symbol_node(child, source, name) {
761 return Some(found);
762 }
763 }
764
765 None
766}
767
768fn node_has_name(node: tree_sitter::Node, source: &str, name: &str) -> bool {
769 let mut cursor = node.walk();
770 let children: Vec<_> = node.children(&mut cursor).collect();
771 for child in children {
772 let kind = child.kind();
773 if kind == "identifier"
774 || kind == "type_identifier"
775 || kind == "name"
776 || kind == "property_identifier"
777 {
778 let text = &source[child.byte_range()];
779 if text == name {
780 return true;
781 }
782 }
783 if BLOCK_KINDS.contains(&kind) {
784 continue;
785 }
786 let mut inner_cursor = child.walk();
787 let inner_children: Vec<_> = child.children(&mut inner_cursor).collect();
788 for inner in inner_children {
789 let ik = inner.kind();
790 if ik == "identifier" || ik == "type_identifier" || ik == "name" {
791 let text = &source[inner.byte_range()];
792 if text == name {
793 return true;
794 }
795 }
796 }
797 }
798 false
799}
800
801fn language_for_path(path: &Path) -> Option<&'static str> {
802 match path.extension().and_then(|e| e.to_str())? {
803 "rs" => Some("rust"),
804 "ts" | "tsx" => Some("typescript"),
805 "js" | "jsx" => Some("javascript"),
806 "py" => Some("python"),
807 "go" => Some("go"),
808 _ => None,
809 }
810}
811
812fn format_blocks(blocks: &[CodeBlock]) -> String {
813 let mut sections = Vec::with_capacity(blocks.len());
814
815 for block in blocks {
816 let mut header = format!(
817 "{}:{}-{}",
818 block.file.display(),
819 block.start_line,
820 block.end_line
821 );
822 if let Some(kind) = &block.kind {
823 header.push_str(&format!(" ({kind})"));
824 }
825 let details = json!({
826 "path": block.file.to_string_lossy(),
827 "symbol": block.symbol,
828 "kind": block.kind,
829 "language": block.language,
830 "start_line": block.start_line,
831 "end_line": block.end_line,
832 "truncated": block.truncated,
833 });
834
835 let fence = match block.file.extension().and_then(|e| e.to_str()) {
836 Some("rs") => "rust",
837 Some("ts") | Some("tsx") => "typescript",
838 Some("js") | Some("jsx") => "javascript",
839 Some("py") => "python",
840 Some("go") => "go",
841 _ => "text",
842 };
843 sections.push(format!(
844 "{header}\nDetails: {details}\n```{fence}\n{}\n```",
845 block.code
846 ));
847 }
848
849 sections.join("\n\n")
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855
856 #[test]
857 fn extract_rust_file() {
858 let tmp = tempfile::tempdir().unwrap();
859 let file = tmp.path().join("sample.rs");
860 std::fs::write(
861 &file,
862 r#"
863pub struct User {
864 pub name: String,
865 pub age: u32,
866}
867
868pub enum Status { Active, Inactive }
869
870pub trait Validate {
871 fn validate(&self) -> bool;
872}
873
874impl Validate for User {
875 fn validate(&self) -> bool { true }
876}
877
878pub async fn load_user(id: &str) -> Result<User> { todo!() }
879fn internal_helper() {}
880"#,
881 )
882 .unwrap();
883
884 let result = extract_files(&[file], tmp.path());
885
886 assert!(result.types.contains_key("User"));
888 assert!(result.types.contains_key("Status"));
889 assert!(result.types.contains_key("Validate"));
890
891 let user = &result.types["User"];
893 assert_eq!(user.fields.len(), 2);
894 assert_eq!(user.visibility, Visibility::Public);
895
896 let status = &result.types["Status"];
898 assert_eq!(status.variants, vec!["Active", "Inactive"]);
899
900 let validate = &result.types["Validate"];
902 assert!(validate.methods.contains(&"validate".to_string()));
903
904 assert!(user.implements.contains(&"Validate".to_string()));
906
907 let load = &result.functions["load_user"];
909 assert!(load.is_async);
910 assert!(load.signature.contains("-> Result<User>"));
911 assert_eq!(load.visibility, Visibility::Public);
912
913 let helper = &result.functions["internal_helper"];
914 assert_eq!(helper.visibility, Visibility::Private);
915 }
916
917 #[test]
918 fn extract_typescript_file() {
919 let tmp = tempfile::tempdir().unwrap();
920 let file = tmp.path().join("models.ts");
921 std::fs::write(
922 &file,
923 r#"
924export interface User {
925 name: string;
926 email: string;
927}
928
929export enum Status {
930 Active = "active",
931 Inactive = "inactive",
932}
933
934export async function fetchUser(id: string): Promise<User> {
935 return {} as User;
936}
937
938function internalHelper(): void {}
939"#,
940 )
941 .unwrap();
942
943 let result = extract_files(&[file], tmp.path());
944 assert!(result.types.contains_key("User"));
945 assert!(result.types.contains_key("Status"));
946 assert_eq!(result.types["User"].visibility, Visibility::Public);
947 assert_eq!(result.types["Status"].variants, vec!["Active", "Inactive"]);
948 assert!(result.functions["fetchUser"].is_async);
949 assert_eq!(
950 result.functions["internalHelper"].visibility,
951 Visibility::Private
952 );
953 }
954
955 #[test]
956 fn format_output_shows_rich_info() {
957 let tmp = tempfile::tempdir().unwrap();
958 let file = tmp.path().join("lib.rs");
959 std::fs::write(
960 &file,
961 r#"
962pub struct Config { pub host: String, pub port: u16 }
963pub enum Mode { Debug, Release }
964pub fn start(config: &Config) -> Result<()> { todo!() }
965"#,
966 )
967 .unwrap();
968
969 let result = extract_files(std::slice::from_ref(&file), tmp.path());
970 let output = format_result(&result, &[file], tmp.path(), "extract", None);
971
972 assert!(output.contains("pub struct Config { host, port }"));
973 assert!(output.contains("pub enum Mode { Debug, Release }"));
974 assert!(output.contains("pub fn start"));
975 assert!(output.contains("-> Result<()>"));
976 }
977
978 #[test]
979 fn skeleton_output_includes_line_ranges_and_target_hint() {
980 let tmp = tempfile::tempdir().unwrap();
981 let file = tmp.path().join("lib.rs");
982 std::fs::write(
983 &file,
984 r#"
985pub struct Config { pub host: String, pub port: u16 }
986pub fn start(config: &Config) -> Result<()> { todo!() }
987"#,
988 )
989 .unwrap();
990
991 let result = extract_files(std::slice::from_ref(&file), tmp.path());
992 let output = format_result(&result, &[file], tmp.path(), "build", None);
993
994 assert!(output.contains("compact code skeleton"));
995 assert!(output.contains("file#symbol"));
996 assert!(output.contains("pub struct Config"));
997 assert!(output.contains(" @ lib.rs:2"));
998 assert!(output.contains("pub fn start"));
999 assert!(output.contains(" @ lib.rs:3"));
1000 }
1001
1002 #[test]
1003 fn symbol_extract_includes_structured_details() {
1004 let tmp = tempfile::tempdir().unwrap();
1005 let file = tmp.path().join("lib.rs");
1006 std::fs::write(
1007 &file,
1008 r#"
1009pub struct Config {
1010 pub host: String,
1011}
1012
1013pub fn start(config: &Config) -> Result<()> { todo!() }
1014"#,
1015 )
1016 .unwrap();
1017
1018 let found =
1019 extract_symbol(&std::fs::read_to_string(&file).unwrap(), &file, "Config").unwrap();
1020 let output = format_blocks(&[CodeBlock {
1021 file: PathBuf::from("lib.rs"),
1022 ..found
1023 }]);
1024
1025 assert!(output.contains("Details:"));
1026 assert!(output.contains("\"symbol\":\"Config\""));
1027 assert!(output.contains("\"language\":\"rust\""));
1028 assert!(output.contains("\"start_line\":2"));
1029 assert!(output.contains("pub struct Config"));
1030 }
1031
1032 #[test]
1033 fn typescript_skeleton_output_includes_line_ranges() {
1034 let tmp = tempfile::tempdir().unwrap();
1035 let file = tmp.path().join("models.ts");
1036 std::fs::write(
1037 &file,
1038 r#"
1039export interface User { name: string; }
1040export async function fetchUser(id: string): Promise<User> { return {} as User; }
1041"#,
1042 )
1043 .unwrap();
1044
1045 let result = extract_files(std::slice::from_ref(&file), tmp.path());
1046 let output = format_result(&result, &[file], tmp.path(), "build", None);
1047
1048 assert!(output.contains("pub interface User @ models.ts:2"));
1049 assert!(output.contains("pub async function fetchUser"));
1050 assert!(output.contains(" @ models.ts:3"));
1051 }
1052}