Skip to main content

imp_core/tools/scan/
mod.rs

1//! Scan tool — extract code structure using tree-sitter AST parsing.
2//!
3//! Dispatches to language-specific parsers based on file extension.
4//! Produces rich output: visibility, signatures, fields, variants, trait impls.
5
6pub 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
26/// Node kinds that represent enclosing blocks we want to extract around a line or symbol.
27const BLOCK_KINDS: &[&str] = &[
28    // Rust
29    "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    // TypeScript / JavaScript
40    "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    // Python
51    "function_definition",
52    "class_definition",
53    "decorated_definition",
54    // Go
55    "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                // extract accepts positional targets like file:line, file:start-end, file#symbol
130                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
175// ── extraction dispatch ─────────────────────────────────────────────
176
177fn 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        // Skip binary files
187        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            // TODO: add more languages as tree-sitter grammars are added
213            _ => {}
214        }
215    }
216
217    result
218}
219
220// ── file collection ─────────────────────────────────────────────────
221
222fn 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
284// ── formatting ──────────────────────────────────────────────────────
285
286fn 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    // Group types and functions by source file
302    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            // Standalone functions only (not Type::method — those show under Types)
334            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    // "src/lib.rs:42" → "src/lib.rs"
447    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    // "Type::method" or "module.function" patterns
460    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        // Types extracted
887        assert!(result.types.contains_key("User"));
888        assert!(result.types.contains_key("Status"));
889        assert!(result.types.contains_key("Validate"));
890
891        // User has fields
892        let user = &result.types["User"];
893        assert_eq!(user.fields.len(), 2);
894        assert_eq!(user.visibility, Visibility::Public);
895
896        // Status has variants
897        let status = &result.types["Status"];
898        assert_eq!(status.variants, vec!["Active", "Inactive"]);
899
900        // Validate has methods
901        let validate = &result.types["Validate"];
902        assert!(validate.methods.contains(&"validate".to_string()));
903
904        // User implements Validate
905        assert!(user.implements.contains(&"Validate".to_string()));
906
907        // Functions extracted with signatures
908        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}