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