1use sha2::{Digest, Sha256};
8use std::collections::HashMap;
9use std::fmt::Write as FmtWrite;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, OnceLock};
12use tokio::sync::Mutex;
13use tracing::{debug, error, info, instrument, warn};
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;
24
25type FileReadResult = (String, bool, usize, String, (usize, usize), String, usize);
27type ReadCoverage = (Vec<(usize, usize)>, String, usize);
28
29const MAX_FILE_SIZE: u64 = 50_000_000;
31
32fn range_format(start_line_num: Option<usize>, end_line_num: Option<usize>) -> String {
33 let st = start_line_num.map_or(String::new(), |n| n.to_string());
34 let end = end_line_num.map_or(String::new(), |n| n.to_string());
35
36 if st.is_empty() && end.is_empty() {
37 String::new()
38 } else {
39 format!(":{st}-{end}")
40 }
41}
42
43#[instrument(level = "debug", skip(file_path))]
44async fn read_file(
45 file_path: &str,
46 max_tokens: Option<usize>,
47 cwd: &Path,
48 workspace_root: &Path,
49 show_line_numbers: bool,
50 start_line_num: Option<usize>,
51 end_line_num: Option<usize>,
52) -> Result<FileReadResult> {
53 let file_path_expanded = expand_user(file_path);
54 let path = if Path::new(&file_path_expanded).is_absolute() {
55 PathBuf::from(&file_path_expanded)
56 } else {
57 cwd.join(&file_path_expanded)
58 };
59
60 if !path.exists() {
61 return Err(WinxError::FileAccessError {
62 path: path.clone(),
63 message: "File does not exist".to_string(),
64 });
65 }
66
67 let path = match validate_path_in_workspace(&path, workspace_root) {
68 Ok(canonical) => canonical,
69 Err(security_err) => {
70 return Err(WinxError::PathSecurityError {
71 path: path.clone(),
72 message: security_err.to_string(),
73 });
74 }
75 };
76
77 if !path.is_file() {
78 return Err(WinxError::FileAccessError {
79 path: path.clone(),
80 message: "Path exists but is not a file".to_string(),
81 });
82 }
83
84 let content = read_file_to_string(&path, MAX_FILE_SIZE)?;
85 let file_hash = hash_content(&content);
86 let lines: Vec<&str> = content.lines().collect();
87 let total_lines = lines.len();
88
89 let start_idx = start_line_num.map_or(0, |n| n.saturating_sub(1).min(lines.len()));
90 let end_idx = end_line_num.map_or(lines.len(), |n| n.min(lines.len()));
91
92 if start_idx > lines.len() || start_idx > end_idx {
93 return Err(ErrorRecovery::param_error(
94 "line_range",
95 &format!("Invalid line range for file with {} lines", lines.len()),
96 ));
97 }
98
99 let effective_start = start_line_num.unwrap_or(1);
100 let effective_end = end_line_num.unwrap_or(total_lines);
101
102 let filtered_lines =
103 if lines.is_empty() { &[] } else { &lines[start_idx..end_idx.min(lines.len())] };
104 let mut result_content = String::new();
105
106 if show_line_numbers {
107 for (i, line) in filtered_lines.iter().enumerate() {
108 let line_num = start_idx + i + 1;
109 let _ = writeln!(result_content, "{line_num} {line}");
110 }
111 } else {
112 for line in filtered_lines {
113 result_content.push_str(line);
114 result_content.push('\n');
115 }
116 }
117
118 let mut truncated = false;
119 let max_tokens = max_tokens.unwrap_or_else(|| select_max_tokens(file_path));
120 let token_ids = crate::utils::encoder::encode_ids(&result_content);
123 let tokens_count = token_ids
124 .as_ref()
125 .map_or_else(|| crate::utils::encoder::estimate_tokens(&result_content), Vec::len);
126
127 if tokens_count > max_tokens {
128 truncate_to_token_budget(&mut result_content, max_tokens, token_ids);
129 let kept_lines = result_content.lines().count();
131 let last_shown = (start_idx + kept_lines).min(total_lines);
132 let resume_from = last_shown + 1;
133 let _ = write!(
134 result_content,
135 "\n(...truncated) Showing up to line {last_shown} of {total_lines} total lines \
136 ({tokens_count} tokens exceeded limit {max_tokens}). Continue reading from line \
137 {resume_from} using the syntax {file_path}:{resume_from}-{total_lines}"
138 );
139 truncated = true;
140 }
141
142 let canon_path = path.to_string_lossy().to_string();
143
144 Ok((
145 result_content,
146 truncated,
147 tokens_count,
148 canon_path,
149 (effective_start, effective_end.min(total_lines.max(1))),
150 file_hash,
151 total_lines,
152 ))
153}
154
155fn hash_content(content: &str) -> String {
156 let digest = Sha256::digest(content.as_bytes());
157 digest.iter().fold(String::with_capacity(digest.len() * 2), |mut hash, byte| {
158 let _ = write!(hash, "{byte:02x}");
159 hash
160 })
161}
162
163fn truncate_to_token_budget(content: &mut String, max_tokens: usize, ids: Option<Vec<u32>>) {
164 let Some(ids) = ids else {
167 let byte_idx = byte_index_for_char_count(content, max_tokens);
169 content.truncate(byte_idx);
170 return;
171 };
172
173 if ids.len() <= max_tokens {
174 return;
175 }
176
177 if let Some(decoded) = crate::utils::encoder::decode_ids(&ids[..max_tokens]) {
178 *content = decoded;
179 } else {
180 let byte_idx = byte_index_for_char_count(content, max_tokens);
181 content.truncate(byte_idx);
182 }
183}
184
185fn byte_index_for_char_count(content: &str, char_count: usize) -> usize {
186 content.char_indices().nth(char_count).map_or(content.len(), |(idx, _)| idx)
187}
188
189fn select_max_tokens(file_path: &str) -> usize {
190 if is_source_code_file(file_path) {
193 crate::utils::encoder::budget_from_env("WINX_CODING_TOKEN_BUDGET", CODING_MAX_TOKENS)
194 } else {
195 crate::utils::encoder::budget_from_env("WINX_NONCODING_TOKEN_BUDGET", NONCODING_MAX_TOKENS)
196 }
197}
198
199fn is_source_code_file(file_path: &str) -> bool {
200 let path = Path::new(file_path);
201 let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
202 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default();
203
204 matches!(file_name, "Makefile" | "Dockerfile" | "Jenkinsfile")
205 || matches!(
206 extension,
207 "py" | "pyx"
208 | "pyi"
209 | "pyw"
210 | "js"
211 | "jsx"
212 | "ts"
213 | "tsx"
214 | "mjs"
215 | "cjs"
216 | "html"
217 | "css"
218 | "scss"
219 | "sass"
220 | "less"
221 | "c"
222 | "h"
223 | "cpp"
224 | "cxx"
225 | "cc"
226 | "hpp"
227 | "java"
228 | "kt"
229 | "go"
230 | "rs"
231 | "rb"
232 | "php"
233 | "sh"
234 | "bash"
235 | "zsh"
236 | "sql"
237 | "xml"
238 | "json"
239 | "yaml"
240 | "yml"
241 | "toml"
242 | "md"
243 | "ex"
244 | "exs"
245 )
246}
247
248pub async fn handle_tool_call(
249 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
250 read_files: ReadFiles,
251) -> Result<String> {
252 let (cwd, workspace_root) = {
253 let bash_state_guard = bash_state_arc.lock().await;
254 let bash_state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
255 (bash_state.cwd.clone(), bash_state.workspace_root.clone())
256 };
257
258 let mut message = String::new();
259 let mut file_ranges_dict: HashMap<String, ReadCoverage> = HashMap::new();
260
261 for (index, file_path) in read_files.file_paths.iter().enumerate() {
262 let clean_path = read_files.get_clean_path(index);
263 let start_line_num = read_files.start_line_nums.get(index).copied().flatten();
264 let end_line_num = read_files.end_line_nums.get(index).copied().flatten();
265
266 match read_file(
267 &clean_path,
268 Some(select_max_tokens(&clean_path)),
269 &cwd,
270 &workspace_root,
271 read_files.show_line_numbers(),
272 start_line_num,
273 end_line_num,
274 )
275 .await
276 {
277 Ok((content, truncated, _, canon_path, line_range, file_hash, total_lines)) => {
278 let entry = file_ranges_dict
279 .entry(canon_path.clone())
280 .or_insert_with(|| (Vec::new(), file_hash.clone(), total_lines));
281 entry.0.push(line_range);
282 entry.1 = file_hash;
283 entry.2 = total_lines;
284 let _ = write!(
285 message,
286 "\n{}{}\n```\n{content}\n```",
287 clean_path,
288 range_format(start_line_num, end_line_num)
289 );
290
291 if let Err(e) = crate::utils::workspace_stats::record_read(
292 &workspace_root,
293 Path::new(&canon_path),
294 ) {
295 debug!("failed to record read stats: {e}");
296 }
297
298 if truncated {
299 let remaining = read_files.file_paths.len().saturating_sub(index + 1);
300 if remaining > 0 {
301 let _ = write!(
302 message,
303 "\n\n(Not reading the remaining {remaining} file(s) due to the token \
304 limit. Call ReadFiles again for them.)"
305 );
306 }
307 break;
308 }
309 }
310 Err(e) => {
311 let _ = write!(message, "\nError reading {file_path}: {e}");
312 }
313 }
314 }
315
316 let mut bash_state_guard = bash_state_arc.lock().await;
317 if let Some(bash_state) = bash_state_guard.as_mut() {
318 for (path, (ranges, file_hash, total_lines)) in file_ranges_dict {
319 bash_state
320 .whitelist_for_overwrite
321 .entry(path)
322 .and_modify(|existing| {
323 existing.file_hash.clone_from(&file_hash);
324 existing.total_lines = total_lines;
325 existing.line_ranges_read.extend(ranges.iter().copied());
326 })
327 .or_insert_with(|| {
328 crate::state::bash_state::FileWhitelistData::new(file_hash, ranges, total_lines)
329 });
330 }
331 }
332
333 Ok(message)
334}