vtcode_core/tools/handlers/read_file/
batch.rs1use std::fmt::Write;
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
12
13use anyhow::{Result, bail};
14use futures::stream::{self, StreamExt};
15use serde::{Deserialize, Serialize};
16use serde_json::{Value, json};
17
18use super::{ReadFileHandler, ReadRange, defaults};
19
20#[derive(Deserialize, Serialize, Clone, Debug)]
22pub struct BatchReadArgs {
23 pub reads: Vec<BatchReadRequest>,
25 #[serde(default = "defaults::max_concurrency")]
27 pub max_concurrency: usize,
28 #[serde(default = "defaults::ui_progress")]
30 pub ui_progress: bool,
31}
32
33#[derive(Deserialize, Serialize, Clone, Debug)]
35pub struct BatchReadRequest {
36 pub file_path: String,
38 #[serde(flatten)]
40 pub range: Option<ReadRange>,
41 #[serde(default)]
43 pub ranges: Option<Vec<ReadRange>>,
44}
45
46#[derive(Serialize, Clone, Debug)]
48pub struct BatchReadResult {
49 pub file_path: String,
51 pub ranges: Vec<RangeResult>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub error: Option<String>,
56}
57
58#[derive(Serialize, Clone, Debug)]
60pub struct RangeResult {
61 pub offset: usize,
63 pub lines_read: usize,
65 pub condensed: bool,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub omitted_lines: Option<usize>,
70 #[serde(skip_serializing_if = "is_false")]
72 pub line_truncated: bool,
73 pub content: String,
75}
76
77fn is_false(value: &bool) -> bool {
78 !*value
79}
80
81#[derive(Clone)]
83pub struct BatchProgress {
84 pub total_files: Arc<AtomicUsize>,
86 pub completed_files: Arc<AtomicUsize>,
88 pub current_file: Arc<tokio::sync::RwLock<String>>,
90 pub total_bytes: Arc<AtomicU64>,
92 pub bytes_read: Arc<AtomicU64>,
94}
95
96impl BatchProgress {
97 pub fn new(total_files: usize) -> Self {
98 Self {
99 total_files: Arc::new(AtomicUsize::new(total_files)),
100 completed_files: Arc::new(AtomicUsize::new(0)),
101 current_file: Arc::new(tokio::sync::RwLock::new(String::new())),
102 total_bytes: Arc::new(AtomicU64::new(0)),
103 bytes_read: Arc::new(AtomicU64::new(0)),
104 }
105 }
106
107 pub async fn file_started(&self, file_path: &str) {
108 let mut current = self.current_file.write().await;
109 *current = file_path.to_string();
110 }
111
112 pub fn file_completed(&self) {
113 self.completed_files.fetch_add(1, Ordering::Relaxed);
114 }
115
116 pub fn add_bytes(&self, bytes: u64) {
117 self.bytes_read.fetch_add(bytes, Ordering::Relaxed);
118 }
119
120 pub fn progress_percent(&self) -> f64 {
121 let completed = self.completed_files.load(Ordering::Relaxed);
122 let total = self.total_files.load(Ordering::Relaxed);
123 if total == 0 {
124 100.0
125 } else {
126 (completed as f64 / total as f64) * 100.0
127 }
128 }
129
130 pub async fn status_line(&self) -> (String, String) {
131 let completed = self.completed_files.load(Ordering::Relaxed);
132 let total = self.total_files.load(Ordering::Relaxed);
133 let current = self.current_file.read().await;
134 let file_name = PathBuf::from(current.as_str())
135 .file_name()
136 .map(|name| name.to_string_lossy().to_string())
137 .unwrap_or_else(|| current.clone());
138
139 let left = format!("Reading {}/{}: {}", completed + 1, total, file_name);
140 let right = format!("{:.0}%", self.progress_percent());
141 (left, right)
142 }
143}
144
145pub(super) async fn execute(handler: &ReadFileHandler, args: BatchReadArgs) -> Result<Value> {
146 if args.reads.is_empty() {
147 return Ok(json!({
148 "success": false,
149 "error": "No read requests provided"
150 }));
151 }
152
153 if args.max_concurrency == 0 {
154 bail!("max_concurrency must be greater than zero");
155 }
156
157 let max_concurrency = args.max_concurrency.min(args.reads.len());
160 let progress = BatchProgress::new(args.reads.len());
161 let mut indexed_results: Vec<(usize, BatchReadResult)> = stream::iter(args.reads.into_iter().enumerate())
162 .map(|(index, request)| {
163 let progress = progress.clone();
164 async move {
165 progress.file_started(&request.file_path).await;
166 let result = read_single_request(handler, &request).await;
167 progress.file_completed();
168 (index, result)
169 }
170 })
171 .buffer_unordered(max_concurrency)
172 .collect()
173 .await;
174 indexed_results.sort_unstable_by_key(|(index, _)| *index);
175 let results: Vec<BatchReadResult> = indexed_results.into_iter().map(|(_, result)| result).collect();
176
177 let content = assemble_content(&results);
178 let all_success = results.iter().all(|result| result.error.is_none());
179 Ok(json!({
180 "success": all_success,
181 "content": content,
182 "items": results,
183 "files_read": results.len(),
184 "files_succeeded": results.iter().filter(|result| result.error.is_none()).count(),
185 "no_spool": true
186 }))
187}
188
189async fn read_single_request(handler: &ReadFileHandler, request: &BatchReadRequest) -> BatchReadResult {
190 let path = PathBuf::from(&request.file_path);
191 if !path.is_absolute() {
192 return BatchReadResult {
193 file_path: request.file_path.clone(),
194 ranges: vec![],
195 error: Some("file_path must be an absolute path".to_string()),
196 };
197 }
198
199 let ranges = request
200 .ranges
201 .clone()
202 .or_else(|| request.range.clone().map(|range| vec![range]))
203 .unwrap_or_else(|| vec![ReadRange::default()]);
204
205 if ranges.is_empty() {
206 return BatchReadResult {
207 file_path: request.file_path.clone(),
208 ranges: vec![],
209 error: None,
210 };
211 }
212
213 if ranges.iter().all(|range| matches!(&range.mode, super::ReadMode::Slice)) {
214 return match super::slice::read_ranges(&path, &ranges).await {
215 Ok(slice_results) => {
216 let super::slice::SliceReadRanges { results, error } = slice_results;
217 let mut range_results = Vec::with_capacity(results.len());
218 for (result, range) in results.into_iter().zip(ranges.iter()) {
219 match result {
220 Some(Ok(result)) => {
221 range_results.push(super::range_result_from_lines(
222 range.offset.max(1),
223 result.lines,
224 result.line_truncated,
225 ));
226 }
227 Some(Err(error)) => {
228 return BatchReadResult {
229 file_path: request.file_path.clone(),
230 ranges: range_results,
231 error: Some(error.to_string()),
232 };
233 }
234 None => {}
235 }
236 }
237
238 BatchReadResult {
239 file_path: request.file_path.clone(),
240 ranges: range_results,
241 error: error.map(|error| error.to_string()),
242 }
243 }
244 Err(error) => BatchReadResult {
245 file_path: request.file_path.clone(),
246 ranges: vec![],
247 error: Some(error.to_string()),
248 },
249 };
250 }
251
252 let mut range_results = Vec::with_capacity(ranges.len());
253 for range in ranges {
254 match handler.read_range(&path, &range).await {
255 Ok(result) => range_results.push(result),
256 Err(error) => {
257 return BatchReadResult {
258 file_path: request.file_path.clone(),
259 ranges: range_results,
260 error: Some(error.to_string()),
261 };
262 }
263 }
264 }
265
266 BatchReadResult {
267 file_path: request.file_path.clone(),
268 ranges: range_results,
269 error: None,
270 }
271}
272
273fn assemble_content(results: &[BatchReadResult]) -> String {
274 let estimated_bytes: usize = results
278 .iter()
279 .map(|result| {
280 result.file_path.len() + result.ranges.iter().map(|r| r.content.len()).sum::<usize>() + 64 })
282 .sum();
283 let mut content = String::with_capacity(estimated_bytes);
284 let mut is_first = true;
285 for result in results {
286 if let Some(error) = &result.error {
287 if !is_first {
288 content.push_str("\n\n");
289 }
290 is_first = false;
291 let _ = write!(content, "== {} (ERROR)\n{}", result.file_path, error);
292 continue;
293 }
294
295 for range in &result.ranges {
296 if !is_first {
297 content.push_str("\n\n");
298 }
299 is_first = false;
300 let end_line = range.offset.saturating_add(range.lines_read.saturating_sub(1));
301 let _ = write!(content, "== {} (L{}..L{})\n{}", result.file_path, range.offset, end_line, range.content);
302 }
303 }
304 content
305}