1use serde::Serialize;
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::fmt::Write as FmtWrite;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use tokio::sync::Mutex;
14use tracing::{debug, instrument};
15
16use crate::errors::{ErrorRecovery, Result, WinxError};
17use crate::state::bash_state::BashState;
18use crate::types::ReadFiles;
19use crate::utils::mmap::read_file_to_string;
20use crate::utils::path::{expand_user, validate_path_in_workspace};
21
22const CODING_MAX_TOKENS: usize = 24_000;
24const NONCODING_MAX_TOKENS: usize = 8_000;
25const DEFAULT_READ_PARALLELISM: usize = 4;
26const MAX_READ_PARALLELISM: usize = 32;
27
28type ReadCoverage = (Vec<(usize, usize)>, String, usize);
29
30#[derive(Debug)]
31struct FileReadResult {
32 content: String,
33 truncated: bool,
34 canonical_path: String,
35 visible_range: (usize, usize),
36 file_hash: String,
37 total_lines: usize,
38}
39
40#[derive(Clone, Debug, Serialize)]
43#[serde(rename_all = "camelCase")]
44pub struct ReadFileRevision {
45 pub path: String,
46 pub revision: String,
47 pub total_lines: usize,
48 pub visible_ranges: Vec<ReadLineRange>,
49 pub truncated: bool,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub next_start_line: Option<usize>,
52 #[serde(rename = "temporary_artifact", skip_serializing_if = "Option::is_none")]
53 pub temporary_artifact: Option<crate::utils::agent_temp::release::TemporaryArtifactMetadata>,
54}
55
56#[derive(Clone, Copy, Debug, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct ReadLineRange {
59 pub start_line: usize,
60 pub end_line: usize,
61}
62
63#[derive(Clone)]
64struct FileReadRequest {
65 index: usize,
66 requested_path: String,
67 clean_path: String,
68 start_line_num: Option<usize>,
69 end_line_num: Option<usize>,
70}
71
72#[derive(Default)]
73struct ReadBatchState {
74 message: String,
75 file_ranges: HashMap<String, ReadCoverage>,
76 stats_paths: Vec<PathBuf>,
77 successful_files: usize,
78 revisions: Vec<ReadFileRevision>,
79 errors: Vec<WinxError>,
80}
81
82impl ReadBatchState {
83 fn record_result(
86 &mut self,
87 request: &FileReadRequest,
88 result: Result<FileReadResult>,
89 total_files: usize,
90 ) -> bool {
91 match result {
92 Ok(result) => {
93 let FileReadResult {
94 content,
95 truncated,
96 canonical_path,
97 visible_range,
98 file_hash,
99 total_lines,
100 } = result;
101 self.successful_files = self.successful_files.saturating_add(1);
102 let revision = revision_from_hash(&file_hash);
103 let entry = self
104 .file_ranges
105 .entry(canonical_path.clone())
106 .or_insert_with(|| (Vec::new(), file_hash.clone(), total_lines));
107 if entry.1 != file_hash || entry.2 != total_lines {
108 *entry = (Vec::new(), file_hash, total_lines);
112 }
113 entry.0.push(visible_range);
114 self.revisions.push(ReadFileRevision {
115 path: canonical_path.clone(),
116 revision,
117 total_lines,
118 visible_ranges: (visible_range.0 <= visible_range.1)
119 .then_some(ReadLineRange {
120 start_line: visible_range.0,
121 end_line: visible_range.1,
122 })
123 .into_iter()
124 .collect(),
125 truncated,
126 temporary_artifact: None,
127 next_start_line: truncated
128 .then_some(visible_range.1.saturating_add(1))
129 .filter(|line| *line <= total_lines),
130 });
131 let _ = write!(
132 self.message,
133 "\n{}{}\n```\n{content}\n```",
134 request.clean_path,
135 range_format(request.start_line_num, request.end_line_num)
136 );
137 self.stats_paths.push(PathBuf::from(canonical_path));
138
139 if truncated {
140 let remaining = total_files.saturating_sub(request.index + 1);
141 if remaining > 0 {
142 let _ = write!(
143 self.message,
144 "\n\n(Not reading the remaining {remaining} file(s) due to the \
145 token limit. Call ReadFiles again for them.)"
146 );
147 }
148 }
149 truncated
150 }
151 Err(error) => {
152 let _ = write!(self.message, "\nError reading {}: {error}", request.requested_path);
153 self.errors.push(error);
154 false
155 }
156 }
157 }
158}
159
160#[derive(Debug)]
167pub struct ReadFilesOutcome {
168 pub text: String,
169 pub successful_files: usize,
170 pub files: Vec<ReadFileRevision>,
171 pub errors: Vec<WinxError>,
172}
173
174const MAX_FILE_SIZE: u64 = 50_000_000;
176
177fn range_format(start_line_num: Option<usize>, end_line_num: Option<usize>) -> String {
178 let st = start_line_num.map_or(String::new(), |n| n.to_string());
179 let end = end_line_num.map_or(String::new(), |n| n.to_string());
180
181 if st.is_empty() && end.is_empty() {
182 String::new()
183 } else {
184 format!(":{st}-{end}")
185 }
186}
187
188#[instrument(level = "debug", skip(file_path))]
189fn read_file(
190 file_path: &str,
191 max_tokens: Option<usize>,
192 cwd: &Path,
193 workspace_root: &Path,
194 show_line_numbers: bool,
195 start_line_num: Option<usize>,
196 end_line_num: Option<usize>,
197) -> Result<FileReadResult> {
198 let file_path_expanded = expand_user(file_path);
199 let path = if Path::new(&file_path_expanded).is_absolute() {
200 PathBuf::from(&file_path_expanded)
201 } else {
202 cwd.join(&file_path_expanded)
203 };
204
205 if !path.exists() {
206 return Err(WinxError::FileNotFound { path: path.clone() });
207 }
208
209 let path = match validate_path_in_workspace(&path, workspace_root) {
210 Ok(canonical) => canonical,
211 Err(security_err) => {
212 return Err(WinxError::PathSecurityError {
213 path: path.clone(),
214 message: security_err.to_string(),
215 });
216 }
217 };
218
219 if !path.is_file() {
220 return Err(WinxError::FileAccessError {
221 path: path.clone(),
222 message: "Path exists but is not a file".to_string(),
223 });
224 }
225
226 let content = read_file_to_string(&path, MAX_FILE_SIZE)?;
227 let file_hash = hash_content(&content);
228 let lines: Vec<&str> = content.lines().collect();
229 let total_lines = lines.len();
230
231 let start_idx = start_line_num.map_or(0, |n| n.saturating_sub(1).min(lines.len()));
232 let end_idx = end_line_num.map_or(lines.len(), |n| n.min(lines.len()));
233
234 if start_idx > lines.len() || start_idx > end_idx {
235 return Err(ErrorRecovery::param_error(
236 "line_range",
237 &format!("Invalid line range for file with {} lines", lines.len()),
238 ));
239 }
240
241 let effective_start = start_line_num.unwrap_or(1);
242 let effective_end = end_line_num.unwrap_or(total_lines);
243
244 let filtered_lines =
245 if lines.is_empty() { &[] } else { &lines[start_idx..end_idx.min(lines.len())] };
246 let mut result_content = String::new();
247
248 if show_line_numbers {
249 for (i, line) in filtered_lines.iter().enumerate() {
250 let line_num = start_idx + i + 1;
251 let _ = writeln!(result_content, "{line_num} {line}");
252 }
253 } else {
254 for line in filtered_lines {
255 result_content.push_str(line);
256 result_content.push('\n');
257 }
258 }
259
260 let mut truncated = false;
261 let mut last_shown = effective_end;
262 let max_tokens = max_tokens.unwrap_or_else(|| select_max_tokens(file_path));
263 let (tokens_count, token_ids) =
267 if crate::utils::encoder::definitely_fits_token_budget(&result_content, max_tokens) {
268 (crate::utils::encoder::estimate_tokens(&result_content), None)
269 } else {
270 let ids = crate::utils::encoder::encode_ids(&result_content);
271 let count = ids
272 .as_ref()
273 .map_or_else(|| crate::utils::encoder::estimate_tokens(&result_content), Vec::len);
274 (count, ids)
275 };
276
277 if tokens_count > max_tokens {
278 truncate_to_token_budget(&mut result_content, max_tokens, token_ids);
279 let kept_lines = result_content.lines().count();
281 last_shown = (start_idx + kept_lines).min(total_lines);
282 let resume_from = last_shown + 1;
283 let _ = write!(
284 result_content,
285 "\n(...truncated) Showing up to line {last_shown} of {total_lines} total lines \
286 ({tokens_count} tokens exceeded limit {max_tokens}). Continue reading from line \
287 {resume_from} using the syntax {file_path}:{resume_from}-{total_lines}"
288 );
289 truncated = true;
290 }
291
292 let canon_path = path.to_string_lossy().to_string();
293 let effective_end_line = if truncated { last_shown } else { effective_end };
294
295 Ok(FileReadResult {
296 content: result_content,
297 truncated,
298 canonical_path: canon_path,
299 visible_range: (effective_start, effective_end_line.min(total_lines.max(1))),
300 file_hash,
301 total_lines,
302 })
303}
304
305fn hash_content(content: &str) -> String {
306 let digest = Sha256::digest(content.as_bytes());
307 digest.iter().fold(String::with_capacity(digest.len() * 2), |mut hash, byte| {
308 let _ = write!(hash, "{byte:02x}");
309 hash
310 })
311}
312
313pub(crate) fn revision_from_hash(hash: &str) -> String {
314 format!("sha256:{hash}")
315}
316
317fn truncate_to_token_budget(content: &mut String, max_tokens: usize, ids: Option<Vec<u32>>) {
318 let Some(ids) = ids else {
321 let byte_idx = byte_index_for_char_count(content, max_tokens);
323 content.truncate(byte_idx);
324 trim_to_last_line_boundary(content);
325 return;
326 };
327
328 if ids.len() <= max_tokens {
329 return;
330 }
331
332 if let Some(decoded) = crate::utils::encoder::decode_ids(&ids[..max_tokens]) {
333 *content = decoded;
334 } else {
335 let byte_idx = byte_index_for_char_count(content, max_tokens);
336 content.truncate(byte_idx);
337 }
338 trim_to_last_line_boundary(content);
339}
340
341fn trim_to_last_line_boundary(content: &mut String) {
342 if let Some(last_nl) = content.rfind('\n') {
343 content.truncate(last_nl + 1);
344 } else {
345 content.clear();
349 }
350}
351
352fn byte_index_for_char_count(content: &str, char_count: usize) -> usize {
353 content.char_indices().nth(char_count).map_or(content.len(), |(idx, _)| idx)
354}
355
356fn select_max_tokens(file_path: &str) -> usize {
357 if is_source_code_file(file_path) {
360 crate::utils::encoder::budget_from_env("WINX_CODING_TOKEN_BUDGET", CODING_MAX_TOKENS)
361 } else {
362 crate::utils::encoder::budget_from_env("WINX_NONCODING_TOKEN_BUDGET", NONCODING_MAX_TOKENS)
363 }
364}
365
366pub(crate) fn configured_parallelism() -> usize {
367 parse_read_parallelism(crate::config::env_text("WINX_READ_PARALLELISM").as_deref())
368}
369
370fn parse_read_parallelism(value: Option<&str>) -> usize {
371 value
372 .and_then(|value| value.parse::<usize>().ok())
373 .filter(|parallelism| *parallelism > 0)
374 .unwrap_or(DEFAULT_READ_PARALLELISM)
375 .min(MAX_READ_PARALLELISM)
376}
377
378fn is_source_code_file(file_path: &str) -> bool {
379 let path = Path::new(file_path);
380 let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
381 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default();
382
383 matches!(file_name, "Makefile" | "Dockerfile" | "Jenkinsfile")
384 || matches!(
385 extension,
386 "py" | "pyx"
387 | "pyi"
388 | "pyw"
389 | "js"
390 | "jsx"
391 | "ts"
392 | "tsx"
393 | "mjs"
394 | "cjs"
395 | "html"
396 | "css"
397 | "scss"
398 | "sass"
399 | "less"
400 | "c"
401 | "h"
402 | "cpp"
403 | "cxx"
404 | "cc"
405 | "hpp"
406 | "java"
407 | "kt"
408 | "go"
409 | "rs"
410 | "rb"
411 | "php"
412 | "sh"
413 | "bash"
414 | "zsh"
415 | "sql"
416 | "xml"
417 | "json"
418 | "yaml"
419 | "yml"
420 | "toml"
421 | "md"
422 | "ex"
423 | "exs"
424 )
425}
426
427async fn execute_read_requests(
428 requests: &[FileReadRequest],
429 cwd: &Path,
430 workspace_root: &Path,
431 show_line_numbers: bool,
432) -> ReadBatchState {
433 let mut state = ReadBatchState::default();
434
435 'batches: for batch in requests.chunks(configured_parallelism()) {
439 let mut tasks = Vec::with_capacity(batch.len());
440 for request in batch.iter().cloned() {
441 let worker_path = request.clean_path.clone();
442 let worker_cwd = cwd.to_path_buf();
443 let worker_root = workspace_root.to_path_buf();
444 let start_line_num = request.start_line_num;
445 let end_line_num = request.end_line_num;
446 let task = tokio::task::spawn_blocking(move || {
447 read_file(
448 &worker_path,
449 Some(select_max_tokens(&worker_path)),
450 &worker_cwd,
451 &worker_root,
452 show_line_numbers,
453 start_line_num,
454 end_line_num,
455 )
456 });
457 tasks.push((request, task));
458 }
459
460 let mut tasks = tasks.into_iter();
461 while let Some((request, task)) = tasks.next() {
462 let result = task.await.unwrap_or_else(|error| {
463 Err(WinxError::CommandExecutionError(format!(
464 "ReadFiles worker failed for {}: {error}",
465 request.clean_path
466 )))
467 });
468 if state.record_result(&request, result, requests.len()) {
469 for (_, pending) in tasks {
472 pending.abort();
473 }
474 break 'batches;
475 }
476 }
477 }
478 state
479}
480
481async fn persist_read_stats(workspace_root: &Path, stats_paths: Vec<PathBuf>) {
482 if stats_paths.is_empty() {
483 return;
484 }
485 let stats_root = workspace_root.to_path_buf();
486 let result = tokio::task::spawn_blocking(move || {
487 crate::utils::workspace_stats::record_reads(&stats_root, &stats_paths)
488 })
489 .await;
490 match result {
491 Ok(Err(error)) => debug!("failed to record read stats: {error}"),
492 Err(error) => debug!("read stats worker failed: {error}"),
493 Ok(Ok(())) => {}
494 }
495}
496
497pub async fn handle_tool_call(
498 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
499 read_files: ReadFiles,
500) -> Result<String> {
501 let outcome = handle_tool_call_detailed(bash_state_arc, read_files).await?;
502 if outcome.successful_files == 0 {
503 if let Some(error) = outcome.errors.into_iter().next() {
504 return Err(error);
505 }
506 }
507 Ok(outcome.text)
508}
509
510pub async fn handle_tool_call_detailed(
511 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
512 read_files: ReadFiles,
513) -> Result<ReadFilesOutcome> {
514 let (cwd, workspace_root, temporary_thread) = {
515 let bash_state_guard = bash_state_arc.lock().await;
516 let bash_state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
517 (
518 bash_state.cwd.clone(),
519 bash_state.workspace_root.clone(),
520 bash_state.current_thread_id.clone(),
521 )
522 };
523
524 let requests = read_files
525 .file_paths
526 .iter()
527 .enumerate()
528 .map(|(index, file_path)| FileReadRequest {
529 index,
530 requested_path: file_path.clone(),
531 clean_path: read_files.get_clean_path(index),
532 start_line_num: read_files.start_line_nums.get(index).copied().flatten(),
533 end_line_num: read_files.end_line_nums.get(index).copied().flatten(),
534 })
535 .collect::<Vec<_>>();
536 let batch =
537 execute_read_requests(&requests, &cwd, &workspace_root, read_files.show_line_numbers())
538 .await;
539 let ReadBatchState {
540 message,
541 file_ranges,
542 stats_paths,
543 successful_files,
544 mut revisions,
545 errors,
546 } = batch;
547 let temporary_workspace = workspace_root.clone();
548 revisions = tokio::task::spawn_blocking(move || {
549 for receipt in &mut revisions {
550 if let Ok(Some((text, metadata))) = crate::utils::agent_temp::release::read_managed(
551 &temporary_workspace,
552 &temporary_thread,
553 Path::new(&receipt.path),
554 ) {
555 if revision_from_hash(&hash_content(&text)) == receipt.revision {
559 receipt.temporary_artifact = Some(metadata);
560 }
561 }
562 }
563 revisions
564 })
565 .await
566 .map_err(|error| {
567 WinxError::CommandExecutionError(format!("temporary read metadata worker failed: {error}"))
568 })?;
569 persist_read_stats(&workspace_root, stats_paths).await;
570
571 let mut bash_state_guard = bash_state_arc.lock().await;
572 if let Some(bash_state) = bash_state_guard.as_mut() {
573 for (path, (ranges, file_hash, total_lines)) in file_ranges {
574 bash_state.record_read_coverage(&path, ranges, file_hash, total_lines);
575 }
576 }
577
578 Ok(ReadFilesOutcome { text: message, successful_files, files: revisions, errors })
579}
580
581#[cfg(test)]
582mod tests {
583 use std::fmt::Write as _;
584
585 use super::{parse_read_parallelism, read_file, trim_to_last_line_boundary};
586 use crate::state::bash_state::FileWhitelistData;
587
588 #[test]
589 fn token_cut_never_exposes_a_partial_line() {
590 let mut content = "complete line\npartial line suffix".to_string();
591 content.truncate("complete line\npartial".len());
592 trim_to_last_line_boundary(&mut content);
593 assert_eq!(content, "complete line\n");
594
595 let mut first_line_only = "partial first line".to_string();
596 first_line_only.truncate(7);
597 trim_to_last_line_boundary(&mut first_line_only);
598 assert!(first_line_only.is_empty(), "expected empty first_line_only");
599 }
600
601 #[test]
602 fn token_truncation_whitelists_only_complete_visible_lines() -> anyhow::Result<()> {
603 let temp = tempfile::tempdir()?;
604 let path = temp.path().join("large.txt");
605 let mut source = String::new();
606 for line in 1..=200 {
607 writeln!(source, "line {line}: enough repeated content to consume tokens quickly")?;
608 }
609 std::fs::write(&path, source)?;
610 let path = path.to_str().ok_or_else(|| anyhow::anyhow!("fixture path is not UTF-8"))?;
611
612 let result = read_file(path, Some(50), temp.path(), temp.path(), false, None, None)?;
613
614 assert!(result.truncated);
615 let visible = result
616 .content
617 .split_once("\n(...truncated)")
618 .map(|(visible, _)| visible)
619 .ok_or_else(|| anyhow::anyhow!("truncation marker is missing"))?;
620 let visible_lines = visible.lines().count();
621 assert_eq!(result.visible_range, (1, visible_lines));
622 let coverage = FileWhitelistData::new(
623 result.file_hash,
624 vec![result.visible_range],
625 result.total_lines,
626 );
627 assert!(!coverage.is_read_enough());
628 assert_eq!(coverage.get_unread_ranges(), vec![(visible_lines + 1, result.total_lines)]);
629 Ok(())
630 }
631
632 #[test]
633 fn oversized_first_line_records_no_read_coverage() -> anyhow::Result<()> {
634 let temp = tempfile::tempdir()?;
635 let path = temp.path().join("single-line.txt");
636 std::fs::write(&path, "x".repeat(10_000))?;
637 let path = path.to_str().ok_or_else(|| anyhow::anyhow!("fixture path is not UTF-8"))?;
638
639 let result = read_file(path, Some(10), temp.path(), temp.path(), false, None, None)?;
640
641 assert!(result.truncated);
642 assert!(result.content.starts_with("\n(...truncated) Showing up to line 0"));
643 assert_eq!(result.visible_range, (1, 0));
644 let coverage = FileWhitelistData::new(
645 result.file_hash,
646 vec![result.visible_range],
647 result.total_lines,
648 );
649 assert!(coverage.line_ranges_read.is_empty(), "expected empty coverage.line_ranges_read");
650 assert_eq!(coverage.get_unread_ranges(), vec![(1, 1)]);
651 Ok(())
652 }
653
654 #[test]
655 fn parallelism_uses_safe_defaults_and_bounds() {
656 assert_eq!(parse_read_parallelism(None), 4);
657 assert_eq!(parse_read_parallelism(Some("invalid")), 4);
658 assert_eq!(parse_read_parallelism(Some("0")), 4);
659 assert_eq!(parse_read_parallelism(Some("1")), 1);
660 assert_eq!(parse_read_parallelism(Some("8")), 8);
661 assert_eq!(parse_read_parallelism(Some("999")), 32);
662 }
663}