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