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 =
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 Err(WinxError::FileNotFound { path: root.clone() })
255 }
256}
257
258fn 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
282fn ext_of(path: &Path) -> String {
284 path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_lowercase()
285}
286
287fn rel_of(path: &Path, workspace_root: &Path) -> String {
289 crate::utils::path::display_relative(path.strip_prefix(workspace_root).unwrap_or(path))
290}
291
292fn 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 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 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
434fn 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 files.sort_by(|a, b| a.1.cmp(&b.1));
455 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 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 let low = fitting_repo_symbol_prefix(&out, &out_files, rel, &syms, item_budget);
563
564 if low == 0 {
565 omitted_symbols = true;
566 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); 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 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 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}