Skip to main content

vtcode_core/tools/handlers/read_file/
batch.rs

1//! Batch read orchestration and progress state.
2//!
3//! This module owns only batch admission, bounded fan-out, result ordering, and
4//! response assembly. Indentation-aware range semantics remain behind the
5//! `ReadFileHandler::read_range` interface in the parent module; compatible
6//! slice ranges share one bounded read context.
7
8use 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/// Batch read arguments accepted by the `read_file` tool.
21#[derive(Deserialize, Serialize, Clone, Debug)]
22pub struct BatchReadArgs {
23    /// List of read requests to execute in parallel.
24    pub reads: Vec<BatchReadRequest>,
25    /// Maximum concurrent file reads (default: 8).
26    #[serde(default = "defaults::max_concurrency")]
27    pub max_concurrency: usize,
28    /// Whether to show progress in UI (default: true).
29    #[serde(default = "defaults::ui_progress")]
30    pub ui_progress: bool,
31}
32
33/// A single file read request within a batch.
34#[derive(Deserialize, Serialize, Clone, Debug)]
35pub struct BatchReadRequest {
36    /// Absolute path to the file to read.
37    pub file_path: String,
38    /// Single range to read (mutually exclusive with `ranges`).
39    #[serde(flatten)]
40    pub range: Option<ReadRange>,
41    /// Multiple ranges to read from the same file.
42    #[serde(default)]
43    pub ranges: Option<Vec<ReadRange>>,
44}
45
46/// Result for a single file read in batch mode.
47#[derive(Serialize, Clone, Debug)]
48pub struct BatchReadResult {
49    /// The file path that was read.
50    pub file_path: String,
51    /// Results for each range read.
52    pub ranges: Vec<RangeResult>,
53    /// Error if the entire file read failed.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub error: Option<String>,
56}
57
58/// Result for a single range read.
59#[derive(Serialize, Clone, Debug)]
60pub struct RangeResult {
61    /// Starting line offset.
62    pub offset: usize,
63    /// Lines actually read.
64    pub lines_read: usize,
65    /// Whether content was condensed.
66    pub condensed: bool,
67    /// Number of lines omitted if condensed.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub omitted_lines: Option<usize>,
70    /// Whether a physical source line exceeded the bounded reader limit.
71    #[serde(skip_serializing_if = "is_false")]
72    pub line_truncated: bool,
73    /// The content read.
74    pub content: String,
75}
76
77fn is_false(value: &bool) -> bool {
78    !*value
79}
80
81/// Progress tracking for batch reads.
82#[derive(Clone)]
83pub struct BatchProgress {
84    /// Total number of files to read.
85    pub total_files: Arc<AtomicUsize>,
86    /// Number of files completed.
87    pub completed_files: Arc<AtomicUsize>,
88    /// Current file being read.
89    pub current_file: Arc<tokio::sync::RwLock<String>>,
90    /// Total bytes to read (estimated).
91    pub total_bytes: Arc<AtomicU64>,
92    /// Bytes read so far.
93    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    // A concurrency value larger than the batch cannot add useful work and
158    // should not size the stream's in-flight buffer.
159    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    // Reserve capacity for the joined content. Each result contributes at least
275    // a header line plus its range content; estimate conservatively to avoid
276    // repeated reallocations as the String grows.
277    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 // headers, separators, line numbers
281        })
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}