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;
13
14use tokio::sync::Mutex;
15use tree_sitter_tags::{TagsConfiguration, TagsContext};
16
17use crate::errors::{Result, WinxError};
18use crate::state::bash_state::BashState;
19use crate::types::{Outline, OutlineFile, OutlineOutput, OutlineSymbol};
20use crate::utils::mmap::read_file_to_string;
21use crate::utils::path::resolve_in_workspace;
22use crate::utils::path_prob::score_paths;
23use crate::utils::repo::walk_workspace_files;
24use crate::utils::symbols::{self, Symbol};
25
26/// Cap on files in repo-map mode when the caller passes 0.
27const DEFAULT_MAX_FILES: usize = 50;
28/// Cap on symbols in single-file mode when the caller passes 0.
29const DEFAULT_MAX_SYMBOLS: usize = 500;
30/// Skip files larger than this when outlining (huge generated files are noise).
31const MAX_OUTLINE_FILE_SIZE: u64 = 2_000_000;
32/// Byte proxy (~4 bytes/token) for the rendered repo-map budget.
33const OUTLINE_MAX_BYTES: usize = 24_000 * 4;
34/// Stop after reading+parsing this many files in repo mode, so a tree of
35/// definition-less supported files can't be fully scanned (a scan-budget cap).
36const MAX_FILES_SCANNED: usize = 20_000;
37
38type Configs = HashMap<String, Option<TagsConfiguration>>;
39
40pub async fn handle_tool_call(
41    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
42    args: Outline,
43) -> Result<(String, serde_json::Value)> {
44    let (cwd, workspace_root) = {
45        let guard = bash_state_arc.lock().await;
46        let bash_state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
47        (bash_state.cwd.clone(), bash_state.workspace_root.clone())
48    };
49    let workspace_root = workspace_root.canonicalize().unwrap_or(workspace_root);
50    let root = resolve_in_workspace(&args.path, &cwd, &workspace_root).map_err(|e| {
51        WinxError::PathSecurityError { path: PathBuf::from(&args.path), message: e.to_string() }
52    })?;
53
54    let mut context = TagsContext::new();
55    let mut configs: Configs = HashMap::new();
56
57    if root.is_file() {
58        outline_one(&root, &workspace_root, &args, &mut context, &mut configs)
59    } else if root.is_dir() {
60        outline_repo(&root, &workspace_root, &args, &mut context, &mut configs)
61    } else {
62        // Don't silently degrade a typo'd file path into a whole-workspace scan.
63        Err(WinxError::FileAccessError {
64            path: root.clone(),
65            message: "path not found (or not a regular file/directory)".to_string(),
66        })
67    }
68}
69
70/// A `file`-mode result carrying only a status message (no symbols).
71fn empty_file_outline(message: String) -> Result<(String, serde_json::Value)> {
72    let structured = OutlineOutput {
73        mode: "file".to_string(),
74        files_shown: 0,
75        files: Vec::new(),
76        truncated: false,
77    };
78    Ok((message, crate::tools::structured_json(&structured)?))
79}
80
81/// Lowercase extension of `path` ("" if none).
82fn ext_of(path: &Path) -> String {
83    path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_lowercase()
84}
85
86/// Workspace-relative display path.
87fn rel_of(path: &Path, workspace_root: &Path) -> String {
88    path.strip_prefix(workspace_root).unwrap_or(path).to_string_lossy().to_string()
89}
90
91/// Compile-once-per-language cached config lookup.
92fn config_for<'a>(configs: &'a mut Configs, ext: &str) -> Option<&'a TagsConfiguration> {
93    configs.entry(ext.to_string()).or_insert_with(|| symbols::config_for(ext)).as_ref()
94}
95
96fn render_symbols(out: &mut String, syms: &[Symbol]) {
97    for s in syms {
98        let _ = writeln!(out, "  {:>5}  {:<9} {}", s.line, s.kind, s.name);
99    }
100}
101
102fn to_output(syms: Vec<Symbol>) -> Vec<OutlineSymbol> {
103    syms.into_iter().map(|s| OutlineSymbol { name: s.name, kind: s.kind, line: s.line }).collect()
104}
105
106fn outline_one(
107    file: &Path,
108    workspace_root: &Path,
109    args: &Outline,
110    context: &mut TagsContext,
111    configs: &mut Configs,
112) -> Result<(String, serde_json::Value)> {
113    let rel = rel_of(file, workspace_root);
114    let max = if args.max_results == 0 { DEFAULT_MAX_SYMBOLS } else { args.max_results };
115    let ext = ext_of(file);
116
117    // Distinguish the real reasons we'd return no symbols instead of collapsing
118    // them all into a misleading "no definitions" (no silent fallback).
119    if !symbols::supports(&ext) {
120        return empty_file_outline(format!(
121            "No symbols in {rel}: unsupported language (extension `.{ext}`)."
122        ));
123    }
124    let text = match read_file_to_string(file, MAX_OUTLINE_FILE_SIZE) {
125        Ok(text) => text,
126        Err(e) => return empty_file_outline(format!("Could not outline {rel}: {e}")),
127    };
128    let Some(config) = config_for(configs, &ext) else {
129        return empty_file_outline(format!("No symbols in {rel}: no tags query for `.{ext}`."));
130    };
131
132    let mut syms = symbols::extract(context, config, &text);
133    let total = syms.len();
134    let truncated = total > max;
135    if truncated {
136        syms.truncate(max);
137    }
138
139    let mut out = String::new();
140    if syms.is_empty() {
141        return empty_file_outline(format!("No definitions found in {rel}."));
142    }
143    if truncated {
144        let _ = writeln!(out, "{rel} ({} of {total} symbols):", syms.len());
145    } else {
146        let noun = if total == 1 { "symbol" } else { "symbols" };
147        let _ = writeln!(out, "{rel} ({total} {noun}):");
148    }
149    render_symbols(&mut out, &syms);
150    if truncated {
151        let _ = write!(out, "(...{} more; raise max_results)", total - syms.len());
152    }
153
154    let structured = OutlineOutput {
155        mode: "file".to_string(),
156        files_shown: 1,
157        files: vec![OutlineFile { file: rel, symbols: to_output(syms) }],
158        truncated,
159    };
160    Ok((out, crate::tools::structured_json(&structured)?))
161}
162
163/// Collect supported files under `root`, ranked best-first by the path-prob model
164/// (alphabetical fallback). Returns `(absolute, workspace-relative)` pairs.
165fn ranked_supported_files(root: &Path, workspace_root: &Path) -> Vec<(PathBuf, String)> {
166    let mut files: Vec<(PathBuf, String)> = walk_workspace_files(root)
167        .into_iter()
168        .filter(|abs| symbols::supports(&ext_of(abs)))
169        .map(|abs| {
170            let rel = rel_of(&abs, workspace_root);
171            (abs, rel)
172        })
173        .collect();
174
175    // Pre-sort alphabetically so equal-score ties resolve deterministically
176    // (stable sort keeps this order).
177    files.sort_by(|a, b| a.1.cmp(&b.1));
178    // Score the names borrowed as &str — no per-path String clone (score_paths is
179    // generic over AsRef<str>). Scope `rels` so its borrow of `files` ends before
180    // we move `files` below.
181    let ranking = {
182        let rels: Vec<&str> = files.iter().map(|(_, r)| r.as_str()).collect();
183        score_paths(&rels)
184    };
185    if let Some(weights) = ranking {
186        // Pair each file with its weight and sort by weight desc, MOVING the files
187        // (no per-entry clone of the old reindex). Stable sort preserves the
188        // alphabetical pre-sort for equal weights.
189        let mut pairs: Vec<(f64, (PathBuf, String))> = weights.into_iter().zip(files).collect();
190        pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
191        files = pairs.into_iter().map(|(_, f)| f).collect();
192    }
193    files
194}
195
196fn outline_repo(
197    root: &Path,
198    workspace_root: &Path,
199    args: &Outline,
200    context: &mut TagsContext,
201    configs: &mut Configs,
202) -> Result<(String, serde_json::Value)> {
203    let max_files = if args.max_results == 0 { DEFAULT_MAX_FILES } else { args.max_results };
204
205    let mut out = String::new();
206    let mut out_files: Vec<OutlineFile> = Vec::new();
207    let mut truncated = false;
208    let mut scanned = 0usize;
209
210    for (abs, rel) in ranked_supported_files(root, workspace_root) {
211        if out_files.len() >= max_files
212            || out.len() >= OUTLINE_MAX_BYTES
213            || scanned >= MAX_FILES_SCANNED
214        {
215            truncated = true;
216            break;
217        }
218        // read_file_to_string enforces the size cap and rejects non-UTF-8, so a
219        // separate metadata stat is redundant — skip on any read error.
220        let Ok(text) = read_file_to_string(&abs, MAX_OUTLINE_FILE_SIZE) else { continue };
221        scanned += 1;
222        let ext = ext_of(&abs);
223        let mut syms = match config_for(configs, &ext) {
224            Some(config) => symbols::extract(context, config, &text),
225            None => continue,
226        };
227        if syms.is_empty() {
228            continue;
229        }
230        // Per-file symbol cap: one definition-dense file (minified/generated)
231        // must not blow the budget or the structured array.
232        if syms.len() > DEFAULT_MAX_SYMBOLS {
233            syms.truncate(DEFAULT_MAX_SYMBOLS);
234            truncated = true;
235        }
236        let _ = writeln!(out, "{rel}");
237        render_symbols(&mut out, &syms);
238        out_files.push(OutlineFile { file: rel, symbols: to_output(syms) });
239    }
240
241    if out_files.is_empty() {
242        out = format!("No code symbols found under {}.", root.display());
243    } else if truncated {
244        let _ = write!(out, "(...capped; narrow `path` or raise max_results)");
245    }
246
247    let files_shown = out_files.len();
248    let structured =
249        OutlineOutput { mode: "repo".to_string(), files_shown, files: out_files, truncated };
250    Ok((out, crate::tools::structured_json(&structured)?))
251}
252
253#[cfg(test)]
254mod tests {
255    #![allow(clippy::unwrap_used)]
256    use super::*;
257    use tempfile::TempDir;
258
259    fn state_in(dir: &TempDir) -> Arc<Mutex<Option<BashState>>> {
260        let mut bs = BashState::new();
261        let root = dir.path().canonicalize().unwrap();
262        bs.cwd = root.clone();
263        bs.workspace_root = root;
264        Arc::new(Mutex::new(Some(bs)))
265    }
266
267    fn args(path: &str) -> Outline {
268        Outline { path: path.to_string(), max_results: 0, thread_id: String::new() }
269    }
270
271    #[tokio::test]
272    async fn single_file_lists_symbols() {
273        let dir = TempDir::new().unwrap();
274        std::fs::write(dir.path().join("a.rs"), "pub fn alpha() {}\nstruct Beta;\n").unwrap();
275        let st = state_in(&dir);
276        let (out, structured) = handle_tool_call(&st, args("a.rs")).await.unwrap();
277        assert!(out.contains("alpha"));
278        assert!(out.contains("Beta"));
279        assert_eq!(structured["mode"], "file");
280        let syms = structured["files"][0]["symbols"].as_array().unwrap();
281        assert!(syms.iter().any(|s| s["name"] == "alpha"));
282    }
283
284    #[tokio::test]
285    async fn repo_map_ranks_files() {
286        let dir = TempDir::new().unwrap();
287        std::fs::create_dir_all(dir.path().join("src")).unwrap();
288        std::fs::write(dir.path().join("src/lib.rs"), "pub fn one() {}\n").unwrap();
289        std::fs::write(dir.path().join("src/util.rs"), "fn two() {}\n").unwrap();
290        std::fs::write(dir.path().join("notes.txt"), "not code\n").unwrap();
291        let st = state_in(&dir);
292        let (out, structured) = handle_tool_call(&st, args("")).await.unwrap();
293        assert_eq!(structured["mode"], "repo");
294        assert_eq!(structured["files_shown"], 2); // only the 2 .rs files
295        assert!(out.contains("one"));
296        assert!(out.contains("two"));
297        assert!(!out.contains("notes.txt"));
298    }
299
300    #[tokio::test]
301    async fn no_symbols_message() {
302        let dir = TempDir::new().unwrap();
303        std::fs::write(dir.path().join("data.json"), "{\"a\":1}\n").unwrap();
304        let st = state_in(&dir);
305        let (out, structured) = handle_tool_call(&st, args("data.json")).await.unwrap();
306        assert!(out.to_lowercase().contains("no symbols"));
307        assert_eq!(structured["files_shown"], 0);
308    }
309
310    #[tokio::test]
311    async fn nonexistent_path_errors_instead_of_repo_scan() {
312        // Regression: a typo'd file path must error, not silently become an empty
313        // whole-workspace scan.
314        let dir = TempDir::new().unwrap();
315        std::fs::write(dir.path().join("real.rs"), "fn x() {}\n").unwrap();
316        let st = state_in(&dir);
317        assert!(handle_tool_call(&st, args("nope_typo.rs")).await.is_err());
318    }
319
320    #[tokio::test]
321    async fn repo_per_file_cap_marks_truncated() {
322        // Regression (B1): a definition-dense file must be clipped and reported
323        // as truncated, not blow the budget while claiming completeness.
324        let dir = TempDir::new().unwrap();
325        let mut src = String::new();
326        for i in 0..(DEFAULT_MAX_SYMBOLS + 100) {
327            let _ = writeln!(src, "fn f{i}() {{}}");
328        }
329        std::fs::write(dir.path().join("big.rs"), src).unwrap();
330        let st = state_in(&dir);
331        let (_, structured) = handle_tool_call(&st, args("")).await.unwrap();
332        let syms = structured["files"][0]["symbols"].as_array().unwrap();
333        assert!(syms.len() <= DEFAULT_MAX_SYMBOLS, "got {}", syms.len());
334        assert_eq!(structured["truncated"], true);
335    }
336}