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