1use 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
26const DEFAULT_MAX_FILES: usize = 50;
28const DEFAULT_MAX_SYMBOLS: usize = 500;
30const MAX_OUTLINE_FILE_SIZE: u64 = 2_000_000;
32const OUTLINE_MAX_BYTES: usize = 24_000 * 4;
34const 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 Err(WinxError::FileAccessError {
64 path: root.clone(),
65 message: "path not found (or not a regular file/directory)".to_string(),
66 })
67 }
68}
69
70fn 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
81fn ext_of(path: &Path) -> String {
83 path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_lowercase()
84}
85
86fn rel_of(path: &Path, workspace_root: &Path) -> String {
88 path.strip_prefix(workspace_root).unwrap_or(path).to_string_lossy().to_string()
89}
90
91fn 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 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
163fn 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 files.sort_by(|a, b| a.1.cmp(&b.1));
178 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 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 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 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 std::fmt::Write as _;
258 use tempfile::TempDir;
259
260 fn state_in(dir: &TempDir) -> Arc<Mutex<Option<BashState>>> {
261 let mut bs = BashState::new();
262 let root = dir.path().canonicalize().unwrap();
263 bs.cwd = root.clone();
264 bs.workspace_root = root;
265 Arc::new(Mutex::new(Some(bs)))
266 }
267
268 fn args(path: &str) -> Outline {
269 Outline { path: path.to_string(), max_results: 0, thread_id: String::new() }
270 }
271
272 #[tokio::test]
273 async fn single_file_lists_symbols() {
274 let dir = TempDir::new().unwrap();
275 std::fs::write(dir.path().join("a.rs"), "pub fn alpha() {}\nstruct Beta;\n").unwrap();
276 let st = state_in(&dir);
277 let (out, structured) = handle_tool_call(&st, args("a.rs")).await.unwrap();
278 assert!(out.contains("alpha"));
279 assert!(out.contains("Beta"));
280 assert_eq!(structured["mode"], "file");
281 let syms = structured["files"][0]["symbols"].as_array().unwrap();
282 assert!(syms.iter().any(|s| s["name"] == "alpha"));
283 }
284
285 #[tokio::test]
286 async fn repo_map_ranks_files() {
287 let dir = TempDir::new().unwrap();
288 std::fs::create_dir_all(dir.path().join("src")).unwrap();
289 std::fs::write(dir.path().join("src/lib.rs"), "pub fn one() {}\n").unwrap();
290 std::fs::write(dir.path().join("src/util.rs"), "fn two() {}\n").unwrap();
291 std::fs::write(dir.path().join("notes.txt"), "not code\n").unwrap();
292 let st = state_in(&dir);
293 let (out, structured) = handle_tool_call(&st, args("")).await.unwrap();
294 assert_eq!(structured["mode"], "repo");
295 assert_eq!(structured["files_shown"], 2); assert!(out.contains("one"));
297 assert!(out.contains("two"));
298 assert!(!out.contains("notes.txt"));
299 }
300
301 #[tokio::test]
302 async fn no_symbols_message() {
303 let dir = TempDir::new().unwrap();
304 std::fs::write(dir.path().join("data.json"), "{\"a\":1}\n").unwrap();
305 let st = state_in(&dir);
306 let (out, structured) = handle_tool_call(&st, args("data.json")).await.unwrap();
307 assert!(out.to_lowercase().contains("no symbols"));
308 assert_eq!(structured["files_shown"], 0);
309 }
310
311 #[tokio::test]
312 async fn nonexistent_path_errors_instead_of_repo_scan() {
313 let dir = TempDir::new().unwrap();
316 std::fs::write(dir.path().join("real.rs"), "fn x() {}\n").unwrap();
317 let st = state_in(&dir);
318 assert!(handle_tool_call(&st, args("nope_typo.rs")).await.is_err());
319 }
320
321 #[tokio::test]
322 async fn repo_per_file_cap_marks_truncated() {
323 let dir = TempDir::new().unwrap();
326 let mut src = String::new();
327 for i in 0..(DEFAULT_MAX_SYMBOLS + 100) {
328 let _ = writeln!(src, "fn f{i}() {{}}");
329 }
330 std::fs::write(dir.path().join("big.rs"), src).unwrap();
331 let st = state_in(&dir);
332 let (_, structured) = handle_tool_call(&st, args("")).await.unwrap();
333 let syms = structured["files"][0]["symbols"].as_array().unwrap();
334 assert!(syms.len() <= DEFAULT_MAX_SYMBOLS, "got {}", syms.len());
335 assert_eq!(structured["truncated"], true);
336 }
337}