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::{CodeMapFallback, 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, thread_id) = {
45        let guard = bash_state_arc.lock().await;
46        let bash_state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
47        (
48            bash_state.cwd.clone(),
49            bash_state.workspace_root.clone(),
50            bash_state.current_thread_id.clone(),
51        )
52    };
53
54    tokio::task::spawn_blocking(move || outline_with_paths(&args, &cwd, workspace_root, &thread_id))
55        .await
56        .map_err(|error| {
57            WinxError::CommandExecutionError(format!("CodeMap outline worker failed: {error}"))
58        })?
59}
60
61fn outline_with_paths(
62    args: &Outline,
63    cwd: &Path,
64    workspace_root: PathBuf,
65    thread_id: &str,
66) -> Result<(String, serde_json::Value)> {
67    let workspace_root = workspace_root.canonicalize().unwrap_or(workspace_root);
68    let root = resolve_in_workspace(&args.path, cwd, &workspace_root).map_err(|e| {
69        WinxError::PathSecurityError { path: PathBuf::from(&args.path), message: e.to_string() }
70    })?;
71
72    let mut context = TagsContext::new();
73    let mut configs: Configs = HashMap::new();
74
75    if root.is_file() {
76        outline_one(&root, &workspace_root, thread_id, args, &mut context, &mut configs)
77    } else if root.is_dir() {
78        outline_repo(&root, &workspace_root, args, &mut context, &mut configs)
79    } else {
80        // Don't silently degrade a typo'd file path into a whole-workspace scan.
81        Err(WinxError::FileAccessError {
82            path: root.clone(),
83            message: "path not found (or not a regular file/directory)".to_string(),
84        })
85    }
86}
87
88/// A `file`-mode result carrying only a status message (no symbols).
89fn empty_file_outline(
90    message: String,
91    extension: String,
92    language_supported: bool,
93    fallback: Option<CodeMapFallback>,
94) -> Result<(String, serde_json::Value)> {
95    let structured = OutlineOutput {
96        mode: "file".to_string(),
97        files_shown: 0,
98        files: Vec::new(),
99        truncated: false,
100        file_extension: Some(extension),
101        language_supported: Some(language_supported),
102        fallback,
103    };
104    Ok((message, crate::tools::structured_json(&structured)?))
105}
106
107/// Lowercase extension of `path` ("" if none).
108fn ext_of(path: &Path) -> String {
109    path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_lowercase()
110}
111
112/// Workspace-relative display path.
113fn rel_of(path: &Path, workspace_root: &Path) -> String {
114    path.strip_prefix(workspace_root).unwrap_or(path).to_string_lossy().to_string()
115}
116
117/// Compile-once-per-language cached config lookup.
118fn config_for<'a>(configs: &'a mut Configs, ext: &str) -> Option<&'a TagsConfiguration> {
119    configs.entry(ext.to_string()).or_insert_with(|| symbols::config_for(ext)).as_ref()
120}
121
122fn render_symbols(out: &mut String, syms: &[Symbol]) {
123    for s in syms {
124        let _ = writeln!(out, "  {:>5}  {:<9} {}", s.line, s.kind, s.name);
125    }
126}
127
128fn to_output(syms: Vec<Symbol>) -> Vec<OutlineSymbol> {
129    syms.into_iter().map(|s| OutlineSymbol { name: s.name, kind: s.kind, line: s.line }).collect()
130}
131
132fn outline_one(
133    file: &Path,
134    workspace_root: &Path,
135    thread_id: &str,
136    args: &Outline,
137    context: &mut TagsContext,
138    configs: &mut Configs,
139) -> Result<(String, serde_json::Value)> {
140    let rel = rel_of(file, workspace_root);
141    let max = if args.max_results == 0 { DEFAULT_MAX_SYMBOLS } else { args.max_results };
142    let ext = ext_of(file);
143
144    // Distinguish the real reasons we'd return no symbols instead of collapsing
145    // them all into a misleading "no definitions" (no silent fallback).
146    if !symbols::supports(&ext) {
147        let temporary_artifact_dir =
148            crate::utils::agent_temp::session_info(workspace_root, thread_id).directory;
149        let fallback = CodeMapFallback {
150            tool: "ReadFiles".to_string(),
151            file_paths: vec![file.to_string_lossy().into_owned()],
152            reason: "unsupported_language".to_string(),
153            temporary_artifact_dir: temporary_artifact_dir.to_string_lossy().into_owned(),
154        };
155        return empty_file_outline(
156            format!(
157            "No symbols in {rel}: unsupported language (extension `.{ext}`). Use ReadFiles for \
158             exact canonical source; do not transform source solely to make CodeMap parse it. A \
159             genuinely useful derived helper may live in temporary_artifact_dir with short names \
160             and source-path/line provenance."
161        ),
162            ext,
163            false,
164            Some(fallback),
165        );
166    }
167    let text = match read_file_to_string(file, MAX_OUTLINE_FILE_SIZE) {
168        Ok(text) => text,
169        Err(e) => {
170            return empty_file_outline(format!("Could not outline {rel}: {e}"), ext, true, None)
171        }
172    };
173    let Some(config) = config_for(configs, &ext) else {
174        let temporary_artifact_dir =
175            crate::utils::agent_temp::session_info(workspace_root, thread_id).directory;
176        return empty_file_outline(
177            format!("No symbols in {rel}: the `.{ext}` tags query could not be loaded."),
178            ext,
179            true,
180            Some(CodeMapFallback {
181                tool: "ReadFiles".to_string(),
182                file_paths: vec![file.to_string_lossy().into_owned()],
183                reason: "parser_unavailable".to_string(),
184                temporary_artifact_dir: temporary_artifact_dir.to_string_lossy().into_owned(),
185            }),
186        );
187    };
188
189    let mut syms = symbols::extract(context, config, &text);
190    let total = syms.len();
191    let truncated = total > max;
192    if truncated {
193        syms.truncate(max);
194    }
195
196    let mut out = String::new();
197    if syms.is_empty() {
198        return empty_file_outline(format!("No definitions found in {rel}."), ext, true, None);
199    }
200    if truncated {
201        let _ = writeln!(out, "{rel} ({} of {total} symbols):", syms.len());
202    } else {
203        let noun = if total == 1 { "symbol" } else { "symbols" };
204        let _ = writeln!(out, "{rel} ({total} {noun}):");
205    }
206    render_symbols(&mut out, &syms);
207    if truncated {
208        let _ = write!(out, "(...{} more; raise max_results)", total - syms.len());
209    }
210
211    let structured = OutlineOutput {
212        mode: "file".to_string(),
213        files_shown: 1,
214        files: vec![OutlineFile { file: rel, symbols: to_output(syms) }],
215        truncated,
216        file_extension: Some(ext),
217        language_supported: Some(true),
218        fallback: None,
219    };
220    Ok((out, crate::tools::structured_json(&structured)?))
221}
222
223/// Collect supported files under `root`, ranked best-first by the path-prob model
224/// (alphabetical fallback). Returns `(absolute, workspace-relative)` pairs.
225fn ranked_supported_files(root: &Path, workspace_root: &Path) -> Vec<(PathBuf, String)> {
226    let mut files: Vec<(PathBuf, String)> = walk_workspace_files(root)
227        .into_iter()
228        .filter(|abs| symbols::supports(&ext_of(abs)))
229        .map(|abs| {
230            let rel = rel_of(&abs, workspace_root);
231            (abs, rel)
232        })
233        .collect();
234
235    // Pre-sort alphabetically so equal-score ties resolve deterministically
236    // (stable sort keeps this order).
237    files.sort_by(|a, b| a.1.cmp(&b.1));
238    // Score the names borrowed as &str — no per-path String clone (score_paths is
239    // generic over AsRef<str>). Scope `rels` so its borrow of `files` ends before
240    // we move `files` below.
241    let ranking = {
242        let rels: Vec<&str> = files.iter().map(|(_, r)| r.as_str()).collect();
243        score_paths(&rels)
244    };
245    if let Some(weights) = ranking {
246        // Pair each file with its weight and sort by weight desc, MOVING the files
247        // (no per-entry clone of the old reindex). Stable sort preserves the
248        // alphabetical pre-sort for equal weights.
249        let mut pairs: Vec<(f64, (PathBuf, String))> = weights.into_iter().zip(files).collect();
250        pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
251        files = pairs.into_iter().map(|(_, f)| f).collect();
252    }
253    files
254}
255
256fn outline_repo(
257    root: &Path,
258    workspace_root: &Path,
259    args: &Outline,
260    context: &mut TagsContext,
261    configs: &mut Configs,
262) -> Result<(String, serde_json::Value)> {
263    let max_files = if args.max_results == 0 { DEFAULT_MAX_FILES } else { args.max_results };
264
265    let mut out = String::new();
266    let mut out_files: Vec<OutlineFile> = Vec::new();
267    let mut truncated = false;
268    let mut scanned = 0usize;
269
270    for (abs, rel) in ranked_supported_files(root, workspace_root) {
271        if out_files.len() >= max_files
272            || out.len() >= OUTLINE_MAX_BYTES
273            || scanned >= MAX_FILES_SCANNED
274        {
275            truncated = true;
276            break;
277        }
278        // read_file_to_string enforces the size cap and rejects non-UTF-8, so a
279        // separate metadata stat is redundant — skip on any read error.
280        let Ok(text) = read_file_to_string(&abs, MAX_OUTLINE_FILE_SIZE) else { continue };
281        scanned += 1;
282        let ext = ext_of(&abs);
283        let mut syms = match config_for(configs, &ext) {
284            Some(config) => symbols::extract(context, config, &text),
285            None => continue,
286        };
287        if syms.is_empty() {
288            continue;
289        }
290        // Per-file symbol cap: one definition-dense file (minified/generated)
291        // must not blow the budget or the structured array.
292        if syms.len() > DEFAULT_MAX_SYMBOLS {
293            syms.truncate(DEFAULT_MAX_SYMBOLS);
294            truncated = true;
295        }
296        let _ = writeln!(out, "{rel}");
297        render_symbols(&mut out, &syms);
298        out_files.push(OutlineFile { file: rel, symbols: to_output(syms) });
299    }
300
301    if out_files.is_empty() {
302        out = format!("No code symbols found under {}.", root.display());
303    } else if truncated {
304        let _ = write!(out, "(...capped; narrow `path` or raise max_results)");
305    }
306
307    let files_shown = out_files.len();
308    let structured = OutlineOutput {
309        mode: "repo".to_string(),
310        files_shown,
311        files: out_files,
312        truncated,
313        file_extension: None,
314        language_supported: None,
315        fallback: None,
316    };
317    Ok((out, crate::tools::structured_json(&structured)?))
318}
319
320#[cfg(test)]
321mod tests {
322    #![allow(clippy::unwrap_used)]
323    use super::*;
324    use tempfile::TempDir;
325
326    fn state_in(dir: &TempDir) -> Arc<Mutex<Option<BashState>>> {
327        let mut bs = BashState::new();
328        let root = dir.path().canonicalize().unwrap();
329        bs.cwd = root.clone();
330        bs.workspace_root = root;
331        Arc::new(Mutex::new(Some(bs)))
332    }
333
334    fn args(path: &str) -> Outline {
335        Outline { path: path.to_string(), max_results: 0, thread_id: String::new() }
336    }
337
338    #[tokio::test]
339    async fn single_file_lists_symbols() {
340        let dir = TempDir::new().unwrap();
341        std::fs::write(dir.path().join("a.rs"), "pub fn alpha() {}\nstruct Beta;\n").unwrap();
342        let st = state_in(&dir);
343        let (out, structured) = handle_tool_call(&st, args("a.rs")).await.unwrap();
344        assert!(out.contains("alpha"));
345        assert!(out.contains("Beta"));
346        assert_eq!(structured["mode"], "file");
347        let syms = structured["files"][0]["symbols"].as_array().unwrap();
348        assert!(syms.iter().any(|s| s["name"] == "alpha"));
349    }
350
351    #[tokio::test]
352    async fn repo_map_ranks_files() {
353        let dir = TempDir::new().unwrap();
354        std::fs::create_dir_all(dir.path().join("src")).unwrap();
355        std::fs::write(dir.path().join("src/lib.rs"), "pub fn one() {}\n").unwrap();
356        std::fs::write(dir.path().join("src/util.rs"), "fn two() {}\n").unwrap();
357        std::fs::write(dir.path().join("notes.txt"), "not code\n").unwrap();
358        let st = state_in(&dir);
359        let (out, structured) = handle_tool_call(&st, args("")).await.unwrap();
360        assert_eq!(structured["mode"], "repo");
361        assert_eq!(structured["files_shown"], 2); // only the 2 .rs files
362        assert!(out.contains("one"));
363        assert!(out.contains("two"));
364        assert!(!out.contains("notes.txt"));
365    }
366
367    #[tokio::test]
368    async fn no_symbols_message() {
369        let dir = TempDir::new().unwrap();
370        std::fs::write(dir.path().join("data.json"), "{\"a\":1}\n").unwrap();
371        let st = state_in(&dir);
372        let (out, structured) = handle_tool_call(&st, args("data.json")).await.unwrap();
373        assert!(out.to_lowercase().contains("no symbols"));
374        assert_eq!(structured["files_shown"], 0);
375    }
376
377    #[tokio::test]
378    async fn unsupported_language_returns_actionable_exact_source_fallback() {
379        let dir = TempDir::new().unwrap();
380        std::fs::write(dir.path().join("page.heex"), "<p><%= @name %></p>\n").unwrap();
381        let st = state_in(&dir);
382        let (out, structured) = handle_tool_call(&st, args("page.heex")).await.unwrap();
383        assert!(out.contains("unsupported language"));
384        assert!(out.contains("ReadFiles"));
385        assert!(out.contains("do not transform source solely"));
386        assert_eq!(structured["files_shown"], 0);
387        assert_eq!(structured["file_extension"], "heex");
388        assert_eq!(structured["language_supported"], false);
389        assert_eq!(structured["fallback"]["tool"], "ReadFiles");
390        assert_eq!(structured["fallback"]["reason"], "unsupported_language");
391        assert_eq!(
392            structured["fallback"]["file_paths"][0],
393            dir.path().join("page.heex").canonicalize().unwrap().to_string_lossy().as_ref()
394        );
395        assert!(structured["fallback"]["temporary_artifact_dir"]
396            .as_str()
397            .is_some_and(|path| path.contains("/.winx/tmp/session-")));
398    }
399
400    #[tokio::test]
401    async fn python_and_elixir_are_native_code_map_languages() {
402        let dir = TempDir::new().unwrap();
403        std::fs::write(
404            dir.path().join("service.py"),
405            "class Service:\n    def run(self):\n        pass\n",
406        )
407        .unwrap();
408        std::fs::write(
409            dir.path().join("worker.ex"),
410            "defmodule Worker do\n  def run(), do: :ok\nend\n",
411        )
412        .unwrap();
413        let st = state_in(&dir);
414
415        let (python, python_data) = handle_tool_call(&st, args("service.py")).await.unwrap();
416        assert!(python.contains("Service"), "{python}");
417        assert!(python.contains("run"), "{python}");
418        assert_eq!(python_data["language_supported"], true);
419
420        let (elixir, elixir_data) = handle_tool_call(&st, args("worker.ex")).await.unwrap();
421        assert!(elixir.contains("Worker"), "{elixir}");
422        assert!(elixir.contains("run"), "{elixir}");
423        assert_eq!(elixir_data["language_supported"], true);
424    }
425
426    #[tokio::test]
427    async fn repo_map_prunes_winx_but_explicit_helper_outline_remains_available() {
428        let dir = TempDir::new().unwrap();
429        let root = dir.path().canonicalize().unwrap();
430        std::fs::write(root.join("main.py"), "def canonical():\n    pass\n").unwrap();
431        let helper = crate::utils::agent_temp::session_info(&root, "helper-session")
432            .directory
433            .join("review_adapter.py");
434        std::fs::create_dir_all(helper.parent().unwrap()).unwrap();
435        std::fs::write(&helper, "def derived_review():\n    pass\n").unwrap();
436        let st = state_in(&dir);
437
438        let (repo, _) = handle_tool_call(&st, args("")).await.unwrap();
439        assert!(repo.contains("canonical"), "{repo}");
440        assert!(!repo.contains("derived_review"), "{repo}");
441
442        let helper_relative = helper.strip_prefix(&root).unwrap().to_string_lossy();
443        let (explicit, _) = handle_tool_call(&st, args(&helper_relative)).await.unwrap();
444        assert!(explicit.contains("derived_review"), "{explicit}");
445    }
446
447    #[tokio::test]
448    async fn nonexistent_path_errors_instead_of_repo_scan() {
449        // Regression: a typo'd file path must error, not silently become an empty
450        // whole-workspace scan.
451        let dir = TempDir::new().unwrap();
452        std::fs::write(dir.path().join("real.rs"), "fn x() {}\n").unwrap();
453        let st = state_in(&dir);
454        assert!(handle_tool_call(&st, args("nope_typo.rs")).await.is_err());
455    }
456
457    #[tokio::test]
458    async fn repo_per_file_cap_marks_truncated() {
459        // Regression (B1): a definition-dense file must be clipped and reported
460        // as truncated, not blow the budget while claiming completeness.
461        let dir = TempDir::new().unwrap();
462        let mut src = String::new();
463        for i in 0..(DEFAULT_MAX_SYMBOLS + 100) {
464            let _ = writeln!(src, "fn f{i}() {{}}");
465        }
466        std::fs::write(dir.path().join("big.rs"), src).unwrap();
467        let st = state_in(&dir);
468        let (_, structured) = handle_tool_call(&st, args("")).await.unwrap();
469        let syms = structured["files"][0]["symbols"].as_array().unwrap();
470        assert!(syms.len() <= DEFAULT_MAX_SYMBOLS, "got {}", syms.len());
471        assert_eq!(structured["truncated"], true);
472    }
473}