Skip to main content

winx_code_agent/tools/
outline.rs

1//! Implementation of the `Outline` tool — a tree-sitter symbol map.
2//!
3//! A file path returns that file's definitions (functions, types, methods, ...);
4//! a directory (or empty = the whole workspace) returns a ranked, token-budgeted
5//! repo symbol map. Read-only and workspace-confined, so it works in every mode.
6//! Reuses the bundled tree-sitter grammars, the `ignore` walker, and the
7//! path-probability ranker that already power the other read tools.
8
9use std::collections::HashMap;
10use std::fmt::Write as FmtWrite;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::time::UNIX_EPOCH;
14
15use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use tokio::sync::Mutex;
19use tree_sitter_tags::{TagsConfiguration, TagsContext};
20
21use crate::errors::{Result, WinxError};
22use crate::state::bash_state::BashState;
23use crate::types::{CodeMapFallback, Outline, OutlineFile, OutlineOutput, OutlineSymbol};
24use crate::utils::mmap::read_file_to_string;
25use crate::utils::path::resolve_in_workspace;
26use crate::utils::path_prob::score_paths;
27use crate::utils::repo::walk_workspace_files;
28use crate::utils::symbols::{self, Symbol};
29use crate::utils::workspace_stats::active_files_for_context;
30
31/// Cap on files in repo-map mode when the caller passes 0.
32const DEFAULT_MAX_FILES: usize = 20;
33/// Cap on symbols in single-file mode when the caller passes 0.
34const DEFAULT_MAX_SYMBOLS: usize = 250;
35/// Preserve breadth in a repo map: dense files can be outlined directly later.
36const REPO_MAX_SYMBOLS_PER_FILE: usize = 64;
37/// Skip files larger than this when outlining (huge generated files are noise).
38const MAX_OUTLINE_FILE_SIZE: u64 = 2_000_000;
39/// Combined text + structured navigation payload. The MCP envelope and JSON
40/// escaping still fit comfortably under a 64 KiB response in ordinary source.
41pub(crate) const CODE_MAP_PAYLOAD_MAX_BYTES: usize = 44 * 1024;
42/// Leave room for cursor, snapshot, counters, and the truncation notice.
43const CODE_MAP_PAYLOAD_FIXED_RESERVE: usize = 1_024;
44/// Stop after reading+parsing this many files in repo mode, so a tree of
45/// definition-less supported files can't be fully scanned (a scan-budget cap).
46const MAX_FILES_SCANNED: usize = 20_000;
47const MAX_CURSOR_BYTES: usize = 1_024;
48
49type Configs = HashMap<String, Option<TagsConfiguration>>;
50
51#[derive(Debug, Deserialize, Serialize)]
52struct RepoCursor {
53    version: u8,
54    offset: usize,
55    snapshot: String,
56}
57
58fn navigation_payload_bytes(text: &str, files: &[OutlineFile]) -> usize {
59    text.len()
60        .saturating_add(serde_json::to_vec(files).map_or(usize::MAX, |serialized| serialized.len()))
61}
62
63fn finalize_output(text: &str, mut output: OutlineOutput) -> Result<serde_json::Value> {
64    // `payload_bytes` describes the payload that contains the field itself. A
65    // few iterations make the decimal digit count converge without estimation.
66    for _ in 0..3 {
67        let serialized = serde_json::to_vec(&output)
68            .map_err(|error| WinxError::SerializationError(error.to_string()))?;
69        let measured = text.len().saturating_add(serialized.len());
70        if measured == output.payload_bytes {
71            break;
72        }
73        output.payload_bytes = measured;
74    }
75    crate::tools::structured_json(&output)
76}
77
78fn query_terms(query: &str) -> Vec<String> {
79    let mut terms = query
80        .split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
81        .map(str::trim)
82        .filter(|term| term.len() >= 2)
83        .map(str::to_ascii_lowercase)
84        .collect::<Vec<_>>();
85    terms.sort();
86    terms.dedup();
87    terms
88}
89
90fn contextual_path_score(rel: &str, terms: &[String], active_paths: &[String]) -> usize {
91    let lower = rel.to_ascii_lowercase();
92    let file_name = Path::new(&lower).file_name().and_then(|name| name.to_str()).unwrap_or("");
93    let active = active_paths
94        .iter()
95        .position(|path| rel == path || rel.ends_with(path))
96        .map_or(0, |index| 1_000usize.saturating_sub(index * 50));
97    active.saturating_add(terms.iter().fold(0usize, |score, term| {
98        let bonus = if file_name == term || file_name.strip_suffix(".rs") == Some(term) {
99            200
100        } else if file_name.contains(term) {
101            80
102        } else if lower.split('/').any(|component| component == term) {
103            60
104        } else if lower.contains(term) {
105            20
106        } else {
107            0
108        };
109        score.saturating_add(bonus)
110    }))
111}
112
113fn repo_snapshot_hash(root: &Path, files: &[(PathBuf, String)], query: &str) -> String {
114    let mut hasher = Sha256::new();
115    hasher.update(root.to_string_lossy().as_bytes());
116    hasher.update([0]);
117    hasher.update(query.as_bytes());
118    for (path, rel) in files {
119        hasher.update([0]);
120        hasher.update(rel.as_bytes());
121        if let Ok(metadata) = path.metadata() {
122            hasher.update(metadata.len().to_le_bytes());
123            let modified = metadata
124                .modified()
125                .ok()
126                .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
127                .map_or(0, |duration| duration.as_nanos());
128            hasher.update(modified.to_le_bytes());
129        }
130    }
131    let digest = hasher.finalize();
132    let mut short = String::with_capacity(16);
133    for byte in &digest[..8] {
134        let _ = write!(short, "{byte:02x}");
135    }
136    short
137}
138
139fn encode_cursor(offset: usize, snapshot: &str) -> Result<String> {
140    let bytes =
141        serde_json::to_vec(&RepoCursor { version: 1, offset, snapshot: snapshot.to_string() })
142            .map_err(|error| WinxError::SerializationError(error.to_string()))?;
143    Ok(URL_SAFE_NO_PAD.encode(bytes))
144}
145
146fn decode_cursor(value: &str) -> Result<Option<RepoCursor>> {
147    if value.is_empty() {
148        return Ok(None);
149    }
150    if value.len() > MAX_CURSOR_BYTES {
151        return Err(WinxError::ArgumentParseError("CodeMap cursor is too large".to_string()));
152    }
153    let bytes = URL_SAFE_NO_PAD.decode(value).map_err(|_| {
154        WinxError::ArgumentParseError(
155            "Invalid CodeMap cursor; restart the outline without cursor".to_string(),
156        )
157    })?;
158    let cursor: RepoCursor = serde_json::from_slice(&bytes).map_err(|_| {
159        WinxError::ArgumentParseError(
160            "Invalid CodeMap cursor; restart the outline without cursor".to_string(),
161        )
162    })?;
163    if cursor.version != 1 {
164        return Err(WinxError::ArgumentParseError(
165            "Unsupported CodeMap cursor version; restart without cursor".to_string(),
166        ));
167    }
168    Ok(Some(cursor))
169}
170
171fn repo_cursor_offset(value: &str, snapshot_hash: &str, ranked_len: usize) -> Result<usize> {
172    let Some(cursor) = decode_cursor(value)? else { return Ok(0) };
173    if cursor.snapshot != snapshot_hash {
174        return Err(WinxError::ArgumentParseError(
175            "CodeMap cursor is stale because the ranked workspace snapshot changed; restart without cursor"
176                .to_string(),
177        ));
178    }
179    if cursor.offset > ranked_len {
180        return Err(WinxError::ArgumentParseError(
181            "CodeMap cursor offset is outside the current workspace snapshot; restart without cursor"
182                .to_string(),
183        ));
184    }
185    Ok(cursor.offset)
186}
187
188pub async fn handle_tool_call(
189    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
190    args: Outline,
191) -> Result<(String, serde_json::Value)> {
192    handle_tool_call_with_budget(bash_state_arc, args, CODE_MAP_PAYLOAD_MAX_BYTES).await
193}
194
195pub(crate) async fn handle_tool_call_with_budget(
196    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
197    args: Outline,
198    payload_max_bytes: usize,
199) -> Result<(String, serde_json::Value)> {
200    let (cwd, workspace_root, thread_id) = {
201        let guard = bash_state_arc.lock().await;
202        let bash_state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
203        (
204            bash_state.cwd.clone(),
205            bash_state.workspace_root.clone(),
206            bash_state.current_thread_id.clone(),
207        )
208    };
209
210    tokio::task::spawn_blocking(move || {
211        outline_with_paths(&args, &cwd, workspace_root, &thread_id, payload_max_bytes)
212    })
213    .await
214    .map_err(|error| {
215        WinxError::CommandExecutionError(format!("CodeMap outline worker failed: {error}"))
216    })?
217}
218
219fn outline_with_paths(
220    args: &Outline,
221    cwd: &Path,
222    workspace_root: PathBuf,
223    thread_id: &str,
224    payload_max_bytes: usize,
225) -> Result<(String, serde_json::Value)> {
226    let workspace_root =
227        workspace_root.canonicalize().map_or(workspace_root, crate::utils::path::simplified);
228    let root = resolve_in_workspace(&args.path, cwd, &workspace_root).map_err(|e| {
229        WinxError::PathSecurityError { path: PathBuf::from(&args.path), message: e.to_string() }
230    })?;
231
232    let mut context = TagsContext::new();
233    let mut configs: Configs = HashMap::new();
234
235    if root.is_file() {
236        if !args.cursor.is_empty() {
237            return Err(WinxError::ArgumentParseError(
238                "CodeMap cursor is valid only for directory outlines".to_string(),
239            ));
240        }
241        outline_one(
242            &root,
243            &workspace_root,
244            thread_id,
245            args,
246            &mut context,
247            &mut configs,
248            payload_max_bytes,
249        )
250    } else if root.is_dir() {
251        outline_repo(&root, &workspace_root, args, &mut context, &mut configs, payload_max_bytes)
252    } else {
253        // Don't silently degrade a typo'd file path into a whole-workspace scan.
254        Err(WinxError::FileNotFound { path: root.clone() })
255    }
256}
257
258/// A `file`-mode result carrying only a status message (no symbols).
259fn empty_file_outline(
260    message: String,
261    extension: String,
262    language_supported: bool,
263    fallback: Option<CodeMapFallback>,
264) -> Result<(String, serde_json::Value)> {
265    let structured = OutlineOutput {
266        mode: "file".to_string(),
267        files_shown: 0,
268        files: Vec::new(),
269        truncated: false,
270        next_cursor: None,
271        snapshot_hash: None,
272        files_scanned: 1,
273        payload_bytes: 0,
274        file_extension: Some(extension),
275        language_supported: Some(language_supported),
276        fallback,
277    };
278    let value = finalize_output(&message, structured)?;
279    Ok((message, value))
280}
281
282/// Lowercase extension of `path` ("" if none).
283fn ext_of(path: &Path) -> String {
284    path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_lowercase()
285}
286
287/// Workspace-relative display path.
288fn rel_of(path: &Path, workspace_root: &Path) -> String {
289    crate::utils::path::display_relative(path.strip_prefix(workspace_root).unwrap_or(path))
290}
291
292/// Compile-once-per-language cached config lookup.
293fn config_for<'a>(configs: &'a mut Configs, ext: &str) -> Option<&'a TagsConfiguration> {
294    configs.entry(ext.to_string()).or_insert_with(|| symbols::config_for(ext)).as_ref()
295}
296
297fn render_symbols(out: &mut String, syms: &[Symbol]) {
298    for s in syms {
299        let _ = writeln!(out, "  {:>5}  {:<9} {}", s.line, s.kind, s.name);
300    }
301}
302
303fn render_file_outline(rel: &str, total: usize, syms: &[Symbol]) -> String {
304    let mut out = String::new();
305    if syms.len() < total {
306        let _ = writeln!(out, "{rel} ({} of {total} symbols):", syms.len());
307    } else {
308        let noun = if total == 1 { "symbol" } else { "symbols" };
309        let _ = writeln!(out, "{rel} ({total} {noun}):");
310    }
311    render_symbols(&mut out, syms);
312    if syms.len() < total {
313        let _ = write!(out, "(...{} more; narrow path or raise max_results)", total - syms.len());
314    }
315    out
316}
317
318fn to_output(syms: Vec<Symbol>) -> Vec<OutlineSymbol> {
319    syms.into_iter().map(|s| OutlineSymbol { name: s.name, kind: s.kind, line: s.line }).collect()
320}
321
322fn outline_one(
323    file: &Path,
324    workspace_root: &Path,
325    thread_id: &str,
326    args: &Outline,
327    context: &mut TagsContext,
328    configs: &mut Configs,
329    payload_max_bytes: usize,
330) -> Result<(String, serde_json::Value)> {
331    let rel = rel_of(file, workspace_root);
332    let max = if args.max_results == 0 { DEFAULT_MAX_SYMBOLS } else { args.max_results };
333    let ext = ext_of(file);
334
335    // Distinguish the real reasons we'd return no symbols instead of collapsing
336    // them all into a misleading "no definitions" (no silent fallback).
337    if !symbols::supports(&ext) {
338        let temporary_artifact_dir =
339            crate::utils::agent_temp::session_info(workspace_root, thread_id).directory;
340        let fallback = CodeMapFallback {
341            tool: "ReadFiles".to_string(),
342            file_paths: vec![file.to_string_lossy().into_owned()],
343            reason: "unsupported_language".to_string(),
344            temporary_artifact_dir: temporary_artifact_dir.to_string_lossy().into_owned(),
345        };
346        return empty_file_outline(
347            format!(
348            "No symbols in {rel}: unsupported language (extension `.{ext}`). Use ReadFiles for \
349             exact canonical source; do not transform source solely to make CodeMap parse it. A \
350             genuinely useful derived helper may live in temporary_artifact_dir with short names \
351             and source-path/line provenance."
352        ),
353            ext,
354            false,
355            Some(fallback),
356        );
357    }
358    let text = match read_file_to_string(file, MAX_OUTLINE_FILE_SIZE) {
359        Ok(text) => text,
360        Err(e) => {
361            return empty_file_outline(format!("Could not outline {rel}: {e}"), ext, true, None)
362        }
363    };
364    let Some(config) = config_for(configs, &ext) else {
365        let temporary_artifact_dir =
366            crate::utils::agent_temp::session_info(workspace_root, thread_id).directory;
367        return empty_file_outline(
368            format!("No symbols in {rel}: the `.{ext}` tags query could not be loaded."),
369            ext,
370            true,
371            Some(CodeMapFallback {
372                tool: "ReadFiles".to_string(),
373                file_paths: vec![file.to_string_lossy().into_owned()],
374                reason: "parser_unavailable".to_string(),
375                temporary_artifact_dir: temporary_artifact_dir.to_string_lossy().into_owned(),
376            }),
377        );
378    };
379
380    let syms = symbols::extract(context, config, &text);
381    let total = syms.len();
382    if syms.is_empty() {
383        return empty_file_outline(format!("No definitions found in {rel}."), ext, true, None);
384    }
385
386    // Find the largest prefix that respects both max_results and the combined
387    // text + structured response budget. Binary search avoids repeatedly
388    // serializing hundreds of almost-identical prefixes.
389    let upper = total.min(max);
390    let mut low = 0usize;
391    let mut high = upper;
392    while low < high {
393        let middle = low + (high - low).div_ceil(2);
394        let selected = &syms[..middle];
395        let candidate_text = render_file_outline(&rel, total, selected);
396        let candidate_files =
397            vec![OutlineFile { file: rel.clone(), symbols: to_output(selected.to_vec()) }];
398        if navigation_payload_bytes(&candidate_text, &candidate_files)
399            <= payload_max_bytes.saturating_sub(CODE_MAP_PAYLOAD_FIXED_RESERVE)
400        {
401            low = middle;
402        } else {
403            high = middle - 1;
404        }
405    }
406
407    let selected = &syms[..low];
408    let truncated = low < total;
409    let out = if selected.is_empty() {
410        format!(
411            "{total} definitions found in {rel}, but the first symbol exceeds the CodeMap response budget. Use ReadFiles for exact source."
412        )
413    } else {
414        render_file_outline(&rel, total, selected)
415    };
416
417    let structured = OutlineOutput {
418        mode: "file".to_string(),
419        files_shown: 1,
420        files: vec![OutlineFile { file: rel, symbols: to_output(selected.to_vec()) }],
421        truncated,
422        next_cursor: None,
423        snapshot_hash: None,
424        files_scanned: 1,
425        payload_bytes: 0,
426        file_extension: Some(ext),
427        language_supported: Some(true),
428        fallback: None,
429    };
430    let value = finalize_output(&out, structured)?;
431    Ok((out, value))
432}
433
434/// Collect supported files under `root`, ranked by explicit task focus and
435/// workspace activity before the generic path-probability prior. Returns
436/// `(absolute, workspace-relative)` pairs.
437fn ranked_supported_files(
438    root: &Path,
439    workspace_root: &Path,
440    query: &str,
441    active_paths: &[String],
442) -> Vec<(PathBuf, String)> {
443    let mut files: Vec<(PathBuf, String)> = walk_workspace_files(root)
444        .into_iter()
445        .filter(|abs| symbols::supports(&ext_of(abs)))
446        .map(|abs| {
447            let rel = rel_of(&abs, workspace_root);
448            (abs, rel)
449        })
450        .collect();
451
452    // Pre-sort alphabetically so equal-score ties resolve deterministically
453    // (stable sort keeps this order).
454    files.sort_by(|a, b| a.1.cmp(&b.1));
455    // Score the names borrowed as &str — no per-path String clone (score_paths is
456    // generic over AsRef<str>). Scope `rels` so its borrow of `files` ends before
457    // we move `files` below.
458    let path_ranking = {
459        let rels: Vec<&str> = files.iter().map(|(_, r)| r.as_str()).collect();
460        score_paths(&rels)
461    }
462    .unwrap_or_else(|| vec![0.0; files.len()]);
463    let terms = query_terms(query);
464    let mut ranked = path_ranking
465        .into_iter()
466        .zip(files)
467        .map(|(path_score, file)| {
468            let context_score = contextual_path_score(&file.1, &terms, active_paths);
469            (context_score, path_score, file)
470        })
471        .collect::<Vec<_>>();
472    ranked.sort_by(|left, right| {
473        right
474            .0
475            .cmp(&left.0)
476            .then_with(|| right.1.partial_cmp(&left.1).unwrap_or(std::cmp::Ordering::Equal))
477            .then_with(|| left.2 .1.cmp(&right.2 .1))
478    });
479    ranked.into_iter().map(|(_, _, file)| file).collect()
480}
481
482fn fitting_repo_symbol_prefix(
483    current_text: &str,
484    current_files: &[OutlineFile],
485    rel: &str,
486    symbols: &[Symbol],
487    item_budget: usize,
488) -> usize {
489    let mut low = 0usize;
490    let mut high = symbols.len();
491    while low < high {
492        let middle = low + (high - low).div_ceil(2);
493        let selected = &symbols[..middle];
494        let mut chunk = String::new();
495        let _ = writeln!(chunk, "{rel}");
496        render_symbols(&mut chunk, selected);
497        let mut candidate_text = current_text.to_string();
498        candidate_text.push_str(&chunk);
499        let mut candidate_files = current_files.to_vec();
500        candidate_files
501            .push(OutlineFile { file: rel.to_string(), symbols: to_output(selected.to_vec()) });
502        if navigation_payload_bytes(&candidate_text, &candidate_files) <= item_budget {
503            low = middle;
504        } else {
505            high = middle - 1;
506        }
507    }
508    low
509}
510
511fn outline_repo(
512    root: &Path,
513    workspace_root: &Path,
514    args: &Outline,
515    context: &mut TagsContext,
516    configs: &mut Configs,
517    payload_max_bytes: usize,
518) -> Result<(String, serde_json::Value)> {
519    let max_files = if args.max_results == 0 { DEFAULT_MAX_FILES } else { args.max_results };
520    let active_paths = active_files_for_context(workspace_root);
521    let ranked = ranked_supported_files(root, workspace_root, &args.query, &active_paths);
522    let snapshot_hash = repo_snapshot_hash(root, &ranked, &args.query);
523    let start_offset = repo_cursor_offset(&args.cursor, &snapshot_hash, ranked.len())?;
524
525    let item_budget = payload_max_bytes.saturating_sub(CODE_MAP_PAYLOAD_FIXED_RESERVE);
526    let mut out = String::new();
527    if start_offset > 0 {
528        let _ = writeln!(out, "Code map continuation from ranked offset {start_offset}:");
529    }
530    let mut out_files: Vec<OutlineFile> = Vec::new();
531    let mut omitted_symbols = false;
532    let mut files_scanned = 0usize;
533    let mut next_offset = start_offset;
534
535    for (index, (abs, rel)) in ranked.iter().enumerate().skip(start_offset) {
536        if out_files.len() >= max_files || files_scanned >= MAX_FILES_SCANNED {
537            next_offset = index;
538            break;
539        }
540        // read_file_to_string enforces the size cap and rejects non-UTF-8, so a
541        // separate metadata stat is redundant — skip on any read error.
542        files_scanned += 1;
543        next_offset = index + 1;
544        let Ok(text) = read_file_to_string(abs, MAX_OUTLINE_FILE_SIZE) else { continue };
545        let ext = ext_of(abs);
546        let mut syms = match config_for(configs, &ext) {
547            Some(config) => symbols::extract(context, config, &text),
548            None => continue,
549        };
550        if syms.is_empty() {
551            continue;
552        }
553        let total_symbols = syms.len();
554        if syms.len() > REPO_MAX_SYMBOLS_PER_FILE {
555            syms.truncate(REPO_MAX_SYMBOLS_PER_FILE);
556            omitted_symbols = true;
557        }
558
559        // Fit the largest symbol prefix for this file into the combined text +
560        // structured budget. Checking the candidate before committing avoids
561        // the old one-dense-file overshoot.
562        let low = fitting_repo_symbol_prefix(&out, &out_files, rel, &syms, item_budget);
563
564        if low == 0 {
565            omitted_symbols = true;
566            // If the page already contains useful entries, leave this file for
567            // the next cursor. A single pathological symbol on an empty page is
568            // skipped so pagination can still make progress.
569            if !out_files.is_empty() {
570                next_offset = index;
571                break;
572            }
573            continue;
574        }
575
576        let selected = &syms[..low];
577        let _ = writeln!(out, "{rel}");
578        render_symbols(&mut out, selected);
579        out_files.push(OutlineFile { file: rel.clone(), symbols: to_output(selected.to_vec()) });
580        if low < total_symbols {
581            omitted_symbols = true;
582        }
583
584        if low < syms.len() {
585            break;
586        }
587    }
588
589    let next_cursor = if next_offset < ranked.len() {
590        Some(encode_cursor(next_offset, &snapshot_hash)?)
591    } else {
592        None
593    };
594    let truncated = omitted_symbols || next_cursor.is_some();
595    if out_files.is_empty() {
596        out = format!("No code symbols found under {}.", root.display());
597    } else if truncated {
598        if let Some(cursor) = next_cursor.as_deref() {
599            let _ =
600                write!(out, "(...capped; continue with cursor `{cursor}`, or narrow path/query)");
601        } else {
602            let _ = write!(out, "(...dense files capped; outline a specific file for more)");
603        }
604    }
605
606    let files_shown = out_files.len();
607    let structured = OutlineOutput {
608        mode: "repo".to_string(),
609        files_shown,
610        files: out_files,
611        truncated,
612        next_cursor,
613        snapshot_hash: Some(snapshot_hash),
614        files_scanned,
615        payload_bytes: 0,
616        file_extension: None,
617        language_supported: None,
618        fallback: None,
619    };
620    let value = finalize_output(&out, structured)?;
621    Ok((out, value))
622}
623
624#[cfg(test)]
625mod tests {
626    #![allow(clippy::unwrap_used)]
627    use super::*;
628    use tempfile::TempDir;
629
630    fn state_in(dir: &TempDir) -> Arc<Mutex<Option<BashState>>> {
631        let mut bs = BashState::new();
632        let root = dir.path().canonicalize().map(crate::utils::path::simplified).unwrap();
633        bs.cwd = root.clone();
634        bs.workspace_root = root;
635        Arc::new(Mutex::new(Some(bs)))
636    }
637
638    fn args(path: &str) -> Outline {
639        Outline {
640            path: path.to_string(),
641            max_results: 0,
642            query: String::new(),
643            cursor: String::new(),
644            thread_id: String::new(),
645        }
646    }
647
648    #[tokio::test]
649    async fn single_file_lists_symbols() {
650        let dir = TempDir::new().unwrap();
651        std::fs::write(dir.path().join("a.rs"), "pub fn alpha() {}\nstruct Beta;\n").unwrap();
652        let st = state_in(&dir);
653        let (out, structured) = handle_tool_call(&st, args("a.rs")).await.unwrap();
654        assert!(out.contains("alpha"));
655        assert!(out.contains("Beta"));
656        assert_eq!(structured["mode"], "file");
657        let syms = structured["files"][0]["symbols"].as_array().unwrap();
658        assert!(syms.iter().any(|s| s["name"] == "alpha"));
659    }
660
661    #[tokio::test]
662    async fn repo_map_ranks_files() {
663        let dir = TempDir::new().unwrap();
664        std::fs::create_dir_all(dir.path().join("src")).unwrap();
665        std::fs::write(dir.path().join("src/lib.rs"), "pub fn one() {}\n").unwrap();
666        std::fs::write(dir.path().join("src/util.rs"), "fn two() {}\n").unwrap();
667        std::fs::write(dir.path().join("notes.txt"), "not code\n").unwrap();
668        let st = state_in(&dir);
669        let (out, structured) = handle_tool_call(&st, args("")).await.unwrap();
670        assert_eq!(structured["mode"], "repo");
671        assert_eq!(structured["files_shown"], 2); // only the 2 .rs files
672        assert!(out.contains("one"));
673        assert!(out.contains("two"));
674        assert!(!out.contains("notes.txt"));
675    }
676
677    #[tokio::test]
678    async fn repo_map_query_focuses_the_first_page() {
679        let dir = TempDir::new().unwrap();
680        std::fs::create_dir_all(dir.path().join("src")).unwrap();
681        std::fs::write(dir.path().join("src/alpha.rs"), "fn alpha() {}\n").unwrap();
682        std::fs::write(dir.path().join("src/billing.rs"), "fn invoice() {}\n").unwrap();
683        let st = state_in(&dir);
684        let mut focused = args("");
685        focused.max_results = 1;
686        focused.query = "billing invoice".to_string();
687
688        let (_, structured) = handle_tool_call(&st, focused).await.unwrap();
689
690        assert_eq!(structured["files"][0]["file"], "src/billing.rs");
691        assert!(structured["next_cursor"].is_string());
692    }
693
694    #[tokio::test]
695    async fn repo_map_cursor_continues_without_repeating_files() {
696        let dir = TempDir::new().unwrap();
697        for name in ["alpha", "beta", "gamma"] {
698            std::fs::write(dir.path().join(format!("{name}.rs")), format!("fn {name}() {{}}\n"))
699                .unwrap();
700        }
701        let st = state_in(&dir);
702        let mut first_args = args("");
703        first_args.max_results = 1;
704        let (_, first) = handle_tool_call(&st, first_args).await.unwrap();
705        let first_file = first["files"][0]["file"].as_str().unwrap().to_string();
706        let cursor = first["next_cursor"].as_str().unwrap().to_string();
707
708        let mut second_args = args("");
709        second_args.max_results = 1;
710        second_args.cursor = cursor;
711        let (_, second) = handle_tool_call(&st, second_args).await.unwrap();
712
713        assert_ne!(second["files"][0]["file"], first_file);
714        assert_eq!(second["snapshot_hash"], first["snapshot_hash"]);
715    }
716
717    #[tokio::test]
718    async fn repo_map_rejects_a_cursor_after_snapshot_change() {
719        let dir = TempDir::new().unwrap();
720        std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
721        std::fs::write(dir.path().join("b.rs"), "fn b() {}\n").unwrap();
722        let st = state_in(&dir);
723        let mut first_args = args("");
724        first_args.max_results = 1;
725        let (_, first) = handle_tool_call(&st, first_args).await.unwrap();
726        let cursor = first["next_cursor"].as_str().unwrap().to_string();
727        std::fs::write(dir.path().join("c.rs"), "fn c() {}\n").unwrap();
728
729        let mut stale = args("");
730        stale.cursor = cursor;
731        let error = handle_tool_call(&st, stale).await.unwrap_err();
732
733        assert!(error.to_string().contains("cursor is stale"), "{error}");
734    }
735
736    #[tokio::test]
737    async fn no_symbols_message() {
738        let dir = TempDir::new().unwrap();
739        std::fs::write(dir.path().join("data.json"), "{\"a\":1}\n").unwrap();
740        let st = state_in(&dir);
741        let (out, structured) = handle_tool_call(&st, args("data.json")).await.unwrap();
742        assert!(out.to_lowercase().contains("no symbols"));
743        assert_eq!(structured["files_shown"], 0);
744    }
745
746    #[tokio::test]
747    async fn unsupported_language_returns_actionable_exact_source_fallback() {
748        let dir = TempDir::new().unwrap();
749        std::fs::write(dir.path().join("page.heex"), "<p><%= @name %></p>\n").unwrap();
750        let st = state_in(&dir);
751        let (out, structured) = handle_tool_call(&st, args("page.heex")).await.unwrap();
752        assert!(out.contains("unsupported language"));
753        assert!(out.contains("ReadFiles"));
754        assert!(out.contains("do not transform source solely"));
755        assert_eq!(structured["files_shown"], 0);
756        assert_eq!(structured["file_extension"], "heex");
757        assert_eq!(structured["language_supported"], false);
758        assert_eq!(structured["fallback"]["tool"], "ReadFiles");
759        assert_eq!(structured["fallback"]["reason"], "unsupported_language");
760        assert_eq!(
761            structured["fallback"]["file_paths"][0],
762            dir.path()
763                .join("page.heex")
764                .canonicalize()
765                .map(crate::utils::path::simplified)
766                .unwrap()
767                .to_string_lossy()
768                .as_ref()
769        );
770        assert!(structured["fallback"]["temporary_artifact_dir"]
771            .as_str()
772            .is_some_and(|path| path.replace('\\', "/").contains("/.winx/tmp/session-")));
773    }
774
775    #[tokio::test]
776    async fn python_and_elixir_are_native_code_map_languages() {
777        let dir = TempDir::new().unwrap();
778        std::fs::write(
779            dir.path().join("service.py"),
780            "class Service:\n    def run(self):\n        pass\n",
781        )
782        .unwrap();
783        std::fs::write(
784            dir.path().join("worker.ex"),
785            "defmodule Worker do\n  def run(), do: :ok\nend\n",
786        )
787        .unwrap();
788        let st = state_in(&dir);
789
790        let (python, python_data) = handle_tool_call(&st, args("service.py")).await.unwrap();
791        assert!(python.contains("Service"), "{python}");
792        assert!(python.contains("run"), "{python}");
793        assert_eq!(python_data["language_supported"], true);
794
795        let (elixir, elixir_data) = handle_tool_call(&st, args("worker.ex")).await.unwrap();
796        if cfg!(feature = "languages-extra") {
797            assert!(elixir.contains("Worker"), "{elixir}");
798            assert!(elixir.contains("run"), "{elixir}");
799            assert_eq!(elixir_data["language_supported"], true);
800        } else {
801            assert_eq!(elixir_data["language_supported"], false, "{elixir}");
802        }
803    }
804
805    #[tokio::test]
806    async fn repo_map_prunes_winx_but_explicit_helper_outline_remains_available() {
807        let dir = TempDir::new().unwrap();
808        let root = dir.path().canonicalize().map(crate::utils::path::simplified).unwrap();
809        std::fs::write(root.join("main.py"), "def canonical():\n    pass\n").unwrap();
810        let helper = crate::utils::agent_temp::session_info(&root, "helper-session")
811            .directory
812            .join("review_adapter.py");
813        std::fs::create_dir_all(helper.parent().unwrap()).unwrap();
814        std::fs::write(&helper, "def derived_review():\n    pass\n").unwrap();
815        let st = state_in(&dir);
816
817        let (repo, _) = handle_tool_call(&st, args("")).await.unwrap();
818        assert!(repo.contains("canonical"), "{repo}");
819        assert!(!repo.contains("derived_review"), "{repo}");
820
821        let helper_relative = helper.strip_prefix(&root).unwrap().to_string_lossy();
822        let (explicit, _) = handle_tool_call(&st, args(&helper_relative)).await.unwrap();
823        assert!(explicit.contains("derived_review"), "{explicit}");
824    }
825
826    #[tokio::test]
827    async fn nonexistent_path_errors_instead_of_repo_scan() {
828        // Regression: a typo'd file path must error, not silently become an empty
829        // whole-workspace scan.
830        let dir = TempDir::new().unwrap();
831        std::fs::write(dir.path().join("real.rs"), "fn x() {}\n").unwrap();
832        let st = state_in(&dir);
833        assert!(handle_tool_call(&st, args("nope_typo.rs")).await.is_err());
834    }
835
836    #[tokio::test]
837    async fn repo_per_file_cap_marks_truncated() {
838        // Regression (B1): a definition-dense file must be clipped and reported
839        // as truncated, not blow the budget while claiming completeness.
840        let dir = TempDir::new().unwrap();
841        let mut src = String::new();
842        for i in 0..(DEFAULT_MAX_SYMBOLS + 100) {
843            let _ = writeln!(src, "fn f{i}() {{}}");
844        }
845        std::fs::write(dir.path().join("big.rs"), src).unwrap();
846        let st = state_in(&dir);
847        let (_, structured) = handle_tool_call(&st, args("")).await.unwrap();
848        let syms = structured["files"][0]["symbols"].as_array().unwrap();
849        assert!(syms.len() <= REPO_MAX_SYMBOLS_PER_FILE, "got {}", syms.len());
850        assert_eq!(structured["truncated"], true);
851        assert!(
852            usize::try_from(structured["payload_bytes"].as_u64().unwrap())
853                .is_ok_and(|bytes| bytes <= CODE_MAP_PAYLOAD_MAX_BYTES),
854            "{structured}"
855        );
856    }
857}