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