1use 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
31const DEFAULT_MAX_FILES: usize = 20;
33const DEFAULT_MAX_SYMBOLS: usize = 250;
35const REPO_MAX_SYMBOLS_PER_FILE: usize = 64;
37const MAX_OUTLINE_FILE_SIZE: u64 = 2_000_000;
39pub(crate) const CODE_MAP_PAYLOAD_MAX_BYTES: usize = 44 * 1024;
42const CODE_MAP_PAYLOAD_FIXED_RESERVE: usize = 1_024;
44const 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 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 = workspace_root.canonicalize().unwrap_or(workspace_root);
227 let root = resolve_in_workspace(&args.path, cwd, &workspace_root).map_err(|e| {
228 WinxError::PathSecurityError { path: PathBuf::from(&args.path), message: e.to_string() }
229 })?;
230
231 let mut context = TagsContext::new();
232 let mut configs: Configs = HashMap::new();
233
234 if root.is_file() {
235 if !args.cursor.is_empty() {
236 return Err(WinxError::ArgumentParseError(
237 "CodeMap cursor is valid only for directory outlines".to_string(),
238 ));
239 }
240 outline_one(
241 &root,
242 &workspace_root,
243 thread_id,
244 args,
245 &mut context,
246 &mut configs,
247 payload_max_bytes,
248 )
249 } else if root.is_dir() {
250 outline_repo(&root, &workspace_root, args, &mut context, &mut configs, payload_max_bytes)
251 } else {
252 Err(WinxError::FileAccessError {
254 path: root.clone(),
255 message: "path not found (or not a regular file/directory)".to_string(),
256 })
257 }
258}
259
260fn empty_file_outline(
262 message: String,
263 extension: String,
264 language_supported: bool,
265 fallback: Option<CodeMapFallback>,
266) -> Result<(String, serde_json::Value)> {
267 let structured = OutlineOutput {
268 mode: "file".to_string(),
269 files_shown: 0,
270 files: Vec::new(),
271 truncated: false,
272 next_cursor: None,
273 snapshot_hash: None,
274 files_scanned: 1,
275 payload_bytes: 0,
276 file_extension: Some(extension),
277 language_supported: Some(language_supported),
278 fallback,
279 };
280 let value = finalize_output(&message, structured)?;
281 Ok((message, value))
282}
283
284fn ext_of(path: &Path) -> String {
286 path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_lowercase()
287}
288
289fn rel_of(path: &Path, workspace_root: &Path) -> String {
291 path.strip_prefix(workspace_root).unwrap_or(path).to_string_lossy().to_string()
292}
293
294fn config_for<'a>(configs: &'a mut Configs, ext: &str) -> Option<&'a TagsConfiguration> {
296 configs.entry(ext.to_string()).or_insert_with(|| symbols::config_for(ext)).as_ref()
297}
298
299fn render_symbols(out: &mut String, syms: &[Symbol]) {
300 for s in syms {
301 let _ = writeln!(out, " {:>5} {:<9} {}", s.line, s.kind, s.name);
302 }
303}
304
305fn render_file_outline(rel: &str, total: usize, syms: &[Symbol]) -> String {
306 let mut out = String::new();
307 if syms.len() < total {
308 let _ = writeln!(out, "{rel} ({} of {total} symbols):", syms.len());
309 } else {
310 let noun = if total == 1 { "symbol" } else { "symbols" };
311 let _ = writeln!(out, "{rel} ({total} {noun}):");
312 }
313 render_symbols(&mut out, syms);
314 if syms.len() < total {
315 let _ = write!(out, "(...{} more; narrow path or raise max_results)", total - syms.len());
316 }
317 out
318}
319
320fn to_output(syms: Vec<Symbol>) -> Vec<OutlineSymbol> {
321 syms.into_iter().map(|s| OutlineSymbol { name: s.name, kind: s.kind, line: s.line }).collect()
322}
323
324fn outline_one(
325 file: &Path,
326 workspace_root: &Path,
327 thread_id: &str,
328 args: &Outline,
329 context: &mut TagsContext,
330 configs: &mut Configs,
331 payload_max_bytes: usize,
332) -> Result<(String, serde_json::Value)> {
333 let rel = rel_of(file, workspace_root);
334 let max = if args.max_results == 0 { DEFAULT_MAX_SYMBOLS } else { args.max_results };
335 let ext = ext_of(file);
336
337 if !symbols::supports(&ext) {
340 let temporary_artifact_dir =
341 crate::utils::agent_temp::session_info(workspace_root, thread_id).directory;
342 let fallback = CodeMapFallback {
343 tool: "ReadFiles".to_string(),
344 file_paths: vec![file.to_string_lossy().into_owned()],
345 reason: "unsupported_language".to_string(),
346 temporary_artifact_dir: temporary_artifact_dir.to_string_lossy().into_owned(),
347 };
348 return empty_file_outline(
349 format!(
350 "No symbols in {rel}: unsupported language (extension `.{ext}`). Use ReadFiles for \
351 exact canonical source; do not transform source solely to make CodeMap parse it. A \
352 genuinely useful derived helper may live in temporary_artifact_dir with short names \
353 and source-path/line provenance."
354 ),
355 ext,
356 false,
357 Some(fallback),
358 );
359 }
360 let text = match read_file_to_string(file, MAX_OUTLINE_FILE_SIZE) {
361 Ok(text) => text,
362 Err(e) => {
363 return empty_file_outline(format!("Could not outline {rel}: {e}"), ext, true, None)
364 }
365 };
366 let Some(config) = config_for(configs, &ext) else {
367 let temporary_artifact_dir =
368 crate::utils::agent_temp::session_info(workspace_root, thread_id).directory;
369 return empty_file_outline(
370 format!("No symbols in {rel}: the `.{ext}` tags query could not be loaded."),
371 ext,
372 true,
373 Some(CodeMapFallback {
374 tool: "ReadFiles".to_string(),
375 file_paths: vec![file.to_string_lossy().into_owned()],
376 reason: "parser_unavailable".to_string(),
377 temporary_artifact_dir: temporary_artifact_dir.to_string_lossy().into_owned(),
378 }),
379 );
380 };
381
382 let syms = symbols::extract(context, config, &text);
383 let total = syms.len();
384 if syms.is_empty() {
385 return empty_file_outline(format!("No definitions found in {rel}."), ext, true, None);
386 }
387
388 let upper = total.min(max);
392 let mut low = 0usize;
393 let mut high = upper;
394 while low < high {
395 let middle = low + (high - low).div_ceil(2);
396 let selected = &syms[..middle];
397 let candidate_text = render_file_outline(&rel, total, selected);
398 let candidate_files =
399 vec![OutlineFile { file: rel.clone(), symbols: to_output(selected.to_vec()) }];
400 if navigation_payload_bytes(&candidate_text, &candidate_files)
401 <= payload_max_bytes.saturating_sub(CODE_MAP_PAYLOAD_FIXED_RESERVE)
402 {
403 low = middle;
404 } else {
405 high = middle - 1;
406 }
407 }
408
409 let selected = &syms[..low];
410 let truncated = low < total;
411 let out = if selected.is_empty() {
412 format!(
413 "{total} definitions found in {rel}, but the first symbol exceeds the CodeMap response budget. Use ReadFiles for exact source."
414 )
415 } else {
416 render_file_outline(&rel, total, selected)
417 };
418
419 let structured = OutlineOutput {
420 mode: "file".to_string(),
421 files_shown: 1,
422 files: vec![OutlineFile { file: rel, symbols: to_output(selected.to_vec()) }],
423 truncated,
424 next_cursor: None,
425 snapshot_hash: None,
426 files_scanned: 1,
427 payload_bytes: 0,
428 file_extension: Some(ext),
429 language_supported: Some(true),
430 fallback: None,
431 };
432 let value = finalize_output(&out, structured)?;
433 Ok((out, value))
434}
435
436fn ranked_supported_files(
440 root: &Path,
441 workspace_root: &Path,
442 query: &str,
443 active_paths: &[String],
444) -> Vec<(PathBuf, String)> {
445 let mut files: Vec<(PathBuf, String)> = walk_workspace_files(root)
446 .into_iter()
447 .filter(|abs| symbols::supports(&ext_of(abs)))
448 .map(|abs| {
449 let rel = rel_of(&abs, workspace_root);
450 (abs, rel)
451 })
452 .collect();
453
454 files.sort_by(|a, b| a.1.cmp(&b.1));
457 let path_ranking = {
461 let rels: Vec<&str> = files.iter().map(|(_, r)| r.as_str()).collect();
462 score_paths(&rels)
463 }
464 .unwrap_or_else(|| vec![0.0; files.len()]);
465 let terms = query_terms(query);
466 let mut ranked = path_ranking
467 .into_iter()
468 .zip(files)
469 .map(|(path_score, file)| {
470 let context_score = contextual_path_score(&file.1, &terms, active_paths);
471 (context_score, path_score, file)
472 })
473 .collect::<Vec<_>>();
474 ranked.sort_by(|left, right| {
475 right
476 .0
477 .cmp(&left.0)
478 .then_with(|| right.1.partial_cmp(&left.1).unwrap_or(std::cmp::Ordering::Equal))
479 .then_with(|| left.2 .1.cmp(&right.2 .1))
480 });
481 ranked.into_iter().map(|(_, _, file)| file).collect()
482}
483
484fn fitting_repo_symbol_prefix(
485 current_text: &str,
486 current_files: &[OutlineFile],
487 rel: &str,
488 symbols: &[Symbol],
489 item_budget: usize,
490) -> usize {
491 let mut low = 0usize;
492 let mut high = symbols.len();
493 while low < high {
494 let middle = low + (high - low).div_ceil(2);
495 let selected = &symbols[..middle];
496 let mut chunk = String::new();
497 let _ = writeln!(chunk, "{rel}");
498 render_symbols(&mut chunk, selected);
499 let mut candidate_text = current_text.to_string();
500 candidate_text.push_str(&chunk);
501 let mut candidate_files = current_files.to_vec();
502 candidate_files
503 .push(OutlineFile { file: rel.to_string(), symbols: to_output(selected.to_vec()) });
504 if navigation_payload_bytes(&candidate_text, &candidate_files) <= item_budget {
505 low = middle;
506 } else {
507 high = middle - 1;
508 }
509 }
510 low
511}
512
513fn outline_repo(
514 root: &Path,
515 workspace_root: &Path,
516 args: &Outline,
517 context: &mut TagsContext,
518 configs: &mut Configs,
519 payload_max_bytes: usize,
520) -> Result<(String, serde_json::Value)> {
521 let max_files = if args.max_results == 0 { DEFAULT_MAX_FILES } else { args.max_results };
522 let active_paths = active_files_for_context(workspace_root);
523 let ranked = ranked_supported_files(root, workspace_root, &args.query, &active_paths);
524 let snapshot_hash = repo_snapshot_hash(root, &ranked, &args.query);
525 let start_offset = repo_cursor_offset(&args.cursor, &snapshot_hash, ranked.len())?;
526
527 let item_budget = payload_max_bytes.saturating_sub(CODE_MAP_PAYLOAD_FIXED_RESERVE);
528 let mut out = String::new();
529 if start_offset > 0 {
530 let _ = writeln!(out, "Code map continuation from ranked offset {start_offset}:");
531 }
532 let mut out_files: Vec<OutlineFile> = Vec::new();
533 let mut omitted_symbols = false;
534 let mut files_scanned = 0usize;
535 let mut next_offset = start_offset;
536
537 for (index, (abs, rel)) in ranked.iter().enumerate().skip(start_offset) {
538 if out_files.len() >= max_files || files_scanned >= MAX_FILES_SCANNED {
539 next_offset = index;
540 break;
541 }
542 files_scanned += 1;
545 next_offset = index + 1;
546 let Ok(text) = read_file_to_string(abs, MAX_OUTLINE_FILE_SIZE) else { continue };
547 let ext = ext_of(abs);
548 let mut syms = match config_for(configs, &ext) {
549 Some(config) => symbols::extract(context, config, &text),
550 None => continue,
551 };
552 if syms.is_empty() {
553 continue;
554 }
555 let total_symbols = syms.len();
556 if syms.len() > REPO_MAX_SYMBOLS_PER_FILE {
557 syms.truncate(REPO_MAX_SYMBOLS_PER_FILE);
558 omitted_symbols = true;
559 }
560
561 let low = fitting_repo_symbol_prefix(&out, &out_files, rel, &syms, item_budget);
565
566 if low == 0 {
567 omitted_symbols = true;
568 if !out_files.is_empty() {
572 next_offset = index;
573 break;
574 }
575 continue;
576 }
577
578 let selected = &syms[..low];
579 let _ = writeln!(out, "{rel}");
580 render_symbols(&mut out, selected);
581 out_files.push(OutlineFile { file: rel.clone(), symbols: to_output(selected.to_vec()) });
582 if low < total_symbols {
583 omitted_symbols = true;
584 }
585
586 if low < syms.len() {
587 break;
588 }
589 }
590
591 let next_cursor = if next_offset < ranked.len() {
592 Some(encode_cursor(next_offset, &snapshot_hash)?)
593 } else {
594 None
595 };
596 let truncated = omitted_symbols || next_cursor.is_some();
597 if out_files.is_empty() {
598 out = format!("No code symbols found under {}.", root.display());
599 } else if truncated {
600 if let Some(cursor) = next_cursor.as_deref() {
601 let _ =
602 write!(out, "(...capped; continue with cursor `{cursor}`, or narrow path/query)");
603 } else {
604 let _ = write!(out, "(...dense files capped; outline a specific file for more)");
605 }
606 }
607
608 let files_shown = out_files.len();
609 let structured = OutlineOutput {
610 mode: "repo".to_string(),
611 files_shown,
612 files: out_files,
613 truncated,
614 next_cursor,
615 snapshot_hash: Some(snapshot_hash),
616 files_scanned,
617 payload_bytes: 0,
618 file_extension: None,
619 language_supported: None,
620 fallback: None,
621 };
622 let value = finalize_output(&out, structured)?;
623 Ok((out, value))
624}
625
626#[cfg(test)]
627mod tests {
628 #![allow(clippy::unwrap_used)]
629 use super::*;
630 use tempfile::TempDir;
631
632 fn state_in(dir: &TempDir) -> Arc<Mutex<Option<BashState>>> {
633 let mut bs = BashState::new();
634 let root = dir.path().canonicalize().unwrap();
635 bs.cwd = root.clone();
636 bs.workspace_root = root;
637 Arc::new(Mutex::new(Some(bs)))
638 }
639
640 fn args(path: &str) -> Outline {
641 Outline {
642 path: path.to_string(),
643 max_results: 0,
644 query: String::new(),
645 cursor: String::new(),
646 thread_id: String::new(),
647 }
648 }
649
650 #[tokio::test]
651 async fn single_file_lists_symbols() {
652 let dir = TempDir::new().unwrap();
653 std::fs::write(dir.path().join("a.rs"), "pub fn alpha() {}\nstruct Beta;\n").unwrap();
654 let st = state_in(&dir);
655 let (out, structured) = handle_tool_call(&st, args("a.rs")).await.unwrap();
656 assert!(out.contains("alpha"));
657 assert!(out.contains("Beta"));
658 assert_eq!(structured["mode"], "file");
659 let syms = structured["files"][0]["symbols"].as_array().unwrap();
660 assert!(syms.iter().any(|s| s["name"] == "alpha"));
661 }
662
663 #[tokio::test]
664 async fn repo_map_ranks_files() {
665 let dir = TempDir::new().unwrap();
666 std::fs::create_dir_all(dir.path().join("src")).unwrap();
667 std::fs::write(dir.path().join("src/lib.rs"), "pub fn one() {}\n").unwrap();
668 std::fs::write(dir.path().join("src/util.rs"), "fn two() {}\n").unwrap();
669 std::fs::write(dir.path().join("notes.txt"), "not code\n").unwrap();
670 let st = state_in(&dir);
671 let (out, structured) = handle_tool_call(&st, args("")).await.unwrap();
672 assert_eq!(structured["mode"], "repo");
673 assert_eq!(structured["files_shown"], 2); assert!(out.contains("one"));
675 assert!(out.contains("two"));
676 assert!(!out.contains("notes.txt"));
677 }
678
679 #[tokio::test]
680 async fn repo_map_query_focuses_the_first_page() {
681 let dir = TempDir::new().unwrap();
682 std::fs::create_dir_all(dir.path().join("src")).unwrap();
683 std::fs::write(dir.path().join("src/alpha.rs"), "fn alpha() {}\n").unwrap();
684 std::fs::write(dir.path().join("src/billing.rs"), "fn invoice() {}\n").unwrap();
685 let st = state_in(&dir);
686 let mut focused = args("");
687 focused.max_results = 1;
688 focused.query = "billing invoice".to_string();
689
690 let (_, structured) = handle_tool_call(&st, focused).await.unwrap();
691
692 assert_eq!(structured["files"][0]["file"], "src/billing.rs");
693 assert!(structured["next_cursor"].is_string());
694 }
695
696 #[tokio::test]
697 async fn repo_map_cursor_continues_without_repeating_files() {
698 let dir = TempDir::new().unwrap();
699 for name in ["alpha", "beta", "gamma"] {
700 std::fs::write(dir.path().join(format!("{name}.rs")), format!("fn {name}() {{}}\n"))
701 .unwrap();
702 }
703 let st = state_in(&dir);
704 let mut first_args = args("");
705 first_args.max_results = 1;
706 let (_, first) = handle_tool_call(&st, first_args).await.unwrap();
707 let first_file = first["files"][0]["file"].as_str().unwrap().to_string();
708 let cursor = first["next_cursor"].as_str().unwrap().to_string();
709
710 let mut second_args = args("");
711 second_args.max_results = 1;
712 second_args.cursor = cursor;
713 let (_, second) = handle_tool_call(&st, second_args).await.unwrap();
714
715 assert_ne!(second["files"][0]["file"], first_file);
716 assert_eq!(second["snapshot_hash"], first["snapshot_hash"]);
717 }
718
719 #[tokio::test]
720 async fn repo_map_rejects_a_cursor_after_snapshot_change() {
721 let dir = TempDir::new().unwrap();
722 std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
723 std::fs::write(dir.path().join("b.rs"), "fn b() {}\n").unwrap();
724 let st = state_in(&dir);
725 let mut first_args = args("");
726 first_args.max_results = 1;
727 let (_, first) = handle_tool_call(&st, first_args).await.unwrap();
728 let cursor = first["next_cursor"].as_str().unwrap().to_string();
729 std::fs::write(dir.path().join("c.rs"), "fn c() {}\n").unwrap();
730
731 let mut stale = args("");
732 stale.cursor = cursor;
733 let error = handle_tool_call(&st, stale).await.unwrap_err();
734
735 assert!(error.to_string().contains("cursor is stale"), "{error}");
736 }
737
738 #[tokio::test]
739 async fn no_symbols_message() {
740 let dir = TempDir::new().unwrap();
741 std::fs::write(dir.path().join("data.json"), "{\"a\":1}\n").unwrap();
742 let st = state_in(&dir);
743 let (out, structured) = handle_tool_call(&st, args("data.json")).await.unwrap();
744 assert!(out.to_lowercase().contains("no symbols"));
745 assert_eq!(structured["files_shown"], 0);
746 }
747
748 #[tokio::test]
749 async fn unsupported_language_returns_actionable_exact_source_fallback() {
750 let dir = TempDir::new().unwrap();
751 std::fs::write(dir.path().join("page.heex"), "<p><%= @name %></p>\n").unwrap();
752 let st = state_in(&dir);
753 let (out, structured) = handle_tool_call(&st, args("page.heex")).await.unwrap();
754 assert!(out.contains("unsupported language"));
755 assert!(out.contains("ReadFiles"));
756 assert!(out.contains("do not transform source solely"));
757 assert_eq!(structured["files_shown"], 0);
758 assert_eq!(structured["file_extension"], "heex");
759 assert_eq!(structured["language_supported"], false);
760 assert_eq!(structured["fallback"]["tool"], "ReadFiles");
761 assert_eq!(structured["fallback"]["reason"], "unsupported_language");
762 assert_eq!(
763 structured["fallback"]["file_paths"][0],
764 dir.path().join("page.heex").canonicalize().unwrap().to_string_lossy().as_ref()
765 );
766 assert!(structured["fallback"]["temporary_artifact_dir"]
767 .as_str()
768 .is_some_and(|path| path.contains("/.winx/tmp/session-")));
769 }
770
771 #[tokio::test]
772 async fn python_and_elixir_are_native_code_map_languages() {
773 let dir = TempDir::new().unwrap();
774 std::fs::write(
775 dir.path().join("service.py"),
776 "class Service:\n def run(self):\n pass\n",
777 )
778 .unwrap();
779 std::fs::write(
780 dir.path().join("worker.ex"),
781 "defmodule Worker do\n def run(), do: :ok\nend\n",
782 )
783 .unwrap();
784 let st = state_in(&dir);
785
786 let (python, python_data) = handle_tool_call(&st, args("service.py")).await.unwrap();
787 assert!(python.contains("Service"), "{python}");
788 assert!(python.contains("run"), "{python}");
789 assert_eq!(python_data["language_supported"], true);
790
791 let (elixir, elixir_data) = handle_tool_call(&st, args("worker.ex")).await.unwrap();
792 assert!(elixir.contains("Worker"), "{elixir}");
793 assert!(elixir.contains("run"), "{elixir}");
794 assert_eq!(elixir_data["language_supported"], true);
795 }
796
797 #[tokio::test]
798 async fn repo_map_prunes_winx_but_explicit_helper_outline_remains_available() {
799 let dir = TempDir::new().unwrap();
800 let root = dir.path().canonicalize().unwrap();
801 std::fs::write(root.join("main.py"), "def canonical():\n pass\n").unwrap();
802 let helper = crate::utils::agent_temp::session_info(&root, "helper-session")
803 .directory
804 .join("review_adapter.py");
805 std::fs::create_dir_all(helper.parent().unwrap()).unwrap();
806 std::fs::write(&helper, "def derived_review():\n pass\n").unwrap();
807 let st = state_in(&dir);
808
809 let (repo, _) = handle_tool_call(&st, args("")).await.unwrap();
810 assert!(repo.contains("canonical"), "{repo}");
811 assert!(!repo.contains("derived_review"), "{repo}");
812
813 let helper_relative = helper.strip_prefix(&root).unwrap().to_string_lossy();
814 let (explicit, _) = handle_tool_call(&st, args(&helper_relative)).await.unwrap();
815 assert!(explicit.contains("derived_review"), "{explicit}");
816 }
817
818 #[tokio::test]
819 async fn nonexistent_path_errors_instead_of_repo_scan() {
820 let dir = TempDir::new().unwrap();
823 std::fs::write(dir.path().join("real.rs"), "fn x() {}\n").unwrap();
824 let st = state_in(&dir);
825 assert!(handle_tool_call(&st, args("nope_typo.rs")).await.is_err());
826 }
827
828 #[tokio::test]
829 async fn repo_per_file_cap_marks_truncated() {
830 let dir = TempDir::new().unwrap();
833 let mut src = String::new();
834 for i in 0..(DEFAULT_MAX_SYMBOLS + 100) {
835 let _ = writeln!(src, "fn f{i}() {{}}");
836 }
837 std::fs::write(dir.path().join("big.rs"), src).unwrap();
838 let st = state_in(&dir);
839 let (_, structured) = handle_tool_call(&st, args("")).await.unwrap();
840 let syms = structured["files"][0]["symbols"].as_array().unwrap();
841 assert!(syms.len() <= REPO_MAX_SYMBOLS_PER_FILE, "got {}", syms.len());
842 assert_eq!(structured["truncated"], true);
843 assert!(
844 usize::try_from(structured["payload_bytes"].as_u64().unwrap())
845 .is_ok_and(|bytes| bytes <= CODE_MAP_PAYLOAD_MAX_BYTES),
846 "{structured}"
847 );
848 }
849}