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 kotlin;
8pub mod python;
9pub mod rust;
10pub mod types;
11pub mod typescript;
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::{Path, PathBuf};
15
16use async_trait::async_trait;
17use serde_json::json;
18
19use super::{truncate_head, truncate_line, Tool, ToolContext, ToolOutput, TruncationResult};
20use crate::error::{Error, Result};
21use types::*;
22
23const MAX_OUTPUT_LINES: usize = 2000;
24const MAX_OUTPUT_BYTES: usize = 50 * 1024;
25const MAX_LINE_CHARS: usize = 500;
26
27/// Node kinds that represent enclosing blocks we want to extract around a line or symbol.
28const BLOCK_KINDS: &[&str] = &[
29    // Rust
30    "function_item",
31    "impl_item",
32    "struct_item",
33    "enum_item",
34    "trait_item",
35    "mod_item",
36    "const_item",
37    "static_item",
38    "type_item",
39    "macro_definition",
40    // TypeScript / JavaScript
41    "function_declaration",
42    "method_definition",
43    "class_declaration",
44    "interface_declaration",
45    "type_alias_declaration",
46    "enum_declaration",
47    "export_statement",
48    "lexical_declaration",
49    "variable_declaration",
50    "arrow_function",
51    // Python
52    "function_definition",
53    "class_definition",
54    "decorated_definition",
55    // Kotlin
56    "class_declaration",
57    "object_declaration",
58    "function_declaration",
59    "property_declaration",
60    // Go
61    "function_declaration",
62    "method_declaration",
63    "type_declaration",
64    "type_spec",
65];
66
67pub struct ScanTool;
68
69#[async_trait]
70impl Tool for ScanTool {
71    fn name(&self) -> &str {
72        "scan"
73    }
74
75    fn label(&self) -> &str {
76        "Scan Code Structure"
77    }
78
79    fn description(&self) -> &str {
80        "Code structure search/extraction with tree-sitter."
81    }
82
83    fn parameters(&self) -> serde_json::Value {
84        json!({
85            "type": "object",
86            "properties": {
87                "action": {
88                    "type": "string",
89                    "enum": ["directory", "files", "extract", "search", "tests", "related", "references", "impact"],
90                    "description": "Scan operation"
91                },
92                "directory": {
93                    "type": "string",
94                    "description": "Directory; defaults to cwd"
95                },
96                "files": {
97                    "type": "array",
98                    "items": { "type": "string" },
99                    "description": "Files for action=files"
100                },
101                "targets": {
102                    "type": "array",
103                    "items": { "type": "string" },
104                    "description": "Targets: file#symbol, file:start-end, or file:line"
105                },
106                "query": {
107                    "type": "string",
108                    "description": "Search query"
109                },
110                "mode": {
111                    "type": "string",
112                    "enum": ["symbol", "text", "concept"],
113                    "description": "Search mode; default concept"
114                },
115                "preset": {
116                    "type": "string",
117                    "enum": ["definition", "edit_context", "module_context", "test_context"],
118                    "description": "Extraction preset"
119                },
120                "target": {
121                    "type": "string",
122                    "description": "Single target"
123                },
124                "max_results": {
125                    "type": "integer",
126                    "description": "Max results"
127                }
128            },
129            "required": ["action"]
130        })
131    }
132
133    fn is_readonly(&self) -> bool {
134        true
135    }
136
137    async fn execute(
138        &self,
139        _call_id: &str,
140        params: serde_json::Value,
141        ctx: ToolContext,
142    ) -> Result<ToolOutput> {
143        let action = match params["action"].as_str() {
144            Some(a) => a,
145            None => return Ok(ToolOutput::error("missing 'action' parameter")),
146        };
147
148        let mut files = match action {
149            "extract" => {
150                let target_values = params
151                    .get("targets")
152                    .or_else(|| params.get("files"))
153                    .and_then(|value| value.as_array());
154                let targets = match parse_string_array(target_values, "targets") {
155                    Ok(targets) if !targets.is_empty() => targets,
156                    Ok(_) => return Ok(ToolOutput::error("scan extract requires targets")),
157                    Err(message) => return Ok(ToolOutput::error(message)),
158                };
159                let preset = params["preset"].as_str();
160                return Ok(execute_extract_with_preset(&targets, preset, &ctx));
161            }
162            "search" => {
163                let query = match params["query"]
164                    .as_str()
165                    .map(str::trim)
166                    .filter(|q| !q.is_empty())
167                {
168                    Some(query) => query,
169                    None => return Ok(ToolOutput::error("scan search requires query")),
170                };
171                let mode = params["mode"].as_str().unwrap_or("concept");
172                let max_results = params["max_results"].as_u64().unwrap_or(10) as usize;
173                let files = files_from_params_or_directory(&params, &ctx)?;
174                return Ok(execute_search(
175                    files,
176                    &ctx.cwd,
177                    query,
178                    mode,
179                    max_results.max(1),
180                ));
181            }
182            "tests" => {
183                let targets =
184                    parse_string_array(params["targets"].as_array(), "targets").unwrap_or_default();
185                let target = params["target"]
186                    .as_str()
187                    .map(str::to_string)
188                    .or_else(|| targets.first().cloned())
189                    .or_else(|| params["query"].as_str().map(str::to_string));
190                let Some(target) = target else {
191                    return Ok(ToolOutput::error(
192                        "scan tests requires target, targets, or query",
193                    ));
194                };
195                let max_results = params["max_results"].as_u64().unwrap_or(10) as usize;
196                let files = files_from_params_or_directory(&params, &ctx)?;
197                return Ok(execute_tests(files, &ctx.cwd, &target, max_results.max(1)));
198            }
199            "related" => {
200                let targets =
201                    parse_string_array(params["targets"].as_array(), "targets").unwrap_or_default();
202                let target = params["target"]
203                    .as_str()
204                    .map(str::to_string)
205                    .or_else(|| targets.first().cloned());
206                let Some(target) = target else {
207                    return Ok(ToolOutput::error("scan related requires target or targets"));
208                };
209                let files = files_from_params_or_directory(&params, &ctx)?;
210                return Ok(execute_related(files, &ctx.cwd, &target));
211            }
212            "references" | "impact" => {
213                let target = params["target"]
214                    .as_str()
215                    .map(str::to_string)
216                    .or_else(|| params["query"].as_str().map(str::to_string));
217                let Some(target) = target else {
218                    return Ok(ToolOutput::error(format!(
219                        "scan {action} requires target or query"
220                    )));
221                };
222                let max_results = params["max_results"].as_u64().unwrap_or(25) as usize;
223                let files = files_from_params_or_directory(&params, &ctx)?;
224                if action == "references" {
225                    return Ok(execute_references(
226                        files,
227                        &ctx.cwd,
228                        &target,
229                        max_results.max(1),
230                    ));
231                }
232                return Ok(execute_impact(files, &ctx.cwd, &target, max_results.max(1)));
233            }
234            "files" | "build" => {
235                let files = match parse_string_array(params["files"].as_array(), "files") {
236                    Ok(files) if !files.is_empty() => files,
237                    Ok(_) => return Ok(ToolOutput::error("scan files requires files")),
238                    Err(message) => return Ok(ToolOutput::error(message)),
239                };
240                files
241                    .into_iter()
242                    .map(|file| crate::tools::resolve_path(&ctx.cwd, &file))
243                    .collect()
244            }
245            "directory" | "scan" => {
246                let dir = params["directory"]
247                    .as_str()
248                    .map(|d| crate::tools::resolve_path(&ctx.cwd, d))
249                    .unwrap_or_else(|| ctx.cwd.clone());
250                collect_source_files(&dir)?
251            }
252            _ => return Ok(ToolOutput::error(format!("unknown action: {action}"))),
253        };
254
255        files.sort();
256        files.dedup();
257
258        if files.is_empty() {
259            return Ok(ToolOutput::text("No supported source files found."));
260        }
261
262        let result = extract_files(&files, &ctx.cwd);
263        let action_name = canonical_action(action);
264        let output = format_result(&result, &files, &ctx.cwd, action_name, None);
265
266        Ok(ToolOutput {
267            content: vec![imp_llm::ContentBlock::Text {
268                text: truncate_output(output),
269            }],
270            details: json!({
271                "action": action_name,
272                "files_analyzed": files.len(),
273                "supported_languages": ["rust", "typescript", "javascript", "python", "go", "kotlin"],
274                "types_count": result.types.len(),
275                "functions_count": result.functions.len(),
276            }),
277            is_error: false,
278        })
279    }
280}
281
282// ── parameter helpers ───────────────────────────────────────────────
283
284fn canonical_action(action: &str) -> &str {
285    match action {
286        "scan" => "directory",
287        "build" => "files",
288        other => other,
289    }
290}
291
292fn parse_string_array(
293    values: Option<&Vec<serde_json::Value>>,
294    field: &str,
295) -> std::result::Result<Vec<String>, String> {
296    let Some(values) = values else {
297        return Ok(Vec::new());
298    };
299    let mut strings = Vec::with_capacity(values.len());
300    for (index, value) in values.iter().enumerate() {
301        let Some(text) = value
302            .as_str()
303            .map(str::trim)
304            .filter(|text| !text.is_empty())
305        else {
306            return Err(format!("{field}[{index}] must be a non-empty string"));
307        };
308        strings.push(text.to_string());
309    }
310    Ok(strings)
311}
312
313fn files_from_params_or_directory(
314    params: &serde_json::Value,
315    ctx: &ToolContext,
316) -> Result<Vec<PathBuf>> {
317    let explicit_files =
318        parse_string_array(params["files"].as_array(), "files").map_err(Error::Tool)?;
319    if !explicit_files.is_empty() {
320        return Ok(explicit_files
321            .into_iter()
322            .map(|file| crate::tools::resolve_path(&ctx.cwd, &file))
323            .collect());
324    }
325
326    let dir = params["directory"]
327        .as_str()
328        .map(|d| crate::tools::resolve_path(&ctx.cwd, d))
329        .unwrap_or_else(|| ctx.cwd.clone());
330    collect_source_files(&dir)
331}
332
333// ── extraction dispatch ─────────────────────────────────────────────
334
335fn extract_files(files: &[PathBuf], cwd: &Path) -> ScanResult {
336    let mut result = ScanResult::default();
337
338    for file in files {
339        let source = match std::fs::read_to_string(file) {
340            Ok(s) => s,
341            Err(_) => continue,
342        };
343
344        // Skip binary files
345        if source.as_bytes().contains(&0) {
346            continue;
347        }
348
349        let rel = file
350            .strip_prefix(cwd)
351            .unwrap_or(file)
352            .to_string_lossy()
353            .to_string();
354
355        let ext = file
356            .extension()
357            .and_then(|e| e.to_str())
358            .unwrap_or_default();
359
360        match ext {
361            "rs" => rust::parse(&source, &rel, &mut result),
362            "ts" => {
363                if !rel.ends_with(".d.ts") {
364                    typescript::parse(&source, &rel, false, &mut result);
365                }
366            }
367            "tsx" => typescript::parse(&source, &rel, true, &mut result),
368            "py" => python::parse(&source, &rel, &mut result),
369            "go" => go::parse(&source, &rel, &mut result),
370            "kt" | "kts" => kotlin::parse(&source, &rel, &mut result),
371            "js" | "jsx" => typescript::parse(&source, &rel, ext == "jsx", &mut result),
372            // TODO: add more languages as tree-sitter grammars are added
373            _ => {}
374        }
375    }
376
377    result
378}
379
380// ── file collection ─────────────────────────────────────────────────
381
382fn collect_source_files(root: &Path) -> Result<Vec<PathBuf>> {
383    if root.is_file() {
384        return Ok(if is_supported(root) {
385            vec![root.to_path_buf()]
386        } else {
387            Vec::new()
388        });
389    }
390
391    if !root.exists() {
392        return Err(Error::Tool(format!(
393            "scan path not found: {}",
394            root.display()
395        )));
396    }
397
398    let mut files = Vec::new();
399    for entry in walkdir::WalkDir::new(root)
400        .follow_links(false)
401        .into_iter()
402        .filter_map(|e| e.ok())
403        .filter(|e| e.file_type().is_file())
404        .filter(|e| !is_skip_dir(e.path()))
405    {
406        if is_supported(entry.path()) {
407            files.push(entry.path().to_path_buf());
408        }
409    }
410
411    Ok(files)
412}
413
414fn is_supported(path: &Path) -> bool {
415    matches!(
416        path.extension().and_then(|e| e.to_str()),
417        Some("rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "kt" | "kts")
418    )
419}
420
421fn is_skip_dir(path: &Path) -> bool {
422    const SKIP: &[&str] = &[
423        "target",
424        "node_modules",
425        ".git",
426        "__pycache__",
427        ".venv",
428        "venv",
429        "vendor",
430        "dist",
431        "build",
432        ".next",
433        "coverage",
434    ];
435    path.components().any(|c| {
436        if let std::path::Component::Normal(name) = c {
437            SKIP.contains(&name.to_string_lossy().as_ref())
438        } else {
439            false
440        }
441    })
442}
443
444// ── search and test discovery ───────────────────────────────────────
445
446#[derive(Debug, Clone)]
447struct IndexedSymbol {
448    file: String,
449    name: String,
450    kind: String,
451    line: usize,
452    text: String,
453    is_test: bool,
454}
455
456#[derive(Debug, Clone)]
457struct SearchHit {
458    file: String,
459    symbol: Option<String>,
460    kind: String,
461    line: usize,
462    score: i32,
463    why: Vec<String>,
464}
465
466fn execute_search(
467    mut files: Vec<PathBuf>,
468    cwd: &Path,
469    query: &str,
470    mode: &str,
471    max_results: usize,
472) -> ToolOutput {
473    files.sort();
474    files.dedup();
475    let index = build_symbol_index(&files, cwd);
476    let hits = search_index(&index, query, mode, max_results);
477    let mut lines = vec![
478        format!("Action: search"),
479        format!("Query: {query}"),
480        format!("Mode: {mode}"),
481        format!("Files analyzed: {}", files.len()),
482    ];
483    if hits.is_empty() {
484        lines.push("No matching symbols found.".to_string());
485    } else {
486        lines.push("Results:".to_string());
487        for hit in &hits {
488            let symbol = hit.symbol.as_deref().unwrap_or("<file>");
489            lines.push(format!(
490                "- {}:{} [{}] {} score={} — {}",
491                hit.file,
492                hit.line,
493                hit.kind,
494                symbol,
495                hit.score,
496                hit.why.join(", ")
497            ));
498        }
499    }
500
501    ToolOutput {
502        content: vec![imp_llm::ContentBlock::Text {
503            text: truncate_output(lines.join("\n")),
504        }],
505        details: json!({
506            "action": "search",
507            "query": query,
508            "mode": mode,
509            "files_analyzed": files.len(),
510            "results": hits.iter().map(|hit| json!({
511                "file": hit.file,
512                "symbol": hit.symbol,
513                "kind": hit.kind,
514                "line": hit.line,
515                "score": hit.score,
516                "why": hit.why,
517            })).collect::<Vec<_>>(),
518        }),
519        is_error: false,
520    }
521}
522
523fn execute_tests(
524    mut files: Vec<PathBuf>,
525    cwd: &Path,
526    target: &str,
527    max_results: usize,
528) -> ToolOutput {
529    files.sort();
530    files.dedup();
531    let index = build_symbol_index(&files, cwd);
532    let tests = discover_tests(&index, target, cwd, max_results);
533    let mut lines = vec![
534        format!("Action: tests"),
535        format!("Target: {target}"),
536        format!("Files analyzed: {}", files.len()),
537    ];
538    if tests.is_empty() {
539        lines.push("No likely tests found.".to_string());
540    } else {
541        lines.push("Likely tests:".to_string());
542        for test in &tests {
543            lines.push(format!(
544                "- {}:{} {} — {}",
545                test.file,
546                test.line,
547                test.name,
548                test.command.as_deref().unwrap_or("no command inferred")
549            ));
550        }
551    }
552
553    ToolOutput {
554        content: vec![imp_llm::ContentBlock::Text {
555            text: truncate_output(lines.join("\n")),
556        }],
557        details: json!({
558            "action": "tests",
559            "target": target,
560            "files_analyzed": files.len(),
561            "tests": tests.iter().map(|test| json!({
562                "file": test.file,
563                "symbol": test.name,
564                "line": test.line,
565                "command": test.command,
566                "why": test.why,
567            })).collect::<Vec<_>>(),
568        }),
569        is_error: false,
570    }
571}
572
573fn build_symbol_index(files: &[PathBuf], cwd: &Path) -> Vec<IndexedSymbol> {
574    let result = extract_files(files, cwd);
575    let mut symbols = Vec::new();
576    for t in result.types.values() {
577        symbols.push(IndexedSymbol {
578            file: source_file(&t.source).to_string(),
579            name: t.name.clone(),
580            kind: format!("{:?}", t.kind).to_lowercase(),
581            line: source_line(&t.source),
582            text: format!(
583                "{} {:?} {:?} {:?}",
584                t.name, t.fields, t.variants, t.implements
585            ),
586            is_test: false,
587        });
588    }
589    for f in result.functions.values() {
590        symbols.push(IndexedSymbol {
591            file: source_file(&f.source).to_string(),
592            name: f.name.clone(),
593            kind: "function".to_string(),
594            line: source_line(&f.source),
595            text: f.signature.clone(),
596            is_test: f.is_test,
597        });
598    }
599    symbols
600}
601
602fn search_index(
603    index: &[IndexedSymbol],
604    query: &str,
605    mode: &str,
606    max_results: usize,
607) -> Vec<SearchHit> {
608    let terms = query_terms(query);
609    if terms.is_empty() {
610        return Vec::new();
611    }
612    let mut hits = Vec::new();
613    for symbol in index {
614        let mut score = 0;
615        let mut why = Vec::new();
616        let path = symbol.file.to_lowercase();
617        let name = symbol.name.to_lowercase();
618        let text = symbol.text.to_lowercase();
619        for term in &terms {
620            if name == *term {
621                score += 100;
622                why.push(format!("symbol exactly matches {term}"));
623            } else if name.contains(term) {
624                score += 60;
625                why.push(format!("symbol contains {term}"));
626            }
627            if mode != "symbol" {
628                if path.contains(term) {
629                    score += 25;
630                    why.push(format!("path contains {term}"));
631                }
632                if text.contains(term) {
633                    score += if mode == "concept" { 20 } else { 15 };
634                    why.push(format!("signature/metadata contains {term}"));
635                }
636            }
637        }
638        if score > 0 {
639            hits.push(SearchHit {
640                file: symbol.file.clone(),
641                symbol: Some(symbol.name.clone()),
642                kind: symbol.kind.clone(),
643                line: symbol.line,
644                score,
645                why,
646            });
647        }
648    }
649    hits.sort_by(|a, b| {
650        b.score
651            .cmp(&a.score)
652            .then_with(|| a.file.cmp(&b.file))
653            .then_with(|| a.line.cmp(&b.line))
654    });
655    hits.truncate(max_results);
656    hits
657}
658
659#[derive(Debug, Clone)]
660struct TestHit {
661    file: String,
662    name: String,
663    line: usize,
664    command: Option<String>,
665    why: String,
666}
667
668fn discover_tests(
669    index: &[IndexedSymbol],
670    target: &str,
671    cwd: &Path,
672    max_results: usize,
673) -> Vec<TestHit> {
674    let (target_file, target_symbol) = split_target(target);
675    let target_terms = query_terms(target_symbol.as_deref().unwrap_or(target));
676    let cargo = cwd.join("Cargo.toml").exists();
677    let mut hits = Vec::new();
678    for symbol in index
679        .iter()
680        .filter(|symbol| symbol.is_test || looks_like_test_file(&symbol.file))
681    {
682        let mut score = 0;
683        if let Some(file) = &target_file {
684            if symbol.file == *file {
685                score += 50;
686            } else if same_stem_or_test_neighbor(&symbol.file, file) {
687                score += 35;
688            }
689        }
690        for term in &target_terms {
691            if symbol.name.to_lowercase().contains(term) {
692                score += 20;
693            }
694        }
695        if score > 0 || target_file.is_none() {
696            let test_name = symbol.name.rsplit("::").next().unwrap_or(&symbol.name);
697            hits.push((
698                score,
699                TestHit {
700                    file: symbol.file.clone(),
701                    name: symbol.name.clone(),
702                    line: symbol.line,
703                    command: if cargo && symbol.file.ends_with(".rs") {
704                        Some(format!("cargo test {test_name}"))
705                    } else {
706                        None
707                    },
708                    why: if symbol.is_test {
709                        "indexed test symbol"
710                    } else {
711                        "test file naming"
712                    }
713                    .to_string(),
714                },
715            ));
716        }
717    }
718    hits.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.file.cmp(&b.1.file)));
719    hits.into_iter()
720        .take(max_results)
721        .map(|(_, hit)| hit)
722        .collect()
723}
724
725fn query_terms(query: &str) -> Vec<String> {
726    query
727        .split(|c: char| !c.is_alphanumeric() && c != '_')
728        .map(str::trim)
729        .filter(|term| !term.is_empty())
730        .map(|term| term.to_lowercase())
731        .collect()
732}
733
734fn source_line(source: &str) -> usize {
735    source
736        .rsplit_once(':')
737        .and_then(|(_, line)| line.parse().ok())
738        .unwrap_or(1)
739}
740
741fn split_target(target: &str) -> (Option<String>, Option<String>) {
742    if let Some((file, symbol)) = target.split_once('#') {
743        return (Some(file.to_string()), Some(symbol.to_string()));
744    }
745    if let Some((file, _line)) = target.rsplit_once(':') {
746        return (Some(file.to_string()), None);
747    }
748    if target.contains('/') || target.contains('\\') {
749        return (Some(target.to_string()), None);
750    }
751    (None, Some(target.to_string()))
752}
753
754fn looks_like_test_file(file: &str) -> bool {
755    let name = Path::new(file)
756        .file_name()
757        .and_then(|name| name.to_str())
758        .unwrap_or(file);
759    name.contains("test")
760        || name.ends_with(".spec.ts")
761        || name.ends_with(".spec.tsx")
762        || name.ends_with("_test.go")
763        || name.ends_with("_test.exs")
764}
765
766fn same_stem_or_test_neighbor(test_file: &str, target_file: &str) -> bool {
767    let test_stem = Path::new(test_file)
768        .file_stem()
769        .and_then(|s| s.to_str())
770        .unwrap_or("")
771        .replace("_test", "")
772        .replace(".test", "")
773        .replace(".spec", "");
774    let target_stem = Path::new(target_file)
775        .file_stem()
776        .and_then(|s| s.to_str())
777        .unwrap_or("");
778    !target_stem.is_empty() && test_stem.contains(target_stem)
779}
780
781fn related_symbols<'a>(
782    index: &'a [IndexedSymbol],
783    target_file: Option<&str>,
784    target_symbol: Option<&str>,
785    max_results: usize,
786) -> Vec<&'a IndexedSymbol> {
787    let target_terms = target_symbol.map(query_terms).unwrap_or_default();
788    let mut scored = Vec::new();
789    for symbol in index {
790        if target_symbol == Some(symbol.name.as_str()) {
791            continue;
792        }
793        let mut score = 0;
794        if target_file == Some(symbol.file.as_str()) {
795            score += 50;
796        }
797        for term in &target_terms {
798            if symbol.name.to_lowercase().contains(term)
799                || symbol.text.to_lowercase().contains(term)
800            {
801                score += 15;
802            }
803        }
804        if score > 0 {
805            scored.push((score, symbol));
806        }
807    }
808    scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.line.cmp(&b.1.line)));
809    scored
810        .into_iter()
811        .take(max_results)
812        .map(|(_, symbol)| symbol)
813        .collect()
814}
815
816fn collect_target_files(targets: &[String], cwd: &Path) -> Vec<PathBuf> {
817    let mut files = Vec::new();
818    for target in targets {
819        if let Some((file, _locator)) = parse_extract_target(target) {
820            files.push(crate::tools::resolve_path(cwd, &file));
821        }
822    }
823    files.sort();
824    files.dedup();
825    files
826}
827
828fn execute_related(mut files: Vec<PathBuf>, cwd: &Path, target: &str) -> ToolOutput {
829    files.sort();
830    files.dedup();
831    let index = build_symbol_index(&files, cwd);
832    let (target_file, target_symbol) = split_target(target);
833    let related = related_symbols(&index, target_file.as_deref(), target_symbol.as_deref(), 12);
834    let tests = discover_tests(&index, target, cwd, 5);
835    let definition = target_symbol.as_ref().and_then(|name| {
836        index.iter().find(|symbol| {
837            symbol.name == *name && target_file.as_ref().is_none_or(|file| symbol.file == *file)
838        })
839    });
840
841    let mut lines = vec![
842        format!("Action: related"),
843        format!("Target: {target}"),
844        format!("Files analyzed: {}", files.len()),
845    ];
846    if let Some(symbol) = definition {
847        lines.push(format!(
848            "Definition: {}:{} [{}] {} (extract: {}#{})",
849            symbol.file, symbol.line, symbol.kind, symbol.name, symbol.file, symbol.name
850        ));
851    }
852    if !related.is_empty() {
853        lines.push("Related symbols:".to_string());
854        for symbol in &related {
855            lines.push(format!(
856                "- {}:{} [{}] {} (extract: {}#{})",
857                symbol.file, symbol.line, symbol.kind, symbol.name, symbol.file, symbol.name
858            ));
859        }
860    }
861    if !tests.is_empty() {
862        lines.push("Likely tests:".to_string());
863        for test in &tests {
864            lines.push(format!(
865                "- {}:{} {} — {}",
866                test.file,
867                test.line,
868                test.name,
869                test.command.as_deref().unwrap_or("no command inferred")
870            ));
871        }
872    }
873    if definition.is_none() && related.is_empty() && tests.is_empty() {
874        lines.push("No related context found.".to_string());
875    }
876
877    ToolOutput {
878        content: vec![imp_llm::ContentBlock::Text {
879            text: truncate_output(lines.join("\n")),
880        }],
881        details: json!({
882            "action": "related",
883            "target": target,
884            "files_analyzed": files.len(),
885            "definition": definition.map(|symbol| json!({
886                "file": symbol.file,
887                "symbol": symbol.name,
888                "kind": symbol.kind,
889                "line": symbol.line,
890                "extract_target": format!("{}#{}", symbol.file, symbol.name),
891            })),
892            "related": related.iter().map(|symbol| json!({
893                "file": symbol.file,
894                "symbol": symbol.name,
895                "kind": symbol.kind,
896                "line": symbol.line,
897                "extract_target": format!("{}#{}", symbol.file, symbol.name),
898            })).collect::<Vec<_>>(),
899            "tests": tests.iter().map(|test| json!({
900                "file": test.file,
901                "symbol": test.name,
902                "line": test.line,
903                "command": test.command,
904                "why": test.why,
905            })).collect::<Vec<_>>(),
906        }),
907        is_error: false,
908    }
909}
910
911#[derive(Debug, Clone)]
912struct ReferenceHit {
913    file: String,
914    line: usize,
915    kind: String,
916    snippet: String,
917    confidence: &'static str,
918    why: String,
919}
920
921fn execute_references(
922    mut files: Vec<PathBuf>,
923    cwd: &Path,
924    target: &str,
925    max_results: usize,
926) -> ToolOutput {
927    files.sort();
928    files.dedup();
929    let index = build_symbol_index(&files, cwd);
930    let hits = find_references(&files, cwd, &index, target, max_results);
931    let mut lines = vec![
932        "Action: references".to_string(),
933        format!("Target: {target}"),
934        "Accuracy: lexical/structural reference search, not a complete LSP call graph.".to_string(),
935        format!("Files analyzed: {}", files.len()),
936    ];
937    if hits.is_empty() {
938        lines.push("No references found.".to_string());
939    } else {
940        lines.push("References:".to_string());
941        for hit in &hits {
942            lines.push(format!(
943                "- {}:{} [{} {}] {} — {}",
944                hit.file, hit.line, hit.kind, hit.confidence, hit.snippet, hit.why
945            ));
946        }
947    }
948
949    ToolOutput {
950        content: vec![imp_llm::ContentBlock::Text {
951            text: truncate_output(lines.join("\n")),
952        }],
953        details: json!({
954            "action": "references",
955            "target": target,
956            "accuracy": "lexical_structural_not_lsp_complete",
957            "files_analyzed": files.len(),
958            "references": hits.iter().map(|hit| json!({
959                "file": hit.file,
960                "line": hit.line,
961                "kind": hit.kind,
962                "snippet": hit.snippet,
963                "confidence": hit.confidence,
964                "why": hit.why,
965            })).collect::<Vec<_>>(),
966        }),
967        is_error: false,
968    }
969}
970
971fn execute_impact(
972    mut files: Vec<PathBuf>,
973    cwd: &Path,
974    target: &str,
975    max_results: usize,
976) -> ToolOutput {
977    files.sort();
978    files.dedup();
979    let index = build_symbol_index(&files, cwd);
980    let references = find_references(&files, cwd, &index, target, max_results);
981    let tests = discover_tests(&index, target, cwd, 10);
982    let (_target_file, target_symbol) = split_target(target);
983    let public_status = target_symbol.as_ref().and_then(|name| {
984        index
985            .iter()
986            .find(|symbol| symbol.name == *name)
987            .map(|symbol| {
988                if symbol.kind == "function" {
989                    "unknown"
990                } else {
991                    "indexed symbol"
992                }
993            })
994    });
995    let affected_files: BTreeSet<String> = references.iter().map(|hit| hit.file.clone()).collect();
996    let verify_commands: Vec<String> = tests
997        .iter()
998        .filter_map(|test| test.command.clone())
999        .collect();
1000    let mut lines = vec![
1001        "Action: impact".to_string(),
1002        format!("Target: {target}"),
1003        "Accuracy: lexical/structural impact analysis, not a complete LSP call graph.".to_string(),
1004        format!("Files analyzed: {}", files.len()),
1005        format!("References found: {}", references.len()),
1006    ];
1007    if !affected_files.is_empty() {
1008        lines.push("Likely affected files:".to_string());
1009        for file in &affected_files {
1010            lines.push(format!("- {file}"));
1011        }
1012    }
1013    if !tests.is_empty() {
1014        lines.push("Relevant tests:".to_string());
1015        for test in &tests {
1016            lines.push(format!(
1017                "- {}:{} {} — {}",
1018                test.file,
1019                test.line,
1020                test.name,
1021                test.command.as_deref().unwrap_or("no command inferred")
1022            ));
1023        }
1024    }
1025    if !verify_commands.is_empty() {
1026        lines.push("Suggested verification:".to_string());
1027        for command in &verify_commands {
1028            lines.push(format!("- {command}"));
1029        }
1030    }
1031
1032    ToolOutput {
1033        content: vec![imp_llm::ContentBlock::Text {
1034            text: truncate_output(lines.join("\n")),
1035        }],
1036        details: json!({
1037            "action": "impact",
1038            "target": target,
1039            "accuracy": "lexical_structural_not_lsp_complete",
1040            "files_analyzed": files.len(),
1041            "public_status": public_status,
1042            "affected_files": affected_files.into_iter().collect::<Vec<_>>(),
1043            "references": references.iter().map(|hit| json!({
1044                "file": hit.file,
1045                "line": hit.line,
1046                "kind": hit.kind,
1047                "snippet": hit.snippet,
1048                "confidence": hit.confidence,
1049                "why": hit.why,
1050            })).collect::<Vec<_>>(),
1051            "tests": tests.iter().map(|test| json!({
1052                "file": test.file,
1053                "symbol": test.name,
1054                "line": test.line,
1055                "command": test.command,
1056                "why": test.why,
1057            })).collect::<Vec<_>>(),
1058            "verify_commands": verify_commands,
1059        }),
1060        is_error: false,
1061    }
1062}
1063
1064fn find_references(
1065    files: &[PathBuf],
1066    cwd: &Path,
1067    index: &[IndexedSymbol],
1068    target: &str,
1069    max_results: usize,
1070) -> Vec<ReferenceHit> {
1071    let (_target_file, target_symbol) = split_target(target);
1072    let needle = target_symbol.unwrap_or_else(|| target.to_string());
1073    let needle = needle.trim();
1074    if needle.is_empty() {
1075        return Vec::new();
1076    }
1077    let pattern = format!(r"\b{}\b", regex::escape(needle));
1078    let Ok(symbol_regex) = regex::Regex::new(&pattern) else {
1079        return Vec::new();
1080    };
1081    let mut hits = Vec::new();
1082    for file in files {
1083        let Some(content) = read_text_file(file) else {
1084            continue;
1085        };
1086        let rel = file
1087            .strip_prefix(cwd)
1088            .unwrap_or(file)
1089            .to_string_lossy()
1090            .to_string();
1091        for (idx, line) in content.lines().enumerate() {
1092            if !symbol_regex.is_match(line) {
1093                continue;
1094            }
1095            let line_no = idx + 1;
1096            let (kind, confidence, why) = classify_reference(index, &rel, line_no, line, needle);
1097            hits.push(ReferenceHit {
1098                file: rel.clone(),
1099                line: line_no,
1100                kind,
1101                snippet: truncate_line(line.trim(), 180),
1102                confidence,
1103                why,
1104            });
1105        }
1106    }
1107    hits.sort_by(|a, b| {
1108        reference_kind_rank(&a.kind)
1109            .cmp(&reference_kind_rank(&b.kind))
1110            .then_with(|| a.file.cmp(&b.file))
1111            .then_with(|| a.line.cmp(&b.line))
1112    });
1113    hits.truncate(max_results);
1114    hits
1115}
1116
1117fn classify_reference(
1118    index: &[IndexedSymbol],
1119    file: &str,
1120    line_no: usize,
1121    line: &str,
1122    needle: &str,
1123) -> (String, &'static str, String) {
1124    if index
1125        .iter()
1126        .any(|symbol| symbol.file == file && symbol.line == line_no && symbol.name == needle)
1127    {
1128        return (
1129            "definition".to_string(),
1130            "high",
1131            "matches indexed symbol definition".to_string(),
1132        );
1133    }
1134    let trimmed = line.trim_start();
1135    if trimmed.starts_with("use ")
1136        || trimmed.starts_with("import ")
1137        || trimmed.starts_with("from ")
1138        || trimmed.contains("require(")
1139    {
1140        return (
1141            "import".to_string(),
1142            "medium",
1143            "line looks like import/use".to_string(),
1144        );
1145    }
1146    if looks_like_test_file(file)
1147        || index
1148            .iter()
1149            .any(|symbol| symbol.file == file && symbol.is_test && line_no >= symbol.line)
1150    {
1151        return (
1152            "test".to_string(),
1153            "medium",
1154            "reference appears in test context".to_string(),
1155        );
1156    }
1157    if line.contains(&format!("{needle}(")) || line.contains(&format!(".{needle}")) {
1158        return (
1159            "call".to_string(),
1160            "medium",
1161            "line looks like call/member access".to_string(),
1162        );
1163    }
1164    ("reference".to_string(), "low", "lexical match".to_string())
1165}
1166
1167fn reference_kind_rank(kind: &str) -> usize {
1168    match kind {
1169        "definition" => 0,
1170        "call" => 1,
1171        "reference" => 2,
1172        "import" => 3,
1173        "test" => 4,
1174        _ => 5,
1175    }
1176}
1177
1178// ── formatting ──────────────────────────────────────────────────────
1179
1180fn format_result(
1181    result: &ScanResult,
1182    files: &[PathBuf],
1183    cwd: &Path,
1184    action: &str,
1185    task: Option<&str>,
1186) -> String {
1187    let mut sections = Vec::new();
1188    sections.push(format!("Action: {action}"));
1189    if let Some(task) = task {
1190        sections.push(format!("Task: {task}"));
1191    }
1192    sections.push(format!("Files analyzed: {}", files.len()));
1193    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());
1194
1195    // Group types and functions by source file
1196    let mut file_types: BTreeMap<&str, Vec<&TypeInfo>> = BTreeMap::new();
1197    let mut file_functions: BTreeMap<&str, Vec<&FunctionInfo>> = BTreeMap::new();
1198
1199    for t in result.types.values() {
1200        let file = source_file(&t.source);
1201        file_types.entry(file).or_default().push(t);
1202    }
1203
1204    for f in result.functions.values() {
1205        let file = source_file(&f.source);
1206        file_functions.entry(file).or_default().push(f);
1207    }
1208
1209    let all_files: BTreeSet<&str> = file_types
1210        .keys()
1211        .chain(file_functions.keys())
1212        .copied()
1213        .collect();
1214
1215    for file in &all_files {
1216        let rel = display_path(file, cwd);
1217        let mut lines = vec![rel];
1218
1219        if let Some(types) = file_types.get(file) {
1220            lines.push(format!("  Types ({}):", types.len()));
1221            for t in types {
1222                lines.push(format!("    - {}", format_type(t)));
1223            }
1224        }
1225
1226        if let Some(funcs) = file_functions.get(file) {
1227            // Standalone functions only (not Type::method — those show under Types)
1228            let standalone: Vec<_> = funcs
1229                .iter()
1230                .filter(|f| !f.name.contains("::") && !is_qualified_name(&f.name))
1231                .filter(|f| !f.is_test)
1232                .collect();
1233            if !standalone.is_empty() {
1234                lines.push(format!("  Functions ({}):", standalone.len()));
1235                for f in standalone {
1236                    lines.push(format!("    - {}", format_function(f)));
1237                }
1238            }
1239        }
1240
1241        if lines.len() > 1 {
1242            sections.push(lines.join("\n"));
1243        }
1244    }
1245
1246    sections.join("\n\n")
1247}
1248
1249fn format_type(t: &TypeInfo) -> String {
1250    let vis = format_visibility(&t.visibility);
1251    let kind = match t.kind {
1252        TypeKind::Struct => "struct",
1253        TypeKind::Enum => "enum",
1254        TypeKind::Trait => "trait",
1255        TypeKind::Interface => "interface",
1256        TypeKind::Class => "class",
1257        TypeKind::TypeAlias => "type",
1258        TypeKind::Union => "union",
1259        TypeKind::Protocol => "protocol",
1260    };
1261
1262    let mut out = format!("{vis}{kind} {}", t.name);
1263
1264    match t.kind {
1265        TypeKind::Struct | TypeKind::Class => {
1266            if !t.fields.is_empty() {
1267                let names: Vec<&str> = t.fields.iter().map(|f| f.name.as_str()).collect();
1268                if names.len() <= 6 {
1269                    out.push_str(&format!(" {{ {} }}", names.join(", ")));
1270                } else {
1271                    let shown = &names[..5];
1272                    out.push_str(&format!(
1273                        " {{ {}, ... +{} }}",
1274                        shown.join(", "),
1275                        names.len() - 5
1276                    ));
1277                }
1278            }
1279        }
1280        TypeKind::Enum => {
1281            if !t.variants.is_empty() {
1282                if t.variants.len() <= 6 {
1283                    out.push_str(&format!(" {{ {} }}", t.variants.join(", ")));
1284                } else {
1285                    let shown: Vec<&str> = t.variants[..5].iter().map(|s| s.as_str()).collect();
1286                    out.push_str(&format!(
1287                        " {{ {}, ... +{} }}",
1288                        shown.join(", "),
1289                        t.variants.len() - 5
1290                    ));
1291                }
1292            }
1293        }
1294        TypeKind::Trait | TypeKind::Interface | TypeKind::Protocol => {
1295            if !t.methods.is_empty() {
1296                if t.methods.len() <= 6 {
1297                    out.push_str(&format!(" {{ {} }}", t.methods.join(", ")));
1298                } else {
1299                    let shown: Vec<&str> = t.methods[..5].iter().map(|s| s.as_str()).collect();
1300                    out.push_str(&format!(
1301                        " {{ {}, ... +{} }}",
1302                        shown.join(", "),
1303                        t.methods.len() - 5
1304                    ));
1305                }
1306            }
1307        }
1308        _ => {}
1309    }
1310
1311    if !t.implements.is_empty() {
1312        out.push_str(&format!(" [{}]", t.implements.join(", ")));
1313    }
1314
1315    out.push_str(&format!(" @ {}", t.source));
1316
1317    out
1318}
1319
1320fn format_function(f: &FunctionInfo) -> String {
1321    let vis = format_visibility(&f.visibility);
1322    let mut out = if !f.signature.is_empty() {
1323        format!("{vis}{}", f.signature)
1324    } else {
1325        format!("{vis}fn {}", f.name)
1326    };
1327    out.push_str(&format!(" @ {}", f.source));
1328    out
1329}
1330
1331fn format_visibility(vis: &Visibility) -> &'static str {
1332    match vis {
1333        Visibility::Public => "pub ",
1334        Visibility::Internal => "pub(crate) ",
1335        Visibility::Private => "",
1336    }
1337}
1338
1339fn source_file(source: &str) -> &str {
1340    // "src/lib.rs:42" → "src/lib.rs"
1341    source.rsplit_once(':').map(|(f, _)| f).unwrap_or(source)
1342}
1343
1344fn display_path(path: &str, cwd: &Path) -> String {
1345    let cwd_str = cwd.to_string_lossy();
1346    path.strip_prefix(cwd_str.as_ref())
1347        .map(|p| p.strip_prefix('/').unwrap_or(p))
1348        .unwrap_or(path)
1349        .to_string()
1350}
1351
1352fn is_qualified_name(name: &str) -> bool {
1353    // "Type::method" or "module.function" patterns
1354    name.contains("::")
1355}
1356
1357fn truncate_output(text: String) -> String {
1358    if text.is_empty() {
1359        return text;
1360    }
1361
1362    let truncated_lines = text
1363        .lines()
1364        .map(|line| truncate_line(line, MAX_LINE_CHARS))
1365        .collect::<Vec<_>>()
1366        .join("\n");
1367
1368    let TruncationResult {
1369        content,
1370        truncated,
1371        output_lines,
1372        total_lines,
1373        temp_file,
1374        ..
1375    } = truncate_head(&truncated_lines, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES);
1376
1377    if !truncated {
1378        return content;
1379    }
1380
1381    let mut result = content;
1382    result.push_str(&format!(
1383        "\n[Output truncated: showing first {output_lines} of {total_lines} lines{}]",
1384        temp_file
1385            .as_ref()
1386            .map(|p| format!(". Full output saved to {}", p.display()))
1387            .unwrap_or_default()
1388    ));
1389    result
1390}
1391
1392struct CodeBlock {
1393    file: PathBuf,
1394    start_line: usize,
1395    end_line: usize,
1396    kind: Option<String>,
1397    symbol: Option<String>,
1398    language: Option<String>,
1399    truncated: bool,
1400    code: String,
1401}
1402
1403enum Locator {
1404    Line(usize),
1405    Range(usize, usize),
1406    Symbol(String),
1407}
1408
1409fn execute_extract_with_preset(
1410    targets: &[String],
1411    preset: Option<&str>,
1412    ctx: &ToolContext,
1413) -> ToolOutput {
1414    let Some(preset) = preset else {
1415        return execute_extract(targets, ctx);
1416    };
1417    match preset {
1418        "definition" | "module_context" => execute_extract(targets, ctx),
1419        "edit_context" | "test_context" => execute_context_preset(targets, preset, ctx),
1420        other => ToolOutput::error(format!(
1421            "unknown extract preset: {other}. Use definition, edit_context, module_context, or test_context."
1422        )),
1423    }
1424}
1425
1426fn execute_context_preset(targets: &[String], preset: &str, ctx: &ToolContext) -> ToolOutput {
1427    let mut output = execute_extract(targets, ctx);
1428    if output.is_error {
1429        return output;
1430    }
1431    let files = collect_target_files(targets, &ctx.cwd);
1432    let index = build_symbol_index(&files, &ctx.cwd);
1433    let mut extras = Vec::new();
1434    for target in targets {
1435        let tests = discover_tests(&index, target, &ctx.cwd, 5);
1436        if preset == "test_context" && !tests.is_empty() {
1437            extras.push(format!("Related tests for {target}:"));
1438            for test in tests {
1439                extras.push(format!(
1440                    "- {}:{} {} — {}",
1441                    test.file,
1442                    test.line,
1443                    test.name,
1444                    test.command
1445                        .unwrap_or_else(|| "no command inferred".to_string())
1446                ));
1447            }
1448        } else if preset == "edit_context" {
1449            let (target_file, target_symbol) = split_target(target);
1450            let related =
1451                related_symbols(&index, target_file.as_deref(), target_symbol.as_deref(), 6);
1452            if !related.is_empty() {
1453                extras.push(format!("Related symbols for {target}:"));
1454                for symbol in related {
1455                    extras.push(format!(
1456                        "- {}:{} [{}] {} (extract: {}#{})",
1457                        symbol.file,
1458                        symbol.line,
1459                        symbol.kind,
1460                        symbol.name,
1461                        symbol.file,
1462                        symbol.name
1463                    ));
1464                }
1465            }
1466        }
1467    }
1468    if !extras.is_empty() {
1469        if let Some(text) = output.content.iter_mut().find_map(|block| match block {
1470            imp_llm::ContentBlock::Text { text } => Some(text),
1471            _ => None,
1472        }) {
1473            text.push_str("\n\n");
1474            text.push_str(&extras.join("\n"));
1475            *text = truncate_output(text.clone());
1476        }
1477        output.details["preset"] = json!(preset);
1478        output.details["context"] = json!(extras);
1479    }
1480    output
1481}
1482
1483fn execute_extract(targets: &[String], ctx: &ToolContext) -> ToolOutput {
1484    let mut blocks = Vec::new();
1485    let mut errors = Vec::new();
1486
1487    for target in targets {
1488        let Some((file, locator)) = parse_extract_target(target) else {
1489            errors.push(format!(
1490                "Invalid target `{target}`. Use file#symbol, file:start-end, or file:line."
1491            ));
1492            continue;
1493        };
1494
1495        let path = crate::tools::resolve_path(&ctx.cwd, &file);
1496        let Some(content) = read_text_file(&path) else {
1497            blocks.push(CodeBlock {
1498                file: PathBuf::from(&file),
1499                start_line: 0,
1500                end_line: 0,
1501                kind: None,
1502                symbol: None,
1503                language: language_for_path(Path::new(&file)).map(str::to_string),
1504                truncated: false,
1505                code: format!("Error: could not read {file}"),
1506            });
1507            continue;
1508        };
1509
1510        let rel_path = path.strip_prefix(&ctx.cwd).unwrap_or(&path).to_path_buf();
1511
1512        match locator {
1513            Locator::Line(line) => {
1514                let line_idx = line.saturating_sub(1);
1515                if let Some(extracted) = extract_blocks_at_lines(&content, &path, &[line_idx]) {
1516                    for mut block in extracted {
1517                        block.file = rel_path.clone();
1518                        blocks.push(block);
1519                    }
1520                } else {
1521                    let lines: Vec<&str> = content.lines().collect();
1522                    let start = line_idx.saturating_sub(5);
1523                    let end = (line_idx + 6).min(lines.len());
1524                    blocks.push(CodeBlock {
1525                        file: rel_path.clone(),
1526                        start_line: start + 1,
1527                        end_line: end,
1528                        kind: None,
1529                        symbol: None,
1530                        language: language_for_path(&path).map(str::to_string),
1531                        truncated: false,
1532                        code: lines[start..end].join("\n"),
1533                    });
1534                }
1535            }
1536            Locator::Range(start, end) => {
1537                let lines: Vec<&str> = content.lines().collect();
1538                let s = start.saturating_sub(1).min(lines.len());
1539                let e = end.min(lines.len());
1540                blocks.push(CodeBlock {
1541                    file: rel_path.clone(),
1542                    start_line: s + 1,
1543                    end_line: e,
1544                    kind: None,
1545                    symbol: None,
1546                    language: language_for_path(&path).map(str::to_string),
1547                    truncated: false,
1548                    code: lines[s..e].join("\n"),
1549                });
1550            }
1551            Locator::Symbol(name) => {
1552                if let Some(found) = extract_symbol(&content, &path, &name) {
1553                    blocks.push(CodeBlock {
1554                        file: rel_path.clone(),
1555                        ..found
1556                    });
1557                } else {
1558                    blocks.push(CodeBlock {
1559                        file: rel_path.clone(),
1560                        start_line: 0,
1561                        end_line: 0,
1562                        kind: None,
1563                        symbol: Some(name.clone()),
1564                        language: language_for_path(&path).map(str::to_string),
1565                        truncated: false,
1566                        code: format!("Symbol '{name}' not found in {file}"),
1567                    });
1568                }
1569            }
1570        }
1571    }
1572
1573    if blocks.is_empty() && errors.is_empty() {
1574        return ToolOutput::text("No code blocks found.");
1575    }
1576
1577    let mut output = String::new();
1578    if !blocks.is_empty() {
1579        output.push_str(&format_blocks(&blocks));
1580    }
1581    if !errors.is_empty() {
1582        if !output.is_empty() {
1583            output.push_str("\n\n");
1584        }
1585        output.push_str("Errors:\n");
1586        for error in &errors {
1587            output.push_str(&format!("- {error}\n"));
1588        }
1589    }
1590
1591    ToolOutput {
1592        content: vec![imp_llm::ContentBlock::Text {
1593            text: truncate_output(output),
1594        }],
1595        details: json!({
1596            "action": "extract",
1597            "targets_count": targets.len(),
1598            "blocks_count": blocks.len(),
1599            "errors": errors,
1600            "blocks": blocks.iter().map(block_details).collect::<Vec<_>>(),
1601        }),
1602        is_error: blocks.is_empty(),
1603    }
1604}
1605
1606fn parse_extract_target(target: &str) -> Option<(String, Locator)> {
1607    if let Some(hash_pos) = target.rfind('#') {
1608        let file = target[..hash_pos].to_string();
1609        let symbol = target[hash_pos + 1..].to_string();
1610        if !file.is_empty() && !symbol.is_empty() {
1611            return Some((file, Locator::Symbol(symbol)));
1612        }
1613    }
1614
1615    if let Some(colon_pos) = target.rfind(':') {
1616        let file = target[..colon_pos].to_string();
1617        let suffix = &target[colon_pos + 1..];
1618        if !file.is_empty() && !suffix.is_empty() {
1619            if let Some(dash_pos) = suffix.find('-') {
1620                let start = suffix[..dash_pos].parse::<usize>().ok()?;
1621                let end = suffix[dash_pos + 1..].parse::<usize>().ok()?;
1622                if start == 0 || end == 0 || start > end {
1623                    return None;
1624                }
1625                return Some((file, Locator::Range(start, end)));
1626            } else if let Ok(line) = suffix.parse::<usize>() {
1627                if line == 0 {
1628                    return None;
1629                }
1630                return Some((file, Locator::Line(line)));
1631            }
1632        }
1633    }
1634
1635    None
1636}
1637
1638fn block_details(block: &CodeBlock) -> serde_json::Value {
1639    json!({
1640        "path": block.file.to_string_lossy(),
1641        "symbol": block.symbol,
1642        "kind": block.kind,
1643        "language": block.language,
1644        "start_line": block.start_line,
1645        "end_line": block.end_line,
1646        "truncated": block.truncated,
1647    })
1648}
1649
1650fn read_text_file(path: &Path) -> Option<String> {
1651    let bytes = std::fs::read(path).ok()?;
1652    if bytes.contains(&0) {
1653        return None;
1654    }
1655    Some(String::from_utf8_lossy(&bytes).into_owned())
1656}
1657
1658fn get_parser(path: &Path) -> Option<tree_sitter::Parser> {
1659    let ext = path.extension()?.to_str()?;
1660    let language = match ext {
1661        "rs" => tree_sitter_rust::LANGUAGE.into(),
1662        "ts" | "tsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1663        "js" | "jsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1664        "py" => tree_sitter_python::LANGUAGE.into(),
1665        "go" => tree_sitter_go::LANGUAGE.into(),
1666        "kt" | "kts" => tree_sitter_kotlin_ng::LANGUAGE.into(),
1667        _ => return None,
1668    };
1669    let mut parser = tree_sitter::Parser::new();
1670    parser.set_language(&language).ok()?;
1671    Some(parser)
1672}
1673
1674fn extract_blocks_at_lines(
1675    source: &str,
1676    path: &Path,
1677    match_lines: &[usize],
1678) -> Option<Vec<CodeBlock>> {
1679    let mut parser = get_parser(path)?;
1680    let tree = parser.parse(source, None)?;
1681    let root = tree.root_node();
1682    let lines: Vec<&str> = source.lines().collect();
1683
1684    let mut blocks = Vec::new();
1685    let mut seen_ranges = std::collections::HashSet::new();
1686
1687    for &line_idx in match_lines {
1688        if let Some(node) = find_enclosing_block(root, line_idx) {
1689            let start = node.start_position().row;
1690            let end = node.end_position().row;
1691            let range = (start, end);
1692            if seen_ranges.insert(range) {
1693                let s = start.min(lines.len());
1694                let e = (end + 1).min(lines.len());
1695                blocks.push(CodeBlock {
1696                    file: PathBuf::new(),
1697                    start_line: start + 1,
1698                    end_line: end + 1,
1699                    kind: Some(node.kind().to_string()),
1700                    symbol: None,
1701                    language: language_for_path(path).map(str::to_string),
1702                    truncated: false,
1703                    code: lines[s..e].join("\n"),
1704                });
1705            }
1706        }
1707    }
1708
1709    Some(blocks)
1710}
1711
1712fn find_enclosing_block(root: tree_sitter::Node, target_line: usize) -> Option<tree_sitter::Node> {
1713    let mut best: Option<tree_sitter::Node> = None;
1714    find_enclosing_block_recursive(root, target_line, &mut best);
1715    best
1716}
1717
1718fn find_enclosing_block_recursive<'a>(
1719    node: tree_sitter::Node<'a>,
1720    target_line: usize,
1721    best: &mut Option<tree_sitter::Node<'a>>,
1722) {
1723    let start = node.start_position().row;
1724    let end = node.end_position().row;
1725
1726    if target_line < start || target_line > end {
1727        return;
1728    }
1729
1730    if BLOCK_KINDS.contains(&node.kind()) {
1731        *best = Some(node);
1732    }
1733
1734    let mut cursor = node.walk();
1735    let children: Vec<_> = node.children(&mut cursor).collect();
1736    for child in children {
1737        find_enclosing_block_recursive(child, target_line, best);
1738    }
1739}
1740
1741fn extract_symbol(source: &str, path: &Path, name: &str) -> Option<CodeBlock> {
1742    let mut parser = get_parser(path)?;
1743    let tree = parser.parse(source, None)?;
1744    let root = tree.root_node();
1745    let lines: Vec<&str> = source.lines().collect();
1746
1747    let node = find_symbol_node(root, source, name)?;
1748    let start = node.start_position().row;
1749    let end = node.end_position().row;
1750    let s = start.min(lines.len());
1751    let e = (end + 1).min(lines.len());
1752
1753    Some(CodeBlock {
1754        file: PathBuf::new(),
1755        start_line: start + 1,
1756        end_line: end + 1,
1757        kind: Some(node.kind().to_string()),
1758        symbol: Some(name.to_string()),
1759        language: language_for_path(path).map(str::to_string),
1760        truncated: false,
1761        code: lines[s..e].join("\n"),
1762    })
1763}
1764
1765fn find_symbol_node<'a>(
1766    node: tree_sitter::Node<'a>,
1767    source: &str,
1768    name: &str,
1769) -> Option<tree_sitter::Node<'a>> {
1770    if BLOCK_KINDS.contains(&node.kind()) && node_has_name(node, source, name) {
1771        return Some(node);
1772    }
1773
1774    let mut cursor = node.walk();
1775    let children: Vec<_> = node.children(&mut cursor).collect();
1776    for child in children {
1777        if let Some(found) = find_symbol_node(child, source, name) {
1778            return Some(found);
1779        }
1780    }
1781
1782    None
1783}
1784
1785fn node_has_name(node: tree_sitter::Node, source: &str, name: &str) -> bool {
1786    let mut cursor = node.walk();
1787    let children: Vec<_> = node.children(&mut cursor).collect();
1788    for child in children {
1789        let kind = child.kind();
1790        if kind == "identifier"
1791            || kind == "type_identifier"
1792            || kind == "name"
1793            || kind == "property_identifier"
1794            || kind == "simple_identifier"
1795            || kind == "variable_identifier"
1796        {
1797            let text = &source[child.byte_range()];
1798            if text == name {
1799                return true;
1800            }
1801        }
1802        if BLOCK_KINDS.contains(&kind) {
1803            continue;
1804        }
1805        let mut inner_cursor = child.walk();
1806        let inner_children: Vec<_> = child.children(&mut inner_cursor).collect();
1807        for inner in inner_children {
1808            let ik = inner.kind();
1809            if ik == "identifier"
1810                || ik == "type_identifier"
1811                || ik == "name"
1812                || ik == "simple_identifier"
1813                || ik == "variable_identifier"
1814            {
1815                let text = &source[inner.byte_range()];
1816                if text == name {
1817                    return true;
1818                }
1819            }
1820        }
1821    }
1822    false
1823}
1824
1825fn language_for_path(path: &Path) -> Option<&'static str> {
1826    match path.extension().and_then(|e| e.to_str())? {
1827        "rs" => Some("rust"),
1828        "ts" | "tsx" => Some("typescript"),
1829        "js" | "jsx" => Some("javascript"),
1830        "py" => Some("python"),
1831        "go" => Some("go"),
1832        "kt" | "kts" => Some("kotlin"),
1833        _ => None,
1834    }
1835}
1836
1837fn format_blocks(blocks: &[CodeBlock]) -> String {
1838    let mut sections = Vec::with_capacity(blocks.len());
1839
1840    for block in blocks {
1841        let mut header = format!(
1842            "{}:{}-{}",
1843            block.file.display(),
1844            block.start_line,
1845            block.end_line
1846        );
1847        if let Some(kind) = &block.kind {
1848            header.push_str(&format!(" ({kind})"));
1849        }
1850        let details = block_details(block);
1851
1852        let fence = match block.file.extension().and_then(|e| e.to_str()) {
1853            Some("rs") => "rust",
1854            Some("ts") | Some("tsx") => "typescript",
1855            Some("js") | Some("jsx") => "javascript",
1856            Some("py") => "python",
1857            Some("go") => "go",
1858            _ => "text",
1859        };
1860        sections.push(format!(
1861            "{header}\nDetails: {details}\n```{fence}\n{}\n```",
1862            block.code
1863        ));
1864    }
1865
1866    sections.join("\n\n")
1867}
1868
1869#[cfg(test)]
1870mod tests {
1871    use super::*;
1872
1873    #[test]
1874    fn schema_uses_directory_files_extract_and_targets() {
1875        let schema = ScanTool.parameters();
1876        let properties = schema["properties"].as_object().unwrap();
1877        let actions = properties["action"]["enum"].as_array().unwrap();
1878        assert!(actions.iter().any(|value| value == "directory"));
1879        assert!(actions.iter().any(|value| value == "files"));
1880        assert!(actions.iter().any(|value| value == "extract"));
1881        assert!(actions.iter().any(|value| value == "search"));
1882        assert!(actions.iter().any(|value| value == "tests"));
1883        assert!(actions.iter().any(|value| value == "related"));
1884        assert!(actions.iter().any(|value| value == "references"));
1885        assert!(actions.iter().any(|value| value == "impact"));
1886        assert!(properties.contains_key("targets"));
1887        assert!(properties.contains_key("query"));
1888        assert!(properties.contains_key("mode"));
1889        assert!(properties.contains_key("max_results"));
1890        assert!(properties.contains_key("preset"));
1891        assert!(properties.contains_key("target"));
1892        assert!(!properties.contains_key("task"));
1893    }
1894
1895    #[test]
1896    fn search_index_returns_ranked_symbol_hits() {
1897        let index = vec![
1898            IndexedSymbol {
1899                file: "src/auth/session.rs".to_string(),
1900                name: "resolve_auth_fallback".to_string(),
1901                kind: "function".to_string(),
1902                line: 12,
1903                text: "fn resolve_auth_fallback()".to_string(),
1904                is_test: false,
1905            },
1906            IndexedSymbol {
1907                file: "src/cache.rs".to_string(),
1908                name: "load_cache".to_string(),
1909                kind: "function".to_string(),
1910                line: 4,
1911                text: "fn load_cache()".to_string(),
1912                is_test: false,
1913            },
1914        ];
1915
1916        let hits = search_index(&index, "auth fallback", "concept", 5);
1917
1918        assert_eq!(hits.len(), 1);
1919        assert_eq!(hits[0].symbol.as_deref(), Some("resolve_auth_fallback"));
1920        assert!(hits[0]
1921            .why
1922            .iter()
1923            .any(|why| why.contains("symbol contains")));
1924    }
1925
1926    #[test]
1927    fn discover_tests_suggests_cargo_test_for_rust_tests() {
1928        let tmp = tempfile::tempdir().unwrap();
1929        std::fs::write(
1930            tmp.path().join("Cargo.toml"),
1931            "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
1932        )
1933        .unwrap();
1934        let index = vec![IndexedSymbol {
1935            file: "src/session.rs".to_string(),
1936            name: "falls_back_to_env_token".to_string(),
1937            kind: "function".to_string(),
1938            line: 42,
1939            text: "fn falls_back_to_env_token()".to_string(),
1940            is_test: true,
1941        }];
1942
1943        let tests = discover_tests(
1944            &index,
1945            "src/session.rs#resolve_auth_fallback",
1946            tmp.path(),
1947            5,
1948        );
1949
1950        assert_eq!(tests.len(), 1);
1951        assert_eq!(
1952            tests[0].command.as_deref(),
1953            Some("cargo test falls_back_to_env_token")
1954        );
1955    }
1956
1957    #[test]
1958    fn related_symbols_returns_same_file_context() {
1959        let index = vec![
1960            IndexedSymbol {
1961                file: "src/session.rs".to_string(),
1962                name: "resolve_auth_fallback".to_string(),
1963                kind: "function".to_string(),
1964                line: 10,
1965                text: "fn resolve_auth_fallback()".to_string(),
1966                is_test: false,
1967            },
1968            IndexedSymbol {
1969                file: "src/session.rs".to_string(),
1970                name: "SessionConfig".to_string(),
1971                kind: "struct".to_string(),
1972                line: 2,
1973                text: "SessionConfig".to_string(),
1974                is_test: false,
1975            },
1976        ];
1977
1978        let related = related_symbols(
1979            &index,
1980            Some("src/session.rs"),
1981            Some("resolve_auth_fallback"),
1982            5,
1983        );
1984
1985        assert_eq!(related.len(), 1);
1986        assert_eq!(related[0].name, "SessionConfig");
1987    }
1988
1989    #[test]
1990    fn collect_target_files_extracts_paths_from_targets() {
1991        let cwd = Path::new("/tmp/example");
1992        let files = collect_target_files(&["src/lib.rs#run".to_string()], cwd);
1993
1994        assert_eq!(files, vec![PathBuf::from("/tmp/example/src/lib.rs")]);
1995    }
1996
1997    #[test]
1998    fn references_classify_definitions_and_calls() {
1999        let tmp = tempfile::tempdir().unwrap();
2000        let file = tmp.path().join("lib.rs");
2001        std::fs::write(
2002            &file,
2003            "fn resolve_auth_fallback() {}\nfn caller() { resolve_auth_fallback(); }\n",
2004        )
2005        .unwrap();
2006        let files = vec![file];
2007        let index = build_symbol_index(&files, tmp.path());
2008
2009        let references = find_references(&files, tmp.path(), &index, "resolve_auth_fallback", 10);
2010
2011        assert!(references.iter().any(|hit| hit.kind == "definition"));
2012        assert!(references.iter().any(|hit| hit.kind == "call"));
2013    }
2014
2015    #[test]
2016    fn impact_uses_tests_for_verification_commands() {
2017        let tmp = tempfile::tempdir().unwrap();
2018        std::fs::write(
2019            tmp.path().join("Cargo.toml"),
2020            "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
2021        )
2022        .unwrap();
2023        let file = tmp.path().join("session.rs");
2024        std::fs::write(
2025            &file,
2026            "fn resolve_auth_fallback() {}\n#[test]\nfn resolve_auth_fallback_uses_env() { resolve_auth_fallback(); }\n",
2027        )
2028        .unwrap();
2029
2030        let output = execute_impact(
2031            vec![file],
2032            tmp.path(),
2033            "session.rs#resolve_auth_fallback",
2034            10,
2035        );
2036
2037        assert_eq!(output.details["action"], "impact");
2038        assert!(output.details["verify_commands"]
2039            .as_array()
2040            .unwrap()
2041            .iter()
2042            .any(|value| value.as_str() == Some("cargo test resolve_auth_fallback_uses_env")));
2043    }
2044
2045    #[test]
2046    fn parse_extract_target_rejects_invalid_lines() {
2047        assert!(parse_extract_target("src/lib.rs:0").is_none());
2048        assert!(parse_extract_target("src/lib.rs:10-2").is_none());
2049        assert!(parse_extract_target("src/lib.rs:1-2").is_some());
2050    }
2051
2052    #[test]
2053    fn execute_extract_reports_invalid_target_errors() {
2054        let tmp = tempfile::tempdir().unwrap();
2055        let (tx, _rx) = tokio::sync::mpsc::channel(1);
2056        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
2057        let ctx = ToolContext {
2058            cwd: tmp.path().to_path_buf(),
2059            cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
2060            update_tx: tx,
2061            command_tx: cmd_tx,
2062            ui: std::sync::Arc::new(crate::ui::NullInterface),
2063            file_cache: std::sync::Arc::new(crate::tools::FileCache::new()),
2064            checkpoint_state: std::sync::Arc::new(crate::tools::CheckpointState::new()),
2065            file_tracker: std::sync::Arc::new(std::sync::Mutex::new(
2066                crate::tools::FileTracker::new(),
2067            )),
2068            anchor_store: std::sync::Arc::new(crate::tools::AnchorStore::new()),
2069            lua_tool_loader: None,
2070            mode: crate::config::AgentMode::Full,
2071            read_max_lines: 500,
2072            turn_mana_review: std::sync::Arc::new(std::sync::Mutex::new(
2073                crate::mana_review::TurnManaReviewAccumulator::default(),
2074            )),
2075            run_policy: Default::default(),
2076            config: std::sync::Arc::new(crate::config::Config::default()),
2077            supporting_provenance: Vec::new(),
2078        };
2079
2080        let output = execute_extract(&["not-a-target".to_string()], &ctx);
2081
2082        assert!(output.is_error);
2083        assert_eq!(output.details["action"], "extract");
2084        assert_eq!(output.details["errors"].as_array().unwrap().len(), 1);
2085    }
2086
2087    #[test]
2088    fn extract_rust_file() {
2089        let tmp = tempfile::tempdir().unwrap();
2090        let file = tmp.path().join("sample.rs");
2091        std::fs::write(
2092            &file,
2093            r#"
2094pub struct User {
2095    pub name: String,
2096    pub age: u32,
2097}
2098
2099pub enum Status { Active, Inactive }
2100
2101pub trait Validate {
2102    fn validate(&self) -> bool;
2103}
2104
2105impl Validate for User {
2106    fn validate(&self) -> bool { true }
2107}
2108
2109pub async fn load_user(id: &str) -> Result<User> { todo!() }
2110fn internal_helper() {}
2111"#,
2112        )
2113        .unwrap();
2114
2115        let result = extract_files(&[file], tmp.path());
2116
2117        // Types extracted
2118        assert!(result.types.contains_key("User"));
2119        assert!(result.types.contains_key("Status"));
2120        assert!(result.types.contains_key("Validate"));
2121
2122        // User has fields
2123        let user = &result.types["User"];
2124        assert_eq!(user.fields.len(), 2);
2125        assert_eq!(user.visibility, Visibility::Public);
2126
2127        // Status has variants
2128        let status = &result.types["Status"];
2129        assert_eq!(status.variants, vec!["Active", "Inactive"]);
2130
2131        // Validate has methods
2132        let validate = &result.types["Validate"];
2133        assert!(validate.methods.contains(&"validate".to_string()));
2134
2135        // User implements Validate
2136        assert!(user.implements.contains(&"Validate".to_string()));
2137
2138        // Functions extracted with signatures
2139        let load = &result.functions["load_user"];
2140        assert!(load.is_async);
2141        assert!(load.signature.contains("-> Result<User>"));
2142        assert_eq!(load.visibility, Visibility::Public);
2143
2144        let helper = &result.functions["internal_helper"];
2145        assert_eq!(helper.visibility, Visibility::Private);
2146    }
2147
2148    #[test]
2149    fn extract_typescript_file() {
2150        let tmp = tempfile::tempdir().unwrap();
2151        let file = tmp.path().join("models.ts");
2152        std::fs::write(
2153            &file,
2154            r#"
2155export interface User {
2156    name: string;
2157    email: string;
2158}
2159
2160export enum Status {
2161    Active = "active",
2162    Inactive = "inactive",
2163}
2164
2165export async function fetchUser(id: string): Promise<User> {
2166    return {} as User;
2167}
2168
2169function internalHelper(): void {}
2170"#,
2171        )
2172        .unwrap();
2173
2174        let result = extract_files(&[file], tmp.path());
2175        assert!(result.types.contains_key("User"));
2176        assert!(result.types.contains_key("Status"));
2177        assert_eq!(result.types["User"].visibility, Visibility::Public);
2178        assert_eq!(result.types["Status"].variants, vec!["Active", "Inactive"]);
2179        assert!(result.functions["fetchUser"].is_async);
2180        assert_eq!(
2181            result.functions["internalHelper"].visibility,
2182            Visibility::Private
2183        );
2184    }
2185
2186    #[test]
2187    fn format_output_shows_rich_info() {
2188        let tmp = tempfile::tempdir().unwrap();
2189        let file = tmp.path().join("lib.rs");
2190        std::fs::write(
2191            &file,
2192            r#"
2193pub struct Config { pub host: String, pub port: u16 }
2194pub enum Mode { Debug, Release }
2195pub fn start(config: &Config) -> Result<()> { todo!() }
2196"#,
2197        )
2198        .unwrap();
2199
2200        let result = extract_files(std::slice::from_ref(&file), tmp.path());
2201        let output = format_result(&result, &[file], tmp.path(), "extract", None);
2202
2203        assert!(output.contains("pub struct Config { host, port }"));
2204        assert!(output.contains("pub enum Mode { Debug, Release }"));
2205        assert!(output.contains("pub fn start"));
2206        assert!(output.contains("-> Result<()>"));
2207    }
2208
2209    #[test]
2210    fn skeleton_output_includes_line_ranges_and_target_hint() {
2211        let tmp = tempfile::tempdir().unwrap();
2212        let file = tmp.path().join("lib.rs");
2213        std::fs::write(
2214            &file,
2215            r#"
2216pub struct Config { pub host: String, pub port: u16 }
2217pub fn start(config: &Config) -> Result<()> { todo!() }
2218"#,
2219        )
2220        .unwrap();
2221
2222        let result = extract_files(std::slice::from_ref(&file), tmp.path());
2223        let output = format_result(&result, &[file], tmp.path(), "build", None);
2224
2225        assert!(output.contains("compact code skeleton"));
2226        assert!(output.contains("file#symbol"));
2227        assert!(output.contains("pub struct Config"));
2228        assert!(output.contains(" @ lib.rs:2"));
2229        assert!(output.contains("pub fn start"));
2230        assert!(output.contains(" @ lib.rs:3"));
2231    }
2232
2233    #[test]
2234    fn symbol_extract_includes_structured_details() {
2235        let tmp = tempfile::tempdir().unwrap();
2236        let file = tmp.path().join("lib.rs");
2237        std::fs::write(
2238            &file,
2239            r#"
2240pub struct Config {
2241    pub host: String,
2242}
2243
2244pub fn start(config: &Config) -> Result<()> { todo!() }
2245"#,
2246        )
2247        .unwrap();
2248
2249        let found =
2250            extract_symbol(&std::fs::read_to_string(&file).unwrap(), &file, "Config").unwrap();
2251        let output = format_blocks(&[CodeBlock {
2252            file: PathBuf::from("lib.rs"),
2253            ..found
2254        }]);
2255
2256        assert!(output.contains("Details:"));
2257        assert!(output.contains("\"symbol\":\"Config\""));
2258        assert!(output.contains("\"language\":\"rust\""));
2259        assert!(output.contains("\"start_line\":2"));
2260        assert!(output.contains("pub struct Config"));
2261    }
2262
2263    #[test]
2264    fn typescript_skeleton_output_includes_line_ranges() {
2265        let tmp = tempfile::tempdir().unwrap();
2266        let file = tmp.path().join("models.ts");
2267        std::fs::write(
2268            &file,
2269            r#"
2270export interface User { name: string; }
2271export async function fetchUser(id: string): Promise<User> { return {} as User; }
2272"#,
2273        )
2274        .unwrap();
2275
2276        let result = extract_files(std::slice::from_ref(&file), tmp.path());
2277        let output = format_result(&result, &[file], tmp.path(), "build", None);
2278
2279        assert!(output.contains("pub interface User @ models.ts:2"));
2280        assert!(output.contains("pub async function fetchUser"));
2281        assert!(output.contains(" @ models.ts:3"));
2282    }
2283}