Skip to main content

vtcode_core/tools/handlers/
read_file.rs

1use std::collections::VecDeque;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6use serde::{Deserialize, Deserializer, Serialize};
7use serde_json::{Value, json};
8use tokio::fs::File;
9use tokio::io::BufReader;
10
11use crate::tools::file_ops::read_bounded_line;
12use crate::tools::file_ops::read_byte_range;
13
14use crate::tools::error_helpers::deserialize_tool_args;
15use crate::tools::traits::Tool;
16use crate::utils::serde_helpers::{deserialize_maybe_quoted, deserialize_opt_maybe_quoted};
17
18mod batch;
19mod lines;
20
21pub use batch::{BatchProgress, BatchReadArgs, BatchReadRequest, BatchReadResult, RangeResult};
22use lines::{condense_collected_lines, format_line, range_result_from_lines, trim_empty_lines};
23
24pub struct ReadFileHandler;
25
26const MAX_LINE_LENGTH: usize = 500;
27const TAB_WIDTH: usize = 4;
28const COMMENT_PREFIXES: &[&str] = &["#", "//", "--"];
29const MIN_BATCH_LIMIT: usize = 200;
30const DEFAULT_MAX_CONCURRENCY: usize = 8;
31const BATCH_CONDENSED_THRESHOLD: usize = 100;
32const DEFAULT_BYTE_CHUNK_SIZE: usize = 8192;
33
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub(crate) struct ReadFileOutcome {
36    pub content: String,
37    pub lines_read: usize,
38    pub has_more: bool,
39    /// True when a physical source line exceeded the bounded reader limit.
40    pub line_truncated: bool,
41    /// True when the requested line count exceeded the absolute cap and was clamped.
42    pub capped_by_limit: bool,
43    /// The line count actually applied after clamping (0 for byte-range reads).
44    pub applied_limit: usize,
45}
46
47/// JSON arguments accepted by the `read_file` tool handler.
48#[derive(Deserialize, Serialize, Clone, Debug)]
49pub struct ReadFileArgs {
50    /// Absolute path to the file that will be read.
51    #[serde(alias = "path", alias = "filepath", alias = "target_path", alias = "file")]
52    pub file_path: String,
53    /// 1-indexed line number to start reading from; defaults to 1.
54    #[serde(default = "defaults::offset", deserialize_with = "deserialize_maybe_quoted")]
55    pub offset: usize,
56    /// Maximum number of lines to return; defaults to the configured `max_read_lines` cap.
57    #[serde(default = "defaults::limit", deserialize_with = "deserialize_maybe_quoted")]
58    pub limit: usize,
59    /// Determines whether the handler reads a simple slice or indentation-aware block.
60    #[serde(default, deserialize_with = "deserialize_read_mode")]
61    pub mode: ReadMode,
62    /// Optional indentation configuration used when `mode` is `Indentation`.
63    #[serde(default, deserialize_with = "deserialize_indentation")]
64    pub indentation: Option<IndentationArgs>,
65    /// Optional token limit for response
66    #[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
67    pub max_tokens: Option<usize>,
68    /// Whether to condense long outputs to head/tail.
69    #[serde(default = "defaults::condense", deserialize_with = "deserialize_maybe_quoted")]
70    pub condense: bool,
71    /// Byte offset (0-indexed) to start reading from. When present, enables byte-range read mode.
72    #[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
73    pub offset_bytes: Option<u64>,
74    /// Number of bytes to read. Accepts alias `length`. When present with `offset_bytes`, enables byte-range read mode.
75    #[serde(default, alias = "length", deserialize_with = "deserialize_opt_maybe_quoted")]
76    pub page_size_bytes: Option<usize>,
77}
78
79impl ReadFileArgs {
80    /// Effective line request before the absolute cap is applied.
81    pub(crate) fn effective_line_limit(&self) -> usize {
82        if matches!(self.mode, ReadMode::Slice) && self.max_tokens.is_none() {
83            self.limit.max(MIN_BATCH_LIMIT)
84        } else {
85            self.limit
86        }
87    }
88}
89
90/// A range specification for reading.
91#[derive(Deserialize, Serialize, Clone, Debug, Default)]
92pub struct ReadRange {
93    /// 1-indexed line number to start reading from; defaults to 1.
94    #[serde(default = "defaults::offset", deserialize_with = "deserialize_maybe_quoted")]
95    pub offset: usize,
96    /// Maximum number of lines to return; defaults to 500 for batch.
97    #[serde(default = "defaults::batch_limit", deserialize_with = "deserialize_maybe_quoted")]
98    pub limit: usize,
99    /// Read mode: slice or indentation.
100    #[serde(default, deserialize_with = "deserialize_read_mode")]
101    pub mode: ReadMode,
102    /// Indentation options when mode is indentation.
103    #[serde(default, deserialize_with = "deserialize_indentation")]
104    pub indentation: Option<IndentationArgs>,
105}
106
107#[derive(Deserialize, Serialize, Clone, Debug, Default)]
108#[serde(rename_all = "snake_case")]
109pub enum ReadMode {
110    #[default]
111    Slice,
112    Indentation,
113}
114
115/// Additional configuration for indentation-aware reads.
116#[derive(Deserialize, Serialize, Clone, Debug, Default)]
117pub struct IndentationArgs {
118    /// Optional explicit anchor line; defaults to `offset` when omitted.
119    #[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
120    pub anchor_line: Option<usize>,
121    /// Maximum indentation depth to collect; `0` means unlimited.
122    #[serde(default = "defaults::max_levels", deserialize_with = "deserialize_maybe_quoted")]
123    pub max_levels: usize,
124    /// Whether to include sibling blocks at the same indentation level.
125    #[serde(default = "defaults::include_siblings")]
126    pub include_siblings: bool,
127    /// Whether to include header lines above the anchor block.
128    #[serde(default = "defaults::include_header")]
129    pub include_header: bool,
130    /// Optional hard cap on returned lines; defaults to the global `limit`.
131    #[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
132    pub max_lines: Option<usize>,
133}
134
135fn deserialize_read_mode<'de, D>(deserializer: D) -> Result<ReadMode, D::Error>
136where
137    D: Deserializer<'de>,
138{
139    let value = Value::deserialize(deserializer)?;
140    match value {
141        Value::Null => Ok(ReadMode::Slice),
142        Value::String(raw) => {
143            let trimmed = raw.trim();
144            if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("slice") {
145                Ok(ReadMode::Slice)
146            } else if trimmed.eq_ignore_ascii_case("indentation") {
147                Ok(ReadMode::Indentation)
148            } else {
149                Err(serde::de::Error::custom(format!("invalid read mode: {trimmed}")))
150            }
151        }
152        other => Err(serde::de::Error::custom(format!("invalid read mode type: {other}"))),
153    }
154}
155
156fn deserialize_indentation<'de, D>(deserializer: D) -> Result<Option<IndentationArgs>, D::Error>
157where
158    D: Deserializer<'de>,
159{
160    let value = Value::deserialize(deserializer)?;
161    match value {
162        Value::Null => Ok(None),
163        Value::Bool(true) => Ok(Some(IndentationArgs::default())),
164        Value::Bool(false) => Ok(None),
165        Value::String(raw) => {
166            let trimmed = raw.trim();
167            if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("false") {
168                Ok(None)
169            } else if trimmed.eq_ignore_ascii_case("true") {
170                Ok(Some(IndentationArgs::default()))
171            } else {
172                Err(serde::de::Error::custom(format!("invalid indentation value: {trimmed}")))
173            }
174        }
175        Value::Object(_) => {
176            let args = IndentationArgs::deserialize(value).map_err(serde::de::Error::custom)?;
177            Ok(Some(args))
178        }
179        other => Err(serde::de::Error::custom(format!("invalid indentation type: {other}"))),
180    }
181}
182
183#[derive(Clone, Debug)]
184struct LineRecord {
185    number: usize,
186    raw: String,
187    display: String,
188    indent: usize,
189}
190
191impl LineRecord {
192    fn trimmed(&self) -> &str {
193        self.raw.trim_start()
194    }
195
196    fn is_blank(&self) -> bool {
197        self.trimmed().is_empty()
198    }
199
200    fn is_comment(&self) -> bool {
201        COMMENT_PREFIXES.iter().any(|prefix| self.raw.trim().starts_with(prefix))
202    }
203}
204
205impl ReadFileHandler {
206    /// Execute a batch read of multiple files/ranges in parallel.
207    pub async fn handle_batch(&self, args: BatchReadArgs) -> Result<Value> {
208        batch::execute(self, args).await
209    }
210
211    /// Read a single range from a file.
212    async fn read_range(&self, path: &Path, range: &ReadRange) -> Result<RangeResult> {
213        let offset = range.offset.max(1);
214        let limit = Self::effective_line_limit(range.limit);
215
216        let (collected, line_truncated) = match range.mode {
217            ReadMode::Slice => {
218                let result = slice::read(path, offset, limit).await?;
219                (result.lines, result.line_truncated)
220            }
221            ReadMode::Indentation => {
222                let indentation = range.indentation.clone().unwrap_or_default();
223                let result = indentation::read_block(path, offset, limit, indentation).await?;
224                (result.lines, result.line_truncated)
225            }
226        };
227
228        Ok(range_result_from_lines(offset, collected, line_truncated))
229    }
230
231    /// Clamp a requested line count to the absolute per-call ceiling.
232    ///
233    /// Returns `(applied_limit, capped_by_limit)`. `capped_by_limit` is true
234    /// only when the caller asked for strictly more lines than `absolute_max`
235    /// permits, so a read whose `requested == absolute_max` is *not* flagged as
236    /// clamped (it merely sits at the ceiling). This keeps the pagination chain
237    /// alive on every page that reaches the ceiling without misreporting a
238    /// clamp on the exact-cap follow-up pages.
239    fn clamp_to_absolute_cap(requested: usize, absolute_max: usize) -> (usize, bool) {
240        let capped = requested > absolute_max;
241        (requested.min(absolute_max), capped)
242    }
243
244    fn effective_line_limit(requested: usize) -> usize {
245        let absolute_max = crate::tools::read_limits::absolute_line_cap();
246        Self::clamp_to_absolute_cap(requested.max(1), absolute_max).0
247    }
248
249    pub(crate) async fn handle_detailed(&self, args: ReadFileArgs) -> Result<ReadFileOutcome> {
250        let effective_limit = args.effective_line_limit();
251        let ReadFileArgs {
252            file_path,
253            offset,
254            limit,
255            mode,
256            indentation,
257            max_tokens: _,
258            condense,
259            offset_bytes,
260            page_size_bytes,
261        } = args;
262
263        let path = PathBuf::from(&file_path);
264        anyhow::ensure!(path.is_absolute(), "file_path must be an absolute path");
265
266        // Byte-range read path: when offset_bytes or page_size_bytes is present
267        if offset_bytes.is_some() || page_size_bytes.is_some() {
268            let byte_offset = offset_bytes.unwrap_or(0);
269            let byte_length = page_size_bytes.unwrap_or(DEFAULT_BYTE_CHUNK_SIZE);
270            anyhow::ensure!(byte_length > 0, "page_size_bytes must be greater than 0");
271            return self.read_byte_range(&path, byte_offset, byte_length).await;
272        }
273
274        anyhow::ensure!(offset > 0, "offset must be a 1-indexed line number");
275        anyhow::ensure!(limit > 0, "limit must be greater than zero");
276
277        let absolute_max = crate::tools::read_limits::absolute_line_cap();
278
279        // Absolute hard cap: no single line-based read may return more than
280        // `absolute_max` lines, even when the caller requests a larger `limit`.
281        // Byte-range reads are unaffected (they use `page_size_bytes`).
282        let (applied_limit, capped_by_limit) = Self::clamp_to_absolute_cap(effective_limit, absolute_max);
283
284        let (mut collected, has_more, line_truncated) = match mode {
285            ReadMode::Slice => {
286                let result = slice::read(&path, offset, applied_limit).await?;
287                (result.lines, result.has_more, result.line_truncated)
288            }
289            ReadMode::Indentation => {
290                let indentation = indentation.unwrap_or_default();
291                let result = indentation::read_block(&path, offset, applied_limit, indentation).await?;
292                (result.lines, false, result.line_truncated)
293            }
294        };
295        let lines_read = collected.len();
296
297        if condense {
298            // Skip condensation for paginated reads (caller advanced past
299            // line 1). When the caller passes an explicit offset > 1, they are
300            // following an omit-hint to retrieve a specific slice. Re-condensing
301            // that slice would produce another omit-hint, trapping the agent
302            // in an infinite pagination regress (checkpoint turn_613).
303            if offset <= 1 {
304                condense_collected_lines(&mut collected);
305            }
306        }
307
308        Ok(ReadFileOutcome {
309            content: collected.join("\n"),
310            lines_read,
311            has_more,
312            line_truncated,
313            capped_by_limit,
314            applied_limit,
315        })
316    }
317
318    /// Read a byte range from a file using seek-based access.
319    ///
320    /// Delegates to the shared `read_byte_range` function in `file_ops`.
321    /// Returns content with line numbers prefixed (e.g., "42: content").
322    async fn read_byte_range(
323        &self,
324        file_path: &Path,
325        offset_bytes: u64,
326        page_size_bytes: usize,
327    ) -> Result<ReadFileOutcome> {
328        let result = read_byte_range(file_path, offset_bytes, page_size_bytes, true).await?;
329        Ok(ReadFileOutcome {
330            content: result.content,
331            lines_read: result.lines_read,
332            has_more: result.has_more,
333            line_truncated: false,
334            capped_by_limit: false,
335            applied_limit: 0,
336        })
337    }
338
339    /// Legacy handle method for backward compatibility with file_ops.rs
340    pub async fn handle(&self, args: ReadFileArgs) -> Result<String> {
341        Ok(self.handle_detailed(args).await?.content)
342    }
343}
344
345#[async_trait]
346impl Tool for ReadFileHandler {
347    async fn execute(&self, args: Value) -> Result<Value> {
348        // Try batch mode first (has "reads" field)
349        if args.get("reads").is_some() {
350            let batch_args: BatchReadArgs = deserialize_tool_args(&args, "read_file")?;
351            return self.handle_batch(batch_args).await;
352        }
353
354        // Legacy single-file mode
355        let args: ReadFileArgs = deserialize_tool_args(&args, "read_file")?;
356
357        let file_path = args.file_path.clone();
358        let outcome = self.handle_detailed(args).await?;
359
360        let mut response = json!({
361            "content": outcome.content,
362            "file_path": file_path,
363            "path": file_path,
364            "success": true,
365            "no_spool": true
366        });
367
368        if outcome.has_more {
369            response["has_more"] = json!(true);
370        }
371        if outcome.line_truncated {
372            response["line_truncated"] = json!(true);
373            response["truncated"] = json!(true);
374            response["truncation_reason"] = json!("oversized_line");
375        }
376
377        Ok(response)
378    }
379
380    fn name(&self) -> &str {
381        "read_file"
382    }
383
384    fn description(&self) -> &str {
385        "Read file contents with optional line range, indentation-aware block selection, or batch multiple files"
386    }
387
388    fn parameter_schema(&self) -> Option<Value> {
389        Some(json!({
390            "type": "object",
391            "properties": {
392                "file_path": {
393                    "type": "string",
394                    "description": "Absolute path to the file to read (for single-file mode)"
395                },
396                "offset": {
397                    "type": "integer",
398                    "description": "1-indexed line number to start from (default: 1)",
399                    "default": 1,
400                    "minimum": 1
401                },
402                "limit": {
403                    "type": "integer",
404                    "description": "Maximum lines to return. Requests above the configurable absolute cap (`max_read_lines`) are clamped, and the response carries `next_read_args` to continue reading.",
405                    "default": crate::tools::read_limits::read_limit_lines(),
406                    "minimum": 1
407                },
408                "mode": {
409                    "type": "string",
410                    "enum": ["slice", "indentation"],
411                    "description": "Read mode: slice for simple range, indentation for block",
412                    "default": "slice"
413                },
414                "indentation": {
415                    "description": "Indentation settings when mode=indentation",
416                    "anyOf": [
417                        {"type": "boolean"},
418                        {
419                            "type": "object",
420                            "properties": {
421                                "anchor_line": {
422                                    "type": "integer",
423                                    "description": "Line number to anchor on (defaults to offset)"
424                                },
425                                "max_levels": {
426                                    "type": "integer",
427                                    "description": "Max indentation depth (0=unlimited)",
428                                    "default": 0
429                                },
430                                "include_siblings": {
431                                    "type": "boolean",
432                                    "description": "Include sibling blocks",
433                                    "default": false
434                                },
435                                "include_header": {
436                                    "type": "boolean",
437                                    "description": "Include header lines above anchor",
438                                    "default": true
439                                },
440                                "max_lines": {
441                                    "type": "integer",
442                                    "description": "Hard cap on returned lines"
443                                }
444                            }
445                        }
446                    ]
447                },
448                "max_tokens": {
449                    "type": "integer",
450                    "description": "Optional token limit for response (approximate)"
451                },
452                "condense": {
453                    "type": "boolean",
454                    "description": "Condense long outputs to head/tail (default: true)",
455                    "default": true
456                },
457                "offset_bytes": {
458                    "type": "integer",
459                    "description": "Byte offset (0-indexed) to start reading from. Enables byte-range read mode.",
460                    "minimum": 0
461                },
462                "page_size_bytes": {
463                    "type": "integer",
464                    "description": "Number of bytes to read. Accepts alias `length`. Default: 8192.",
465                    "alias": "length",
466                    "minimum": 1
467                },
468                "reads": {
469                    "type": "array",
470                    "description": "Batch mode: array of file read requests to execute in parallel",
471                    "items": {
472                        "type": "object",
473                        "properties": {
474                            "file_path": {
475                                "type": "string",
476                                "description": "Absolute path to the file"
477                            },
478                            "offset": {
479                                "type": "integer",
480                                "description": "1-indexed start line (default: 1)"
481                            },
482                            "limit": {
483                                "type": "integer",
484                                "description": "Max lines to return (default: 500 for batch)"
485                            },
486                            "ranges": {
487                                "type": "array",
488                                "description": "Multiple ranges from the same file",
489                                "items": {
490                                    "type": "object",
491                                    "properties": {
492                                        "offset": { "type": "integer" },
493                                        "limit": { "type": "integer" },
494                                        "mode": { "type": "string", "enum": ["slice", "indentation"] }
495                                    }
496                                }
497                            }
498                        },
499                        "required": ["file_path"]
500                    }
501                },
502                "max_concurrency": {
503                    "type": "integer",
504                    "description": "Batch mode: max concurrent file reads (default: 8)",
505                    "default": 8
506                }
507            }
508        }))
509    }
510}
511
512mod slice {
513    use super::*;
514
515    #[derive(Clone, Debug, PartialEq, Eq)]
516    pub(super) struct SliceReadResult {
517        pub lines: Vec<String>,
518        pub has_more: bool,
519        pub line_truncated: bool,
520    }
521
522    pub(super) struct SliceReadRanges {
523        pub results: Vec<Option<Result<SliceReadResult>>>,
524        pub error: Option<anyhow::Error>,
525    }
526
527    pub async fn read(path: &Path, offset: usize, limit: usize) -> Result<SliceReadResult> {
528        let file = File::open(path)
529            .await
530            .context(format!("failed to open file: {}", path.display()))?;
531
532        let mut reader = BufReader::new(file);
533        let mut collected = Vec::new();
534        let mut seen = 0usize;
535        let mut buffer = Vec::new();
536        let mut reached_eof = false;
537        let mut line_truncated = false;
538
539        loop {
540            let Some((line, truncated)) = read_formatted_line(&mut reader, &mut buffer).await? else {
541                reached_eof = true;
542                break;
543            };
544            line_truncated |= truncated;
545
546            seen = seen.saturating_add(1);
547
548            if seen < offset {
549                continue;
550            }
551
552            if collected.len() >= limit {
553                break;
554            }
555
556            collected.push(line);
557        }
558
559        // A successful empty read is necessary to inspect an existing empty
560        // file before its first edit. Higher offsets remain out of bounds.
561        if seen < offset && !(seen == 0 && offset == 1) {
562            anyhow::bail!("offset exceeds file length");
563        }
564
565        Ok(SliceReadResult {
566            lines: collected,
567            has_more: !reached_eof,
568            line_truncated,
569        })
570    }
571
572    pub(super) async fn read_ranges(path: &Path, ranges: &[ReadRange]) -> Result<SliceReadRanges> {
573        let file = File::open(path)
574            .await
575            .context(format!("failed to open file: {}", path.display()))?;
576
577        let normalized_ranges: Vec<(usize, usize)> = ranges
578            .iter()
579            .map(|range| {
580                let offset = range.offset.max(1);
581                let limit = ReadFileHandler::effective_line_limit(range.limit);
582                (offset, offset.saturating_add(limit.saturating_sub(1)))
583            })
584            .collect();
585        let max_line = normalized_ranges.iter().map(|(_, end)| *end).max().unwrap_or(0);
586        let mut collected: Vec<Vec<String>> = normalized_ranges.iter().map(|_| Vec::new()).collect();
587        let mut reader = BufReader::new(file);
588        let mut buffer = Vec::new();
589        let mut line_number = 0usize;
590        let mut has_lookahead = false;
591        let mut line_truncated = false;
592        let mut scan_error = None;
593
594        while line_number < max_line {
595            match read_formatted_line(&mut reader, &mut buffer).await {
596                Ok(Some((line, truncated))) => {
597                    line_truncated |= truncated;
598                    line_number += 1;
599                    for (index, (offset, end)) in normalized_ranges.iter().enumerate() {
600                        if (*offset..=*end).contains(&line_number) {
601                            collected[index].push(line.clone());
602                        }
603                    }
604                }
605                Ok(None) => break,
606                Err(error) => {
607                    scan_error = Some(error);
608                    break;
609                }
610            }
611        }
612
613        if scan_error.is_none() && line_number == max_line {
614            match read_formatted_line(&mut reader, &mut buffer).await {
615                Ok(Some((_, truncated))) => {
616                    line_truncated |= truncated;
617                    has_lookahead = true;
618                }
619                Ok(None) => {}
620                Err(error) => scan_error = Some(error),
621            }
622        }
623
624        let results = normalized_ranges
625            .into_iter()
626            .zip(collected)
627            .map(|((offset, end), lines)| {
628                if line_number < offset && !(line_number == 0 && offset == 1) {
629                    scan_error
630                        .is_none()
631                        .then_some(Err(anyhow::anyhow!("offset exceeds file length")))
632                } else if scan_error.is_some() && line_number < end {
633                    None
634                } else {
635                    Some(Ok(SliceReadResult {
636                        lines,
637                        has_more: line_number > end || (line_number == end && has_lookahead),
638                        line_truncated,
639                    }))
640                }
641            })
642            .collect();
643
644        Ok(SliceReadRanges { results, error: scan_error })
645    }
646    async fn read_formatted_line(reader: &mut BufReader<File>, buffer: &mut Vec<u8>) -> Result<Option<(String, bool)>> {
647        let Some(truncated) = read_bounded_line(reader, buffer).await.context("failed to read file")? else {
648            return Ok(None);
649        };
650
651        if buffer.last() == Some(&b'\n') {
652            buffer.pop();
653            if buffer.last() == Some(&b'\r') {
654                buffer.pop();
655            }
656        }
657
658        Ok(Some((format_line(buffer), truncated)))
659    }
660}
661
662mod indentation {
663    use super::*;
664
665    pub(super) struct IndentationReadResult {
666        pub lines: Vec<String>,
667        pub line_truncated: bool,
668    }
669
670    pub async fn read_block(
671        path: &Path,
672        offset: usize,
673        limit: usize,
674        options: IndentationArgs,
675    ) -> Result<IndentationReadResult> {
676        let anchor_line = options.anchor_line.unwrap_or(offset);
677        anyhow::ensure!(anchor_line > 0, "anchor_line must be a 1-indexed line number");
678
679        let guard_limit = options.max_lines.unwrap_or(limit);
680        anyhow::ensure!(guard_limit > 0, "max_lines must be greater than zero");
681
682        // Bound the collection to the window the block expansion can ever use:
683        // the anchor plus the downward guard limit. The algorithm expands at
684        // most `final_limit` lines down from the anchor and needs the full
685        // prefix above it, so `anchor + guard_limit` is always sufficient and
686        // prevents an unbounded full-file read of gigantic files.
687        let collect_cap = anchor_line.saturating_add(guard_limit);
688        let (collected, line_truncated) = collect_file_lines(path, collect_cap).await?;
689        anyhow::ensure!(!collected.is_empty() && anchor_line <= collected.len(), "anchor_line exceeds file length");
690
691        let anchor_index = anchor_line - 1;
692        let effective_indents = compute_effective_indents(&collected);
693        let anchor_indent = effective_indents[anchor_index];
694
695        // Compute the min indent
696        let min_indent = if options.max_levels == 0 {
697            0
698        } else {
699            anchor_indent.saturating_sub(options.max_levels * TAB_WIDTH)
700        };
701
702        // Cap requested lines by guard_limit and file length
703        let final_limit = limit.min(guard_limit).min(collected.len());
704
705        if final_limit == 1 {
706            return Ok(IndentationReadResult {
707                lines: vec![format!(
708                    "{}: {}",
709                    collected[anchor_index].number, collected[anchor_index].display
710                )],
711                line_truncated,
712            });
713        }
714
715        // Bidirectional cursors
716        let mut i: isize = anchor_index as isize - 1; // up
717        let mut j: usize = anchor_index + 1; // down
718        let mut i_counter_min_indent = 0;
719        let mut j_counter_min_indent = 0;
720
721        let mut out = VecDeque::with_capacity(limit);
722        out.push_back(&collected[anchor_index]);
723
724        while out.len() < final_limit {
725            let mut progressed = 0;
726
727            // Expand upward
728            if i >= 0 {
729                #[allow(
730                    clippy::cast_sign_loss,
731                    reason = "Intentional compatibility, platform, or test-only suppression."
732                )]
733                let iu = i as usize;
734                if effective_indents[iu] >= min_indent {
735                    out.push_front(&collected[iu]);
736                    progressed += 1;
737                    i -= 1;
738
739                    // Control sibling inclusion
740                    if effective_indents[iu] == min_indent && !options.include_siblings {
741                        let allow_header_comment = options.include_header && collected[iu].is_comment();
742                        let can_take_line = allow_header_comment || i_counter_min_indent == 0;
743
744                        if can_take_line {
745                            i_counter_min_indent += 1;
746                        } else {
747                            out.pop_front();
748                            progressed -= 1;
749                            i = -1;
750                        }
751                    }
752
753                    if out.len() >= final_limit {
754                        break;
755                    }
756                } else {
757                    i = -1;
758                }
759            }
760
761            // Expand downward
762            if j < collected.len() {
763                let ju = j;
764                if effective_indents[ju] >= min_indent {
765                    out.push_back(&collected[ju]);
766                    progressed += 1;
767                    j += 1;
768
769                    // Control sibling inclusion
770                    if effective_indents[ju] == min_indent && !options.include_siblings {
771                        if j_counter_min_indent > 0 {
772                            out.pop_back();
773                            progressed -= 1;
774                            j = collected.len();
775                        }
776                        j_counter_min_indent += 1;
777                    }
778                } else {
779                    j = collected.len();
780                }
781            }
782
783            if progressed == 0 {
784                break;
785            }
786        }
787
788        trim_empty_lines(&mut out);
789
790        Ok(IndentationReadResult {
791            lines: out
792                .into_iter()
793                .map(|record| format!("{}: {}", record.number, record.display))
794                .collect(),
795            line_truncated,
796        })
797    }
798
799    async fn collect_file_lines(path: &Path, max_lines: usize) -> Result<(Vec<LineRecord>, bool)> {
800        let file = File::open(path)
801            .await
802            .context(format!("failed to open file: {}", path.display()))?;
803
804        let mut reader = BufReader::new(file);
805        let mut buffer = Vec::new();
806        let mut lines = Vec::new();
807        let mut number = 0usize;
808        let mut line_truncated = false;
809
810        while lines.len() < max_lines {
811            let Some(truncated) = read_bounded_line(&mut reader, &mut buffer)
812                .await
813                .context("failed to read file")?
814            else {
815                break;
816            };
817            line_truncated |= truncated;
818
819            if buffer.last() == Some(&b'\n') {
820                buffer.pop();
821                if buffer.last() == Some(&b'\r') {
822                    buffer.pop();
823                }
824            }
825
826            number += 1;
827            let raw = String::from_utf8_lossy(&buffer).into_owned();
828            let indent = measure_indent(&raw);
829            let display = format_line(&buffer);
830            lines.push(LineRecord { number, raw, display, indent });
831        }
832
833        Ok((lines, line_truncated))
834    }
835
836    fn compute_effective_indents(records: &[LineRecord]) -> Vec<usize> {
837        let mut effective = Vec::with_capacity(records.len());
838        let mut previous_indent = 0usize;
839        for record in records {
840            if record.is_blank() {
841                effective.push(previous_indent);
842            } else {
843                previous_indent = record.indent;
844                effective.push(previous_indent);
845            }
846        }
847        effective
848    }
849
850    fn measure_indent(line: &str) -> usize {
851        line.chars()
852            .take_while(|c| matches!(c, ' ' | '\t'))
853            .map(|c| if c == '\t' { TAB_WIDTH } else { 1 })
854            .sum()
855    }
856}
857
858mod defaults {
859    pub fn offset() -> usize {
860        1
861    }
862
863    pub fn limit() -> usize {
864        crate::tools::read_limits::read_limit_lines()
865    }
866
867    pub fn batch_limit() -> usize {
868        500
869    }
870
871    pub fn max_concurrency() -> usize {
872        super::DEFAULT_MAX_CONCURRENCY
873    }
874
875    pub fn ui_progress() -> bool {
876        true
877    }
878
879    pub fn max_levels() -> usize {
880        0
881    }
882
883    pub fn include_siblings() -> bool {
884        false
885    }
886
887    pub fn include_header() -> bool {
888        true
889    }
890
891    pub fn condense() -> bool {
892        true
893    }
894}
895
896#[cfg(test)]
897mod tests {
898    #[expect(
899        unused_imports,
900        reason = "Intentional compatibility, platform, test, or API-shape suppression."
901    )]
902    use super::indentation::*;
903    use super::lines::condense_for_batch;
904    use super::slice::*;
905    use super::*;
906    use std::io::Write;
907    use tempfile::NamedTempFile;
908
909    #[tokio::test]
910    async fn reads_requested_range() -> Result<()> {
911        let mut temp = NamedTempFile::new()?;
912        writeln!(temp, "alpha")?;
913        writeln!(temp, "beta")?;
914        writeln!(temp, "gamma")?;
915
916        let lines = read(temp.path(), 2, 2).await?.lines;
917        assert_eq!(lines, vec!["beta".to_string(), "gamma".to_string()]);
918        Ok(())
919    }
920
921    #[test]
922    fn read_file_args_accepts_boolean_indentation() {
923        let args = json!({
924            "file_path": "/tmp/example.txt",
925            "mode": "slice",
926            "indentation": false
927        });
928
929        let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
930        assert!(matches!(parsed.mode, ReadMode::Slice));
931        assert!(parsed.indentation.is_none());
932    }
933
934    #[test]
935    fn read_file_args_accepts_true_indentation() {
936        let args = json!({
937            "file_path": "/tmp/example.txt",
938            "mode": "indentation",
939            "indentation": true
940        });
941
942        let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
943        assert!(matches!(parsed.mode, ReadMode::Indentation));
944        assert!(parsed.indentation.is_some());
945    }
946
947    #[test]
948    fn read_file_args_accepts_empty_mode() {
949        let args = json!({
950            "file_path": "/tmp/example.txt",
951            "mode": ""
952        });
953
954        let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
955        assert!(matches!(parsed.mode, ReadMode::Slice));
956    }
957
958    #[tokio::test]
959    async fn read_file_handler_skips_condense_when_disabled() -> Result<()> {
960        let mut temp = NamedTempFile::new()?;
961        for idx in 0..60 {
962            writeln!(temp, "line-{idx}")?;
963        }
964
965        let args = ReadFileArgs {
966            file_path: temp.path().to_string_lossy().to_string(),
967            offset: 1,
968            limit: 2000,
969            mode: ReadMode::Slice,
970            indentation: None,
971            max_tokens: None,
972            condense: false,
973            offset_bytes: None,
974            page_size_bytes: None,
975        };
976        let handler = ReadFileHandler;
977        let content = handler.handle(args).await?;
978
979        assert!(!content.contains("lines omitted"));
980        assert_eq!(content.lines().count(), 60);
981        Ok(())
982    }
983
984    #[tokio::test]
985    async fn read_file_handler_skips_condense_for_paginated_read() -> Result<()> {
986        // Regression: when the caller follows an omit-hint and reads a
987        // specific slice (offset > 1), the handler must NOT re-condense the
988        // result. Re-condensing would emit another omit-hint and trap the
989        // agent in an infinite pagination regress (checkpoint turn_613).
990        let mut temp = NamedTempFile::new()?;
991        for idx in 1..=300 {
992            writeln!(temp, "line-{idx}")?;
993        }
994
995        let args = ReadFileArgs {
996            file_path: temp.path().to_string_lossy().to_string(),
997            offset: 81,
998            limit: 229,
999            mode: ReadMode::Slice,
1000            indentation: None,
1001            max_tokens: None,
1002            condense: true,
1003            offset_bytes: None,
1004            page_size_bytes: None,
1005        };
1006        let handler = ReadFileHandler;
1007        let content = handler.handle(args).await?;
1008
1009        // No re-condensation: the requested slice is returned verbatim.
1010        assert!(!content.contains("lines omitted"), "paginated read must not be re-condensed; got: {content}");
1011        // The slice starts at line 81 (1-indexed), so line-81 must be present.
1012        assert!(
1013            content.contains("line-81"),
1014            "paginated read must include the requested offset; got first line: {:?}",
1015            content.lines().next()
1016        );
1017        Ok(())
1018    }
1019
1020    #[tokio::test]
1021    async fn read_file_handler_still_condenses_default_overview_read() -> Result<()> {
1022        // The default overview path (offset == 1) must still condense large
1023        // outputs so the catalog stays bounded. This guards against the fix
1024        // over-correcting and never condensing.
1025        let mut temp = NamedTempFile::new()?;
1026        for idx in 1..=300 {
1027            writeln!(temp, "line-{idx}")?;
1028        }
1029
1030        let args = ReadFileArgs {
1031            file_path: temp.path().to_string_lossy().to_string(),
1032            offset: 1,
1033            limit: 2000,
1034            mode: ReadMode::Slice,
1035            indentation: None,
1036            max_tokens: None,
1037            condense: true,
1038            offset_bytes: None,
1039            page_size_bytes: None,
1040        };
1041        let handler = ReadFileHandler;
1042        let content = handler.handle(args).await?;
1043
1044        assert!(
1045            content.contains("lines omitted"),
1046            "default overview read must condense large output; got {} lines",
1047            content.lines().count()
1048        );
1049        Ok(())
1050    }
1051
1052    #[tokio::test]
1053    async fn empty_file_read_succeeds_at_start_and_rejects_higher_offsets() -> Result<()> {
1054        let temp = NamedTempFile::new()?;
1055        let result = read(temp.path(), 1, 2).await?;
1056        assert!(result.lines.is_empty());
1057        assert!(!result.has_more);
1058        assert!(read(temp.path(), 2, 2).await.is_err());
1059        let ranges = vec![
1060            ReadRange {
1061                offset: 1,
1062                limit: 2,
1063                mode: ReadMode::Slice,
1064                indentation: None,
1065            },
1066            ReadRange {
1067                offset: 2,
1068                limit: 2,
1069                mode: ReadMode::Slice,
1070                indentation: None,
1071            },
1072        ];
1073        let result = read_ranges(temp.path(), &ranges).await?;
1074        assert!(result.results[0].as_ref().unwrap().as_ref().unwrap().lines.is_empty());
1075        assert!(result.results[1].as_ref().unwrap().is_err());
1076        Ok(())
1077    }
1078
1079    #[tokio::test]
1080    async fn errors_when_offset_exceeds_length() {
1081        let mut temp = NamedTempFile::new().unwrap();
1082        writeln!(temp, "only").unwrap();
1083
1084        let err = read(temp.path(), 3, 1).await;
1085        err.unwrap_err();
1086    }
1087
1088    #[tokio::test]
1089    async fn reads_non_utf8_lines() -> Result<()> {
1090        let mut temp = NamedTempFile::new()?;
1091        temp.as_file_mut().write_all(b"\xff\xfe\nplain\n")?;
1092
1093        let lines = read(temp.path(), 1, 2).await?.lines;
1094        let expected_first = format!("{}{}", '\u{FFFD}', '\u{FFFD}');
1095        assert_eq!(lines, vec![expected_first, "plain".to_string()]);
1096        Ok(())
1097    }
1098
1099    #[tokio::test]
1100    async fn reports_oversized_physical_lines_as_truncated() -> Result<()> {
1101        let mut temp = NamedTempFile::new()?;
1102        let long_line = "x".repeat(crate::tools::file_ops::MAX_LINE_READ_BYTES + 1);
1103        writeln!(temp, "{long_line}")?;
1104        writeln!(temp, "plain")?;
1105
1106        let outcome = ReadFileHandler
1107            .handle_detailed(ReadFileArgs {
1108                file_path: temp.path().to_string_lossy().into_owned(),
1109                offset: 1,
1110                limit: 2,
1111                mode: ReadMode::Slice,
1112                indentation: None,
1113                max_tokens: None,
1114                condense: false,
1115                offset_bytes: None,
1116                page_size_bytes: None,
1117            })
1118            .await?;
1119
1120        assert!(outcome.line_truncated);
1121        assert!(outcome.content.contains("plain"));
1122        Ok(())
1123    }
1124
1125    #[tokio::test]
1126    async fn trims_crlf_endings() -> Result<()> {
1127        let mut temp = NamedTempFile::new()?;
1128        write!(temp, "one\r\ntwo\r\n")?;
1129
1130        let lines = read(temp.path(), 1, 2).await?.lines;
1131        assert_eq!(lines, vec!["one".to_string(), "two".to_string()]);
1132        Ok(())
1133    }
1134
1135    #[tokio::test]
1136    async fn respects_limit_even_with_more_lines() -> Result<()> {
1137        let mut temp = NamedTempFile::new()?;
1138        writeln!(temp, "first")?;
1139        writeln!(temp, "second")?;
1140        writeln!(temp, "third")?;
1141
1142        let result = read(temp.path(), 1, 2).await?;
1143        assert_eq!(result.lines, vec!["first".to_string(), "second".to_string()]);
1144        assert!(result.has_more);
1145        Ok(())
1146    }
1147
1148    #[tokio::test]
1149    async fn reads_exact_limit_without_continuation_at_eof() -> Result<()> {
1150        let mut temp = NamedTempFile::new()?;
1151        writeln!(temp, "first")?;
1152        writeln!(temp, "second")?;
1153
1154        let result = read(temp.path(), 1, 2).await?;
1155        assert_eq!(result.lines, vec!["first".to_string(), "second".to_string()]);
1156        assert!(!result.has_more);
1157        Ok(())
1158    }
1159
1160    #[test]
1161    fn clamp_to_absolute_cap_marks_only_true_overages() {
1162        // A request above the ceiling is clamped and flagged.
1163        assert_eq!(ReadFileHandler::clamp_to_absolute_cap(1000, 400), (400, true));
1164        // A request exactly at the ceiling sits at the cap but is not "clamped"
1165        // (this is what keeps the pagination chain alive on follow-up pages).
1166        assert_eq!(ReadFileHandler::clamp_to_absolute_cap(400, 400), (400, false));
1167        // A request below the ceiling is unchanged and not flagged.
1168        assert_eq!(ReadFileHandler::clamp_to_absolute_cap(50, 400), (50, false));
1169    }
1170
1171    #[tokio::test]
1172    async fn absolute_cap_clamps_large_request() -> Result<()> {
1173        let mut temp = NamedTempFile::new()?;
1174        for idx in 1..=1000 {
1175            writeln!(temp, "line-{idx}")?;
1176        }
1177
1178        let handler = ReadFileHandler;
1179        let args = ReadFileArgs {
1180            file_path: temp.path().to_string_lossy().to_string(),
1181            offset: 1,
1182            limit: 2000,
1183            mode: ReadMode::Slice,
1184            indentation: None,
1185            max_tokens: None,
1186            condense: false,
1187            offset_bytes: None,
1188            page_size_bytes: None,
1189        };
1190        let outcome = handler.handle_detailed(args).await?;
1191
1192        let cap = crate::tools::read_limits::read_limit_lines();
1193        assert!(outcome.capped_by_limit, "request larger than the cap must be clamped");
1194        assert_eq!(outcome.applied_limit, cap);
1195        assert!(outcome.has_more);
1196        assert!(outcome.lines_read <= cap);
1197        Ok(())
1198    }
1199
1200    #[tokio::test]
1201    async fn explicit_limit_under_cap_is_preserved() -> Result<()> {
1202        let mut temp = NamedTempFile::new()?;
1203        for idx in 1..=100 {
1204            writeln!(temp, "line-{idx}")?;
1205        }
1206
1207        let handler = ReadFileHandler;
1208        let args = ReadFileArgs {
1209            file_path: temp.path().to_string_lossy().to_string(),
1210            offset: 1,
1211            limit: 50,
1212            mode: ReadMode::Slice,
1213            indentation: None,
1214            max_tokens: None,
1215            condense: false,
1216            offset_bytes: None,
1217            page_size_bytes: None,
1218        };
1219        let outcome = handler.handle_detailed(args).await?;
1220
1221        assert!(!outcome.capped_by_limit);
1222        // Small limits are expanded to MIN_BATCH_LIMIT (200) by the batch path,
1223        // so the whole 100-line file is returned, but it is not cap-clamped.
1224        assert_eq!(outcome.lines_read, 100);
1225        Ok(())
1226    }
1227
1228    #[tokio::test]
1229    async fn truncates_lines_longer_than_max_length() -> Result<()> {
1230        let mut temp = NamedTempFile::new()?;
1231        let long_line = "x".repeat(MAX_LINE_LENGTH + 50);
1232        writeln!(temp, "{long_line}")?;
1233
1234        let lines = read(temp.path(), 1, 1).await?.lines;
1235        let expected = "x".repeat(MAX_LINE_LENGTH);
1236        assert_eq!(lines, vec![expected]);
1237        Ok(())
1238    }
1239
1240    #[tokio::test]
1241    async fn batch_reads_multiple_files() -> Result<()> {
1242        let mut temp1 = NamedTempFile::new()?;
1243        writeln!(temp1, "file1_line1")?;
1244        writeln!(temp1, "file1_line2")?;
1245
1246        let mut temp2 = NamedTempFile::new()?;
1247        writeln!(temp2, "file2_line1")?;
1248        writeln!(temp2, "file2_line2")?;
1249
1250        let handler = ReadFileHandler;
1251        let args = BatchReadArgs {
1252            reads: vec![
1253                BatchReadRequest {
1254                    file_path: temp1.path().to_string_lossy().to_string(),
1255                    range: None,
1256                    ranges: None,
1257                },
1258                BatchReadRequest {
1259                    file_path: temp2.path().to_string_lossy().to_string(),
1260                    range: None,
1261                    ranges: None,
1262                },
1263            ],
1264            max_concurrency: 2,
1265            ui_progress: false,
1266        };
1267
1268        let result = handler.handle_batch(args).await?;
1269        assert_eq!(result["success"], true);
1270        assert_eq!(result["files_read"], 2);
1271        assert_eq!(result["files_succeeded"], 2);
1272
1273        let content = result["content"].as_str().unwrap();
1274        assert!(content.contains("file1_line1"));
1275        assert!(content.contains("file2_line1"));
1276        Ok(())
1277    }
1278
1279    #[tokio::test]
1280    async fn batch_rejects_zero_max_concurrency() -> Result<()> {
1281        let handler = ReadFileHandler;
1282        let args = BatchReadArgs {
1283            reads: vec![BatchReadRequest {
1284                file_path: "/tmp/example.txt".to_string(),
1285                range: None,
1286                ranges: None,
1287            }],
1288            max_concurrency: 0,
1289            ui_progress: false,
1290        };
1291
1292        let error = handler.handle_batch(args).await.expect_err("zero must be rejected");
1293        assert!(error.to_string().contains("max_concurrency must be greater than zero"));
1294        Ok(())
1295    }
1296
1297    #[tokio::test]
1298    async fn batch_caps_excessive_max_concurrency() -> Result<()> {
1299        let mut temp = NamedTempFile::new()?;
1300        writeln!(temp, "line")?;
1301
1302        let handler = ReadFileHandler;
1303        let args = BatchReadArgs {
1304            reads: vec![BatchReadRequest {
1305                file_path: temp.path().to_string_lossy().to_string(),
1306                range: None,
1307                ranges: None,
1308            }],
1309            max_concurrency: usize::MAX,
1310            ui_progress: false,
1311        };
1312
1313        let result = handler.handle_batch(args).await?;
1314        assert_eq!(result["success"], true);
1315        assert_eq!(result["files_succeeded"], 1);
1316        Ok(())
1317    }
1318
1319    #[tokio::test]
1320    async fn batch_preserves_input_order_with_duplicate_requests() -> Result<()> {
1321        let mut first = NamedTempFile::new()?;
1322        writeln!(first, "first")?;
1323        let mut second = NamedTempFile::new()?;
1324        writeln!(second, "second")?;
1325
1326        let first_path = first.path().to_string_lossy().to_string();
1327        let second_path = second.path().to_string_lossy().to_string();
1328        let handler = ReadFileHandler;
1329        let args = BatchReadArgs {
1330            reads: vec![
1331                BatchReadRequest {
1332                    file_path: second_path.clone(),
1333                    range: None,
1334                    ranges: None,
1335                },
1336                BatchReadRequest {
1337                    file_path: first_path.clone(),
1338                    range: None,
1339                    ranges: None,
1340                },
1341                BatchReadRequest { file_path: second_path, range: None, ranges: None },
1342            ],
1343            max_concurrency: usize::MAX,
1344            ui_progress: false,
1345        };
1346
1347        let result = handler.handle_batch(args).await?;
1348        let items = result["items"].as_array().context("batch items must be an array")?;
1349        assert_eq!(items.len(), 3);
1350        assert_eq!(items[0]["ranges"][0]["content"], "second");
1351        assert_eq!(items[1]["ranges"][0]["content"], "first");
1352        assert_eq!(items[2]["ranges"][0]["content"], "second");
1353        let content = result["content"].as_str().context("batch content must be a string")?;
1354        assert!(content.find("second") < content.find("first"));
1355        Ok(())
1356    }
1357
1358    #[tokio::test]
1359    async fn batch_reads_multiple_ranges_from_same_file() -> Result<()> {
1360        let mut temp = NamedTempFile::new()?;
1361        for i in 1..=20 {
1362            writeln!(temp, "line{i}")?;
1363        }
1364
1365        let handler = ReadFileHandler;
1366        let args = BatchReadArgs {
1367            reads: vec![BatchReadRequest {
1368                file_path: temp.path().to_string_lossy().to_string(),
1369                range: None,
1370                ranges: Some(vec![
1371                    ReadRange {
1372                        offset: 1,
1373                        limit: 3,
1374                        mode: ReadMode::Slice,
1375                        indentation: None,
1376                    },
1377                    ReadRange {
1378                        offset: 10,
1379                        limit: 3,
1380                        mode: ReadMode::Slice,
1381                        indentation: None,
1382                    },
1383                ]),
1384            }],
1385            max_concurrency: 4,
1386            ui_progress: false,
1387        };
1388
1389        let result = handler.handle_batch(args).await?;
1390        assert_eq!(result["success"], true);
1391
1392        let items = result["items"].as_array().unwrap();
1393        assert_eq!(items.len(), 1);
1394
1395        let ranges = items[0]["ranges"].as_array().unwrap();
1396        assert_eq!(ranges.len(), 2);
1397        assert_eq!(ranges[0]["offset"], 1);
1398        assert_eq!(ranges[1]["offset"], 10);
1399        assert_eq!(ranges[0]["content"], "line1\nline2\nline3");
1400        assert_eq!(ranges[1]["content"], "line10\nline11\nline12");
1401        Ok(())
1402    }
1403
1404    #[tokio::test]
1405    async fn batch_range_is_capped_by_absolute_line_limit() -> Result<()> {
1406        let cap = crate::tools::read_limits::absolute_line_cap();
1407        let mut temp = NamedTempFile::new()?;
1408        for i in 1..=cap + 1 {
1409            writeln!(temp, "line{i}")?;
1410        }
1411
1412        let handler = ReadFileHandler;
1413        let result = handler
1414            .handle_batch(BatchReadArgs {
1415                reads: vec![BatchReadRequest {
1416                    file_path: temp.path().to_string_lossy().to_string(),
1417                    range: Some(ReadRange {
1418                        offset: 1,
1419                        limit: cap + 1,
1420                        mode: ReadMode::Slice,
1421                        indentation: None,
1422                    }),
1423                    ranges: None,
1424                }],
1425                max_concurrency: 1,
1426                ui_progress: false,
1427            })
1428            .await?;
1429
1430        let range = &result["items"][0]["ranges"][0];
1431        assert_eq!(range["lines_read"], cap);
1432
1433        let content = range["content"].as_str().context("batch range content must be a string")?;
1434        assert!(content.ends_with(&format!("line{cap}")));
1435        assert!(!content.contains(&format!("line{}", cap + 1)));
1436        Ok(())
1437    }
1438
1439    #[tokio::test]
1440    async fn batch_preserves_completed_ranges_before_a_later_error() -> Result<()> {
1441        let mut temp = NamedTempFile::new()?;
1442        writeln!(temp, "line1")?;
1443        writeln!(temp, "line2")?;
1444
1445        let handler = ReadFileHandler;
1446        let args = BatchReadArgs {
1447            reads: vec![BatchReadRequest {
1448                file_path: temp.path().to_string_lossy().to_string(),
1449                range: None,
1450                ranges: Some(vec![
1451                    ReadRange {
1452                        offset: 1,
1453                        limit: 1,
1454                        mode: ReadMode::Slice,
1455                        indentation: None,
1456                    },
1457                    ReadRange {
1458                        offset: 10,
1459                        limit: 1,
1460                        mode: ReadMode::Slice,
1461                        indentation: None,
1462                    },
1463                ]),
1464            }],
1465            max_concurrency: 1,
1466            ui_progress: false,
1467        };
1468
1469        let result = handler.handle_batch(args).await?;
1470        assert_eq!(result["success"], false);
1471        assert_eq!(result["items"][0]["ranges"].as_array().map(Vec::len), Some(1));
1472        assert_eq!(result["items"][0]["ranges"][0]["content"], "line1");
1473        assert!(result["items"][0]["error"].as_str().is_some());
1474        Ok(())
1475    }
1476
1477    #[tokio::test]
1478    async fn batch_handles_missing_file_gracefully() -> Result<()> {
1479        let handler = ReadFileHandler;
1480        let args = BatchReadArgs {
1481            reads: vec![BatchReadRequest {
1482                file_path: "/nonexistent/path/file.txt".to_string(),
1483                range: None,
1484                ranges: None,
1485            }],
1486            max_concurrency: 1,
1487            ui_progress: false,
1488        };
1489
1490        let result = handler.handle_batch(args).await?;
1491        assert_eq!(result["success"], false);
1492
1493        let items = result["items"].as_array().unwrap();
1494        assert!(items[0]["error"].as_str().is_some());
1495        Ok(())
1496    }
1497
1498    #[test]
1499    fn condense_for_batch_preserves_small_outputs() {
1500        let mut lines: Vec<String> = (1..=20).map(|i| format!("line{i}")).collect();
1501        let (condensed, omitted) = condense_for_batch(&mut lines);
1502        assert!(!condensed);
1503        assert_eq!(omitted, 0);
1504        assert_eq!(lines.len(), 20);
1505    }
1506
1507    #[test]
1508    fn condense_for_batch_condenses_large_outputs() {
1509        let mut lines: Vec<String> = (1..=200).map(|i| format!("line{i}")).collect();
1510        let (condensed, omitted) = condense_for_batch(&mut lines);
1511        assert!(condensed);
1512        assert!(omitted > 0);
1513        assert!(lines.len() < 200);
1514        assert!(lines.iter().any(|l| l.contains("omitted")));
1515    }
1516
1517    #[test]
1518    fn condense_for_batch_does_not_treat_plus_minus_text_as_diff() {
1519        let mut lines: Vec<String> = (1..=150)
1520            .map(|i| {
1521                if i % 2 == 0 {
1522                    format!("+ normal status line {i}")
1523                } else {
1524                    format!("- normal status line {i}")
1525                }
1526            })
1527            .collect();
1528        let (condensed, omitted) = condense_for_batch(&mut lines);
1529        assert!(condensed);
1530        assert!(omitted > 0);
1531    }
1532
1533    #[test]
1534    fn condense_for_batch_preserves_actual_diff_output() {
1535        let mut lines = vec![
1536            "diff --git a/src/main.rs b/src/main.rs".to_string(),
1537            "index 1111111..2222222 100644".to_string(),
1538            "--- a/src/main.rs".to_string(),
1539            "+++ b/src/main.rs".to_string(),
1540            "@@ -1 +1 @@".to_string(),
1541            "-old".to_string(),
1542            "+new".to_string(),
1543        ];
1544        let (condensed, omitted) = condense_for_batch(&mut lines);
1545        assert!(!condensed);
1546        assert_eq!(omitted, 0);
1547    }
1548
1549    // --- Byte-range read tests ---
1550
1551    #[tokio::test]
1552    async fn byte_range_reads_first_bytes() -> Result<()> {
1553        let mut temp = NamedTempFile::new()?;
1554        temp.as_file_mut().write_all(b"Hello, World!")?;
1555
1556        let handler = ReadFileHandler;
1557        let args = ReadFileArgs {
1558            file_path: temp.path().to_string_lossy().to_string(),
1559            offset: 1,
1560            limit: 2000,
1561            mode: ReadMode::Slice,
1562            indentation: None,
1563            max_tokens: None,
1564            condense: false,
1565            offset_bytes: Some(0),
1566            page_size_bytes: Some(5),
1567        };
1568        let outcome = handler.handle_detailed(args).await?;
1569        // Content is line-numbered: "1: Hello"
1570        assert_eq!(outcome.content, "1: Hello");
1571        assert!(outcome.has_more);
1572        Ok(())
1573    }
1574
1575    #[tokio::test]
1576    async fn byte_range_reads_with_offset() -> Result<()> {
1577        let mut temp = NamedTempFile::new()?;
1578        writeln!(temp, "Hello, World!")?;
1579        writeln!(temp, "Second line")?;
1580
1581        let handler = ReadFileHandler;
1582        // Seek past "Hello, World!\n" (14 bytes) to start of second line
1583        let args = ReadFileArgs {
1584            file_path: temp.path().to_string_lossy().to_string(),
1585            offset: 1,
1586            limit: 2000,
1587            mode: ReadMode::Slice,
1588            indentation: None,
1589            max_tokens: None,
1590            condense: false,
1591            offset_bytes: Some(14),
1592            page_size_bytes: Some(50),
1593        };
1594        let outcome = handler.handle_detailed(args).await?;
1595        // Should start at line 2
1596        assert!(outcome.content.contains("2: Second line"));
1597        assert!(!outcome.has_more);
1598        Ok(())
1599    }
1600
1601    #[tokio::test]
1602    async fn byte_range_beyond_eof_returns_empty() -> Result<()> {
1603        let mut temp = NamedTempFile::new()?;
1604        temp.as_file_mut().write_all(b"short")?;
1605
1606        let handler = ReadFileHandler;
1607        let args = ReadFileArgs {
1608            file_path: temp.path().to_string_lossy().to_string(),
1609            offset: 1,
1610            limit: 2000,
1611            mode: ReadMode::Slice,
1612            indentation: None,
1613            max_tokens: None,
1614            condense: false,
1615            offset_bytes: Some(100),
1616            page_size_bytes: Some(10),
1617        };
1618        let outcome = handler.handle_detailed(args).await?;
1619        assert_eq!(outcome.content, "");
1620        assert!(!outcome.has_more);
1621        Ok(())
1622    }
1623
1624    #[tokio::test]
1625    async fn byte_range_clamps_to_file_size() -> Result<()> {
1626        // Multi-line file so we can test partial-line skipping
1627        let mut temp = NamedTempFile::new()?;
1628        writeln!(temp, "first line")?;
1629        writeln!(temp, "second line")?;
1630        writeln!(temp, "third line")?;
1631
1632        let handler = ReadFileHandler;
1633        let args = ReadFileArgs {
1634            file_path: temp.path().to_string_lossy().to_string(),
1635            offset: 1,
1636            limit: 2000,
1637            mode: ReadMode::Slice,
1638            indentation: None,
1639            max_tokens: None,
1640            condense: false,
1641            offset_bytes: Some(0),
1642            page_size_bytes: Some(100), // Request more than available
1643        };
1644        let outcome = handler.handle_detailed(args).await?;
1645        // Should have line numbers for all three lines
1646        assert!(outcome.content.contains("1: first line"));
1647        assert!(outcome.content.contains("2: second line"));
1648        assert!(outcome.content.contains("3: third line"));
1649        assert!(!outcome.has_more);
1650        Ok(())
1651    }
1652
1653    #[tokio::test]
1654    async fn byte_range_reads_non_utf8_lossy() -> Result<()> {
1655        let mut temp = NamedTempFile::new()?;
1656        temp.as_file_mut().write_all(b"\xff\xfeplain")?;
1657
1658        let handler = ReadFileHandler;
1659        let args = ReadFileArgs {
1660            file_path: temp.path().to_string_lossy().to_string(),
1661            offset: 1,
1662            limit: 2000,
1663            mode: ReadMode::Slice,
1664            indentation: None,
1665            max_tokens: None,
1666            condense: false,
1667            offset_bytes: Some(0),
1668            page_size_bytes: Some(7), // Read all 7 bytes
1669        };
1670        let outcome = handler.handle_detailed(args).await?;
1671        // Lossy conversion replaces invalid UTF-8 with U+FFFD
1672        assert!(outcome.content.contains('\u{FFFD}'));
1673        assert!(outcome.content.contains("plain"));
1674        Ok(())
1675    }
1676
1677    #[tokio::test]
1678    async fn byte_range_has_more_when_not_at_eof() -> Result<()> {
1679        let mut temp = NamedTempFile::new()?;
1680        writeln!(temp, "line one")?;
1681        writeln!(temp, "line two")?;
1682        writeln!(temp, "line three")?;
1683
1684        let handler = ReadFileHandler;
1685        // Read only first 10 bytes - should get first line only
1686        let args = ReadFileArgs {
1687            file_path: temp.path().to_string_lossy().to_string(),
1688            offset: 1,
1689            limit: 2000,
1690            mode: ReadMode::Slice,
1691            indentation: None,
1692            max_tokens: None,
1693            condense: false,
1694            offset_bytes: Some(0),
1695            page_size_bytes: Some(10),
1696        };
1697        let outcome = handler.handle_detailed(args).await?;
1698        // Should have line number
1699        assert!(outcome.content.contains("1: line one"));
1700        assert!(outcome.has_more);
1701        Ok(())
1702    }
1703
1704    #[tokio::test]
1705    async fn byte_range_defaults_to_8192_when_length_omitted() -> Result<()> {
1706        // Create a file large enough that 8192 bytes doesn't cover everything
1707        let mut temp = NamedTempFile::new()?;
1708        for i in 1..=500 {
1709            writeln!(temp, "line-{i}: {}", "x".repeat(20))?;
1710        }
1711
1712        let handler = ReadFileHandler;
1713        let args = ReadFileArgs {
1714            file_path: temp.path().to_string_lossy().to_string(),
1715            offset: 1,
1716            limit: 2000,
1717            mode: ReadMode::Slice,
1718            indentation: None,
1719            max_tokens: None,
1720            condense: false,
1721            offset_bytes: Some(0),
1722            page_size_bytes: None, // Should default to 8192
1723        };
1724        let outcome = handler.handle_detailed(args).await?;
1725        // Content should have line numbers
1726        assert!(outcome.content.contains("1: line-1"));
1727        assert!(outcome.has_more);
1728        Ok(())
1729    }
1730
1731    #[test]
1732    fn byte_range_args_from_json() {
1733        let args = json!({
1734            "file_path": "/tmp/test.bin",
1735            "offset_bytes": 1024,
1736            "page_size_bytes": 4096
1737        });
1738        let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
1739        assert_eq!(parsed.offset_bytes, Some(1024));
1740        assert_eq!(parsed.page_size_bytes, Some(4096));
1741    }
1742
1743    #[test]
1744    fn byte_range_args_length_alias() {
1745        let args = json!({
1746            "file_path": "/tmp/test.bin",
1747            "offset_bytes": 0,
1748            "length": 2048
1749        });
1750        let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
1751        assert_eq!(parsed.offset_bytes, Some(0));
1752        assert_eq!(parsed.page_size_bytes, Some(2048));
1753    }
1754}