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