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        if seen < offset {
560            anyhow::bail!("offset exceeds file length");
561        }
562
563        Ok(SliceReadResult {
564            lines: collected,
565            has_more: !reached_eof,
566            line_truncated,
567        })
568    }
569
570    pub(super) async fn read_ranges(path: &Path, ranges: &[ReadRange]) -> Result<SliceReadRanges> {
571        let file = File::open(path)
572            .await
573            .context(format!("failed to open file: {}", path.display()))?;
574
575        let normalized_ranges: Vec<(usize, usize)> = ranges
576            .iter()
577            .map(|range| {
578                let offset = range.offset.max(1);
579                let limit = ReadFileHandler::effective_line_limit(range.limit);
580                (offset, offset.saturating_add(limit.saturating_sub(1)))
581            })
582            .collect();
583        let max_line = normalized_ranges.iter().map(|(_, end)| *end).max().unwrap_or(0);
584        let mut collected: Vec<Vec<String>> = normalized_ranges.iter().map(|_| Vec::new()).collect();
585        let mut reader = BufReader::new(file);
586        let mut buffer = Vec::new();
587        let mut line_number = 0usize;
588        let mut has_lookahead = false;
589        let mut line_truncated = false;
590        let mut scan_error = None;
591
592        while line_number < max_line {
593            match read_formatted_line(&mut reader, &mut buffer).await {
594                Ok(Some((line, truncated))) => {
595                    line_truncated |= truncated;
596                    line_number += 1;
597                    for (index, (offset, end)) in normalized_ranges.iter().enumerate() {
598                        if (*offset..=*end).contains(&line_number) {
599                            collected[index].push(line.clone());
600                        }
601                    }
602                }
603                Ok(None) => break,
604                Err(error) => {
605                    scan_error = Some(error);
606                    break;
607                }
608            }
609        }
610
611        if scan_error.is_none() && line_number == max_line {
612            match read_formatted_line(&mut reader, &mut buffer).await {
613                Ok(Some((_, truncated))) => {
614                    line_truncated |= truncated;
615                    has_lookahead = true;
616                }
617                Ok(None) => {}
618                Err(error) => scan_error = Some(error),
619            }
620        }
621
622        let results = normalized_ranges
623            .into_iter()
624            .zip(collected)
625            .map(|((offset, end), lines)| {
626                if line_number < offset {
627                    scan_error
628                        .is_none()
629                        .then_some(Err(anyhow::anyhow!("offset exceeds file length")))
630                } else if scan_error.is_some() && line_number < end {
631                    None
632                } else {
633                    Some(Ok(SliceReadResult {
634                        lines,
635                        has_more: line_number > end || (line_number == end && has_lookahead),
636                        line_truncated,
637                    }))
638                }
639            })
640            .collect();
641
642        Ok(SliceReadRanges { results, error: scan_error })
643    }
644    async fn read_formatted_line(reader: &mut BufReader<File>, buffer: &mut Vec<u8>) -> Result<Option<(String, bool)>> {
645        let Some(truncated) = read_bounded_line(reader, buffer).await.context("failed to read file")? else {
646            return Ok(None);
647        };
648
649        if buffer.last() == Some(&b'\n') {
650            buffer.pop();
651            if buffer.last() == Some(&b'\r') {
652                buffer.pop();
653            }
654        }
655
656        Ok(Some((format_line(buffer), truncated)))
657    }
658}
659
660mod indentation {
661    use super::*;
662
663    pub(super) struct IndentationReadResult {
664        pub lines: Vec<String>,
665        pub line_truncated: bool,
666    }
667
668    pub async fn read_block(
669        path: &Path,
670        offset: usize,
671        limit: usize,
672        options: IndentationArgs,
673    ) -> Result<IndentationReadResult> {
674        let anchor_line = options.anchor_line.unwrap_or(offset);
675        anyhow::ensure!(anchor_line > 0, "anchor_line must be a 1-indexed line number");
676
677        let guard_limit = options.max_lines.unwrap_or(limit);
678        anyhow::ensure!(guard_limit > 0, "max_lines must be greater than zero");
679
680        // Bound the collection to the window the block expansion can ever use:
681        // the anchor plus the downward guard limit. The algorithm expands at
682        // most `final_limit` lines down from the anchor and needs the full
683        // prefix above it, so `anchor + guard_limit` is always sufficient and
684        // prevents an unbounded full-file read of gigantic files.
685        let collect_cap = anchor_line.saturating_add(guard_limit);
686        let (collected, line_truncated) = collect_file_lines(path, collect_cap).await?;
687        anyhow::ensure!(!collected.is_empty() && anchor_line <= collected.len(), "anchor_line exceeds file length");
688
689        let anchor_index = anchor_line - 1;
690        let effective_indents = compute_effective_indents(&collected);
691        let anchor_indent = effective_indents[anchor_index];
692
693        // Compute the min indent
694        let min_indent = if options.max_levels == 0 {
695            0
696        } else {
697            anchor_indent.saturating_sub(options.max_levels * TAB_WIDTH)
698        };
699
700        // Cap requested lines by guard_limit and file length
701        let final_limit = limit.min(guard_limit).min(collected.len());
702
703        if final_limit == 1 {
704            return Ok(IndentationReadResult {
705                lines: vec![format!(
706                    "{}: {}",
707                    collected[anchor_index].number, collected[anchor_index].display
708                )],
709                line_truncated,
710            });
711        }
712
713        // Bidirectional cursors
714        let mut i: isize = anchor_index as isize - 1; // up
715        let mut j: usize = anchor_index + 1; // down
716        let mut i_counter_min_indent = 0;
717        let mut j_counter_min_indent = 0;
718
719        let mut out = VecDeque::with_capacity(limit);
720        out.push_back(&collected[anchor_index]);
721
722        while out.len() < final_limit {
723            let mut progressed = 0;
724
725            // Expand upward
726            if i >= 0 {
727                #[allow(
728                    clippy::cast_sign_loss,
729                    reason = "Intentional compatibility, platform, or test-only suppression."
730                )]
731                let iu = i as usize;
732                if effective_indents[iu] >= min_indent {
733                    out.push_front(&collected[iu]);
734                    progressed += 1;
735                    i -= 1;
736
737                    // Control sibling inclusion
738                    if effective_indents[iu] == min_indent && !options.include_siblings {
739                        let allow_header_comment = options.include_header && collected[iu].is_comment();
740                        let can_take_line = allow_header_comment || i_counter_min_indent == 0;
741
742                        if can_take_line {
743                            i_counter_min_indent += 1;
744                        } else {
745                            out.pop_front();
746                            progressed -= 1;
747                            i = -1;
748                        }
749                    }
750
751                    if out.len() >= final_limit {
752                        break;
753                    }
754                } else {
755                    i = -1;
756                }
757            }
758
759            // Expand downward
760            if j < collected.len() {
761                let ju = j;
762                if effective_indents[ju] >= min_indent {
763                    out.push_back(&collected[ju]);
764                    progressed += 1;
765                    j += 1;
766
767                    // Control sibling inclusion
768                    if effective_indents[ju] == min_indent && !options.include_siblings {
769                        if j_counter_min_indent > 0 {
770                            out.pop_back();
771                            progressed -= 1;
772                            j = collected.len();
773                        }
774                        j_counter_min_indent += 1;
775                    }
776                } else {
777                    j = collected.len();
778                }
779            }
780
781            if progressed == 0 {
782                break;
783            }
784        }
785
786        trim_empty_lines(&mut out);
787
788        Ok(IndentationReadResult {
789            lines: out
790                .into_iter()
791                .map(|record| format!("{}: {}", record.number, record.display))
792                .collect(),
793            line_truncated,
794        })
795    }
796
797    async fn collect_file_lines(path: &Path, max_lines: usize) -> Result<(Vec<LineRecord>, bool)> {
798        let file = File::open(path)
799            .await
800            .context(format!("failed to open file: {}", path.display()))?;
801
802        let mut reader = BufReader::new(file);
803        let mut buffer = Vec::new();
804        let mut lines = Vec::new();
805        let mut number = 0usize;
806        let mut line_truncated = false;
807
808        while lines.len() < max_lines {
809            let Some(truncated) = read_bounded_line(&mut reader, &mut buffer)
810                .await
811                .context("failed to read file")?
812            else {
813                break;
814            };
815            line_truncated |= truncated;
816
817            if buffer.last() == Some(&b'\n') {
818                buffer.pop();
819                if buffer.last() == Some(&b'\r') {
820                    buffer.pop();
821                }
822            }
823
824            number += 1;
825            let raw = String::from_utf8_lossy(&buffer).into_owned();
826            let indent = measure_indent(&raw);
827            let display = format_line(&buffer);
828            lines.push(LineRecord { number, raw, display, indent });
829        }
830
831        Ok((lines, line_truncated))
832    }
833
834    fn compute_effective_indents(records: &[LineRecord]) -> Vec<usize> {
835        let mut effective = Vec::with_capacity(records.len());
836        let mut previous_indent = 0usize;
837        for record in records {
838            if record.is_blank() {
839                effective.push(previous_indent);
840            } else {
841                previous_indent = record.indent;
842                effective.push(previous_indent);
843            }
844        }
845        effective
846    }
847
848    fn measure_indent(line: &str) -> usize {
849        line.chars()
850            .take_while(|c| matches!(c, ' ' | '\t'))
851            .map(|c| if c == '\t' { TAB_WIDTH } else { 1 })
852            .sum()
853    }
854}
855
856mod defaults {
857    pub fn offset() -> usize {
858        1
859    }
860
861    pub fn limit() -> usize {
862        crate::tools::read_limits::read_limit_lines()
863    }
864
865    pub fn batch_limit() -> usize {
866        500
867    }
868
869    pub fn max_concurrency() -> usize {
870        super::DEFAULT_MAX_CONCURRENCY
871    }
872
873    pub fn ui_progress() -> bool {
874        true
875    }
876
877    pub fn max_levels() -> usize {
878        0
879    }
880
881    pub fn include_siblings() -> bool {
882        false
883    }
884
885    pub fn include_header() -> bool {
886        true
887    }
888
889    pub fn condense() -> bool {
890        true
891    }
892}
893
894#[cfg(test)]
895mod tests {
896    #[expect(
897        unused_imports,
898        reason = "Intentional compatibility, platform, test, or API-shape suppression."
899    )]
900    use super::indentation::*;
901    use super::lines::condense_for_batch;
902    use super::slice::*;
903    use super::*;
904    use std::io::Write;
905    use tempfile::NamedTempFile;
906
907    #[tokio::test]
908    async fn reads_requested_range() -> Result<()> {
909        let mut temp = NamedTempFile::new()?;
910        writeln!(temp, "alpha")?;
911        writeln!(temp, "beta")?;
912        writeln!(temp, "gamma")?;
913
914        let lines = read(temp.path(), 2, 2).await?.lines;
915        assert_eq!(lines, vec!["beta".to_string(), "gamma".to_string()]);
916        Ok(())
917    }
918
919    #[test]
920    fn read_file_args_accepts_boolean_indentation() {
921        let args = json!({
922            "file_path": "/tmp/example.txt",
923            "mode": "slice",
924            "indentation": false
925        });
926
927        let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
928        assert!(matches!(parsed.mode, ReadMode::Slice));
929        assert!(parsed.indentation.is_none());
930    }
931
932    #[test]
933    fn read_file_args_accepts_true_indentation() {
934        let args = json!({
935            "file_path": "/tmp/example.txt",
936            "mode": "indentation",
937            "indentation": true
938        });
939
940        let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
941        assert!(matches!(parsed.mode, ReadMode::Indentation));
942        assert!(parsed.indentation.is_some());
943    }
944
945    #[test]
946    fn read_file_args_accepts_empty_mode() {
947        let args = json!({
948            "file_path": "/tmp/example.txt",
949            "mode": ""
950        });
951
952        let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
953        assert!(matches!(parsed.mode, ReadMode::Slice));
954    }
955
956    #[tokio::test]
957    async fn read_file_handler_skips_condense_when_disabled() -> Result<()> {
958        let mut temp = NamedTempFile::new()?;
959        for idx in 0..60 {
960            writeln!(temp, "line-{idx}")?;
961        }
962
963        let args = ReadFileArgs {
964            file_path: temp.path().to_string_lossy().to_string(),
965            offset: 1,
966            limit: 2000,
967            mode: ReadMode::Slice,
968            indentation: None,
969            max_tokens: None,
970            condense: false,
971            offset_bytes: None,
972            page_size_bytes: None,
973        };
974        let handler = ReadFileHandler;
975        let content = handler.handle(args).await?;
976
977        assert!(!content.contains("lines omitted"));
978        assert_eq!(content.lines().count(), 60);
979        Ok(())
980    }
981
982    #[tokio::test]
983    async fn read_file_handler_skips_condense_for_paginated_read() -> Result<()> {
984        // Regression: when the caller follows an omit-hint and reads a
985        // specific slice (offset > 1), the handler must NOT re-condense the
986        // result. Re-condensing would emit another omit-hint and trap the
987        // agent in an infinite pagination regress (checkpoint turn_613).
988        let mut temp = NamedTempFile::new()?;
989        for idx in 1..=300 {
990            writeln!(temp, "line-{idx}")?;
991        }
992
993        let args = ReadFileArgs {
994            file_path: temp.path().to_string_lossy().to_string(),
995            offset: 81,
996            limit: 229,
997            mode: ReadMode::Slice,
998            indentation: None,
999            max_tokens: None,
1000            condense: true,
1001            offset_bytes: None,
1002            page_size_bytes: None,
1003        };
1004        let handler = ReadFileHandler;
1005        let content = handler.handle(args).await?;
1006
1007        // No re-condensation: the requested slice is returned verbatim.
1008        assert!(!content.contains("lines omitted"), "paginated read must not be re-condensed; got: {content}");
1009        // The slice starts at line 81 (1-indexed), so line-81 must be present.
1010        assert!(
1011            content.contains("line-81"),
1012            "paginated read must include the requested offset; got first line: {:?}",
1013            content.lines().next()
1014        );
1015        Ok(())
1016    }
1017
1018    #[tokio::test]
1019    async fn read_file_handler_still_condenses_default_overview_read() -> Result<()> {
1020        // The default overview path (offset == 1) must still condense large
1021        // outputs so the catalog stays bounded. This guards against the fix
1022        // over-correcting and never condensing.
1023        let mut temp = NamedTempFile::new()?;
1024        for idx in 1..=300 {
1025            writeln!(temp, "line-{idx}")?;
1026        }
1027
1028        let args = ReadFileArgs {
1029            file_path: temp.path().to_string_lossy().to_string(),
1030            offset: 1,
1031            limit: 2000,
1032            mode: ReadMode::Slice,
1033            indentation: None,
1034            max_tokens: None,
1035            condense: true,
1036            offset_bytes: None,
1037            page_size_bytes: None,
1038        };
1039        let handler = ReadFileHandler;
1040        let content = handler.handle(args).await?;
1041
1042        assert!(
1043            content.contains("lines omitted"),
1044            "default overview read must condense large output; got {} lines",
1045            content.lines().count()
1046        );
1047        Ok(())
1048    }
1049
1050    #[tokio::test]
1051    async fn errors_when_offset_exceeds_length() {
1052        let mut temp = NamedTempFile::new().unwrap();
1053        writeln!(temp, "only").unwrap();
1054
1055        let err = read(temp.path(), 3, 1).await;
1056        err.unwrap_err();
1057    }
1058
1059    #[tokio::test]
1060    async fn reads_non_utf8_lines() -> Result<()> {
1061        let mut temp = NamedTempFile::new()?;
1062        temp.as_file_mut().write_all(b"\xff\xfe\nplain\n")?;
1063
1064        let lines = read(temp.path(), 1, 2).await?.lines;
1065        let expected_first = format!("{}{}", '\u{FFFD}', '\u{FFFD}');
1066        assert_eq!(lines, vec![expected_first, "plain".to_string()]);
1067        Ok(())
1068    }
1069
1070    #[tokio::test]
1071    async fn reports_oversized_physical_lines_as_truncated() -> Result<()> {
1072        let mut temp = NamedTempFile::new()?;
1073        let long_line = "x".repeat(crate::tools::file_ops::MAX_LINE_READ_BYTES + 1);
1074        writeln!(temp, "{long_line}")?;
1075        writeln!(temp, "plain")?;
1076
1077        let outcome = ReadFileHandler
1078            .handle_detailed(ReadFileArgs {
1079                file_path: temp.path().to_string_lossy().into_owned(),
1080                offset: 1,
1081                limit: 2,
1082                mode: ReadMode::Slice,
1083                indentation: None,
1084                max_tokens: None,
1085                condense: false,
1086                offset_bytes: None,
1087                page_size_bytes: None,
1088            })
1089            .await?;
1090
1091        assert!(outcome.line_truncated);
1092        assert!(outcome.content.contains("plain"));
1093        Ok(())
1094    }
1095
1096    #[tokio::test]
1097    async fn trims_crlf_endings() -> Result<()> {
1098        let mut temp = NamedTempFile::new()?;
1099        write!(temp, "one\r\ntwo\r\n")?;
1100
1101        let lines = read(temp.path(), 1, 2).await?.lines;
1102        assert_eq!(lines, vec!["one".to_string(), "two".to_string()]);
1103        Ok(())
1104    }
1105
1106    #[tokio::test]
1107    async fn respects_limit_even_with_more_lines() -> Result<()> {
1108        let mut temp = NamedTempFile::new()?;
1109        writeln!(temp, "first")?;
1110        writeln!(temp, "second")?;
1111        writeln!(temp, "third")?;
1112
1113        let result = read(temp.path(), 1, 2).await?;
1114        assert_eq!(result.lines, vec!["first".to_string(), "second".to_string()]);
1115        assert!(result.has_more);
1116        Ok(())
1117    }
1118
1119    #[tokio::test]
1120    async fn reads_exact_limit_without_continuation_at_eof() -> Result<()> {
1121        let mut temp = NamedTempFile::new()?;
1122        writeln!(temp, "first")?;
1123        writeln!(temp, "second")?;
1124
1125        let result = read(temp.path(), 1, 2).await?;
1126        assert_eq!(result.lines, vec!["first".to_string(), "second".to_string()]);
1127        assert!(!result.has_more);
1128        Ok(())
1129    }
1130
1131    #[test]
1132    fn clamp_to_absolute_cap_marks_only_true_overages() {
1133        // A request above the ceiling is clamped and flagged.
1134        assert_eq!(ReadFileHandler::clamp_to_absolute_cap(1000, 400), (400, true));
1135        // A request exactly at the ceiling sits at the cap but is not "clamped"
1136        // (this is what keeps the pagination chain alive on follow-up pages).
1137        assert_eq!(ReadFileHandler::clamp_to_absolute_cap(400, 400), (400, false));
1138        // A request below the ceiling is unchanged and not flagged.
1139        assert_eq!(ReadFileHandler::clamp_to_absolute_cap(50, 400), (50, false));
1140    }
1141
1142    #[tokio::test]
1143    async fn absolute_cap_clamps_large_request() -> Result<()> {
1144        let mut temp = NamedTempFile::new()?;
1145        for idx in 1..=1000 {
1146            writeln!(temp, "line-{idx}")?;
1147        }
1148
1149        let handler = ReadFileHandler;
1150        let args = ReadFileArgs {
1151            file_path: temp.path().to_string_lossy().to_string(),
1152            offset: 1,
1153            limit: 2000,
1154            mode: ReadMode::Slice,
1155            indentation: None,
1156            max_tokens: None,
1157            condense: false,
1158            offset_bytes: None,
1159            page_size_bytes: None,
1160        };
1161        let outcome = handler.handle_detailed(args).await?;
1162
1163        let cap = crate::tools::read_limits::read_limit_lines();
1164        assert!(outcome.capped_by_limit, "request larger than the cap must be clamped");
1165        assert_eq!(outcome.applied_limit, cap);
1166        assert!(outcome.has_more);
1167        assert!(outcome.lines_read <= cap);
1168        Ok(())
1169    }
1170
1171    #[tokio::test]
1172    async fn explicit_limit_under_cap_is_preserved() -> Result<()> {
1173        let mut temp = NamedTempFile::new()?;
1174        for idx in 1..=100 {
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: 50,
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        assert!(!outcome.capped_by_limit);
1193        // Small limits are expanded to MIN_BATCH_LIMIT (200) by the batch path,
1194        // so the whole 100-line file is returned, but it is not cap-clamped.
1195        assert_eq!(outcome.lines_read, 100);
1196        Ok(())
1197    }
1198
1199    #[tokio::test]
1200    async fn truncates_lines_longer_than_max_length() -> Result<()> {
1201        let mut temp = NamedTempFile::new()?;
1202        let long_line = "x".repeat(MAX_LINE_LENGTH + 50);
1203        writeln!(temp, "{long_line}")?;
1204
1205        let lines = read(temp.path(), 1, 1).await?.lines;
1206        let expected = "x".repeat(MAX_LINE_LENGTH);
1207        assert_eq!(lines, vec![expected]);
1208        Ok(())
1209    }
1210
1211    #[tokio::test]
1212    async fn batch_reads_multiple_files() -> Result<()> {
1213        let mut temp1 = NamedTempFile::new()?;
1214        writeln!(temp1, "file1_line1")?;
1215        writeln!(temp1, "file1_line2")?;
1216
1217        let mut temp2 = NamedTempFile::new()?;
1218        writeln!(temp2, "file2_line1")?;
1219        writeln!(temp2, "file2_line2")?;
1220
1221        let handler = ReadFileHandler;
1222        let args = BatchReadArgs {
1223            reads: vec![
1224                BatchReadRequest {
1225                    file_path: temp1.path().to_string_lossy().to_string(),
1226                    range: None,
1227                    ranges: None,
1228                },
1229                BatchReadRequest {
1230                    file_path: temp2.path().to_string_lossy().to_string(),
1231                    range: None,
1232                    ranges: None,
1233                },
1234            ],
1235            max_concurrency: 2,
1236            ui_progress: false,
1237        };
1238
1239        let result = handler.handle_batch(args).await?;
1240        assert_eq!(result["success"], true);
1241        assert_eq!(result["files_read"], 2);
1242        assert_eq!(result["files_succeeded"], 2);
1243
1244        let content = result["content"].as_str().unwrap();
1245        assert!(content.contains("file1_line1"));
1246        assert!(content.contains("file2_line1"));
1247        Ok(())
1248    }
1249
1250    #[tokio::test]
1251    async fn batch_rejects_zero_max_concurrency() -> Result<()> {
1252        let handler = ReadFileHandler;
1253        let args = BatchReadArgs {
1254            reads: vec![BatchReadRequest {
1255                file_path: "/tmp/example.txt".to_string(),
1256                range: None,
1257                ranges: None,
1258            }],
1259            max_concurrency: 0,
1260            ui_progress: false,
1261        };
1262
1263        let error = handler.handle_batch(args).await.expect_err("zero must be rejected");
1264        assert!(error.to_string().contains("max_concurrency must be greater than zero"));
1265        Ok(())
1266    }
1267
1268    #[tokio::test]
1269    async fn batch_caps_excessive_max_concurrency() -> Result<()> {
1270        let mut temp = NamedTempFile::new()?;
1271        writeln!(temp, "line")?;
1272
1273        let handler = ReadFileHandler;
1274        let args = BatchReadArgs {
1275            reads: vec![BatchReadRequest {
1276                file_path: temp.path().to_string_lossy().to_string(),
1277                range: None,
1278                ranges: None,
1279            }],
1280            max_concurrency: usize::MAX,
1281            ui_progress: false,
1282        };
1283
1284        let result = handler.handle_batch(args).await?;
1285        assert_eq!(result["success"], true);
1286        assert_eq!(result["files_succeeded"], 1);
1287        Ok(())
1288    }
1289
1290    #[tokio::test]
1291    async fn batch_preserves_input_order_with_duplicate_requests() -> Result<()> {
1292        let mut first = NamedTempFile::new()?;
1293        writeln!(first, "first")?;
1294        let mut second = NamedTempFile::new()?;
1295        writeln!(second, "second")?;
1296
1297        let first_path = first.path().to_string_lossy().to_string();
1298        let second_path = second.path().to_string_lossy().to_string();
1299        let handler = ReadFileHandler;
1300        let args = BatchReadArgs {
1301            reads: vec![
1302                BatchReadRequest {
1303                    file_path: second_path.clone(),
1304                    range: None,
1305                    ranges: None,
1306                },
1307                BatchReadRequest {
1308                    file_path: first_path.clone(),
1309                    range: None,
1310                    ranges: None,
1311                },
1312                BatchReadRequest { file_path: second_path, range: None, ranges: None },
1313            ],
1314            max_concurrency: usize::MAX,
1315            ui_progress: false,
1316        };
1317
1318        let result = handler.handle_batch(args).await?;
1319        let items = result["items"].as_array().context("batch items must be an array")?;
1320        assert_eq!(items.len(), 3);
1321        assert_eq!(items[0]["ranges"][0]["content"], "second");
1322        assert_eq!(items[1]["ranges"][0]["content"], "first");
1323        assert_eq!(items[2]["ranges"][0]["content"], "second");
1324        let content = result["content"].as_str().context("batch content must be a string")?;
1325        assert!(content.find("second") < content.find("first"));
1326        Ok(())
1327    }
1328
1329    #[tokio::test]
1330    async fn batch_reads_multiple_ranges_from_same_file() -> Result<()> {
1331        let mut temp = NamedTempFile::new()?;
1332        for i in 1..=20 {
1333            writeln!(temp, "line{i}")?;
1334        }
1335
1336        let handler = ReadFileHandler;
1337        let args = BatchReadArgs {
1338            reads: vec![BatchReadRequest {
1339                file_path: temp.path().to_string_lossy().to_string(),
1340                range: None,
1341                ranges: Some(vec![
1342                    ReadRange {
1343                        offset: 1,
1344                        limit: 3,
1345                        mode: ReadMode::Slice,
1346                        indentation: None,
1347                    },
1348                    ReadRange {
1349                        offset: 10,
1350                        limit: 3,
1351                        mode: ReadMode::Slice,
1352                        indentation: None,
1353                    },
1354                ]),
1355            }],
1356            max_concurrency: 4,
1357            ui_progress: false,
1358        };
1359
1360        let result = handler.handle_batch(args).await?;
1361        assert_eq!(result["success"], true);
1362
1363        let items = result["items"].as_array().unwrap();
1364        assert_eq!(items.len(), 1);
1365
1366        let ranges = items[0]["ranges"].as_array().unwrap();
1367        assert_eq!(ranges.len(), 2);
1368        assert_eq!(ranges[0]["offset"], 1);
1369        assert_eq!(ranges[1]["offset"], 10);
1370        assert_eq!(ranges[0]["content"], "line1\nline2\nline3");
1371        assert_eq!(ranges[1]["content"], "line10\nline11\nline12");
1372        Ok(())
1373    }
1374
1375    #[tokio::test]
1376    async fn batch_range_is_capped_by_absolute_line_limit() -> Result<()> {
1377        let cap = crate::tools::read_limits::absolute_line_cap();
1378        let mut temp = NamedTempFile::new()?;
1379        for i in 1..=cap + 1 {
1380            writeln!(temp, "line{i}")?;
1381        }
1382
1383        let handler = ReadFileHandler;
1384        let result = handler
1385            .handle_batch(BatchReadArgs {
1386                reads: vec![BatchReadRequest {
1387                    file_path: temp.path().to_string_lossy().to_string(),
1388                    range: Some(ReadRange {
1389                        offset: 1,
1390                        limit: cap + 1,
1391                        mode: ReadMode::Slice,
1392                        indentation: None,
1393                    }),
1394                    ranges: None,
1395                }],
1396                max_concurrency: 1,
1397                ui_progress: false,
1398            })
1399            .await?;
1400
1401        let range = &result["items"][0]["ranges"][0];
1402        assert_eq!(range["lines_read"], cap);
1403
1404        let content = range["content"].as_str().context("batch range content must be a string")?;
1405        assert!(content.ends_with(&format!("line{cap}")));
1406        assert!(!content.contains(&format!("line{}", cap + 1)));
1407        Ok(())
1408    }
1409
1410    #[tokio::test]
1411    async fn batch_preserves_completed_ranges_before_a_later_error() -> Result<()> {
1412        let mut temp = NamedTempFile::new()?;
1413        writeln!(temp, "line1")?;
1414        writeln!(temp, "line2")?;
1415
1416        let handler = ReadFileHandler;
1417        let args = BatchReadArgs {
1418            reads: vec![BatchReadRequest {
1419                file_path: temp.path().to_string_lossy().to_string(),
1420                range: None,
1421                ranges: Some(vec![
1422                    ReadRange {
1423                        offset: 1,
1424                        limit: 1,
1425                        mode: ReadMode::Slice,
1426                        indentation: None,
1427                    },
1428                    ReadRange {
1429                        offset: 10,
1430                        limit: 1,
1431                        mode: ReadMode::Slice,
1432                        indentation: None,
1433                    },
1434                ]),
1435            }],
1436            max_concurrency: 1,
1437            ui_progress: false,
1438        };
1439
1440        let result = handler.handle_batch(args).await?;
1441        assert_eq!(result["success"], false);
1442        assert_eq!(result["items"][0]["ranges"].as_array().map(Vec::len), Some(1));
1443        assert_eq!(result["items"][0]["ranges"][0]["content"], "line1");
1444        assert!(result["items"][0]["error"].as_str().is_some());
1445        Ok(())
1446    }
1447
1448    #[tokio::test]
1449    async fn batch_handles_missing_file_gracefully() -> Result<()> {
1450        let handler = ReadFileHandler;
1451        let args = BatchReadArgs {
1452            reads: vec![BatchReadRequest {
1453                file_path: "/nonexistent/path/file.txt".to_string(),
1454                range: None,
1455                ranges: None,
1456            }],
1457            max_concurrency: 1,
1458            ui_progress: false,
1459        };
1460
1461        let result = handler.handle_batch(args).await?;
1462        assert_eq!(result["success"], false);
1463
1464        let items = result["items"].as_array().unwrap();
1465        assert!(items[0]["error"].as_str().is_some());
1466        Ok(())
1467    }
1468
1469    #[test]
1470    fn condense_for_batch_preserves_small_outputs() {
1471        let mut lines: Vec<String> = (1..=20).map(|i| format!("line{i}")).collect();
1472        let (condensed, omitted) = condense_for_batch(&mut lines);
1473        assert!(!condensed);
1474        assert_eq!(omitted, 0);
1475        assert_eq!(lines.len(), 20);
1476    }
1477
1478    #[test]
1479    fn condense_for_batch_condenses_large_outputs() {
1480        let mut lines: Vec<String> = (1..=200).map(|i| format!("line{i}")).collect();
1481        let (condensed, omitted) = condense_for_batch(&mut lines);
1482        assert!(condensed);
1483        assert!(omitted > 0);
1484        assert!(lines.len() < 200);
1485        assert!(lines.iter().any(|l| l.contains("omitted")));
1486    }
1487
1488    #[test]
1489    fn condense_for_batch_does_not_treat_plus_minus_text_as_diff() {
1490        let mut lines: Vec<String> = (1..=150)
1491            .map(|i| {
1492                if i % 2 == 0 {
1493                    format!("+ normal status line {i}")
1494                } else {
1495                    format!("- normal status line {i}")
1496                }
1497            })
1498            .collect();
1499        let (condensed, omitted) = condense_for_batch(&mut lines);
1500        assert!(condensed);
1501        assert!(omitted > 0);
1502    }
1503
1504    #[test]
1505    fn condense_for_batch_preserves_actual_diff_output() {
1506        let mut lines = vec![
1507            "diff --git a/src/main.rs b/src/main.rs".to_string(),
1508            "index 1111111..2222222 100644".to_string(),
1509            "--- a/src/main.rs".to_string(),
1510            "+++ b/src/main.rs".to_string(),
1511            "@@ -1 +1 @@".to_string(),
1512            "-old".to_string(),
1513            "+new".to_string(),
1514        ];
1515        let (condensed, omitted) = condense_for_batch(&mut lines);
1516        assert!(!condensed);
1517        assert_eq!(omitted, 0);
1518    }
1519
1520    // --- Byte-range read tests ---
1521
1522    #[tokio::test]
1523    async fn byte_range_reads_first_bytes() -> Result<()> {
1524        let mut temp = NamedTempFile::new()?;
1525        temp.as_file_mut().write_all(b"Hello, World!")?;
1526
1527        let handler = ReadFileHandler;
1528        let args = ReadFileArgs {
1529            file_path: temp.path().to_string_lossy().to_string(),
1530            offset: 1,
1531            limit: 2000,
1532            mode: ReadMode::Slice,
1533            indentation: None,
1534            max_tokens: None,
1535            condense: false,
1536            offset_bytes: Some(0),
1537            page_size_bytes: Some(5),
1538        };
1539        let outcome = handler.handle_detailed(args).await?;
1540        // Content is line-numbered: "1: Hello"
1541        assert_eq!(outcome.content, "1: Hello");
1542        assert!(outcome.has_more);
1543        Ok(())
1544    }
1545
1546    #[tokio::test]
1547    async fn byte_range_reads_with_offset() -> Result<()> {
1548        let mut temp = NamedTempFile::new()?;
1549        writeln!(temp, "Hello, World!")?;
1550        writeln!(temp, "Second line")?;
1551
1552        let handler = ReadFileHandler;
1553        // Seek past "Hello, World!\n" (14 bytes) to start of second line
1554        let args = ReadFileArgs {
1555            file_path: temp.path().to_string_lossy().to_string(),
1556            offset: 1,
1557            limit: 2000,
1558            mode: ReadMode::Slice,
1559            indentation: None,
1560            max_tokens: None,
1561            condense: false,
1562            offset_bytes: Some(14),
1563            page_size_bytes: Some(50),
1564        };
1565        let outcome = handler.handle_detailed(args).await?;
1566        // Should start at line 2
1567        assert!(outcome.content.contains("2: Second line"));
1568        assert!(!outcome.has_more);
1569        Ok(())
1570    }
1571
1572    #[tokio::test]
1573    async fn byte_range_beyond_eof_returns_empty() -> Result<()> {
1574        let mut temp = NamedTempFile::new()?;
1575        temp.as_file_mut().write_all(b"short")?;
1576
1577        let handler = ReadFileHandler;
1578        let args = ReadFileArgs {
1579            file_path: temp.path().to_string_lossy().to_string(),
1580            offset: 1,
1581            limit: 2000,
1582            mode: ReadMode::Slice,
1583            indentation: None,
1584            max_tokens: None,
1585            condense: false,
1586            offset_bytes: Some(100),
1587            page_size_bytes: Some(10),
1588        };
1589        let outcome = handler.handle_detailed(args).await?;
1590        assert_eq!(outcome.content, "");
1591        assert!(!outcome.has_more);
1592        Ok(())
1593    }
1594
1595    #[tokio::test]
1596    async fn byte_range_clamps_to_file_size() -> Result<()> {
1597        // Multi-line file so we can test partial-line skipping
1598        let mut temp = NamedTempFile::new()?;
1599        writeln!(temp, "first line")?;
1600        writeln!(temp, "second line")?;
1601        writeln!(temp, "third line")?;
1602
1603        let handler = ReadFileHandler;
1604        let args = ReadFileArgs {
1605            file_path: temp.path().to_string_lossy().to_string(),
1606            offset: 1,
1607            limit: 2000,
1608            mode: ReadMode::Slice,
1609            indentation: None,
1610            max_tokens: None,
1611            condense: false,
1612            offset_bytes: Some(0),
1613            page_size_bytes: Some(100), // Request more than available
1614        };
1615        let outcome = handler.handle_detailed(args).await?;
1616        // Should have line numbers for all three lines
1617        assert!(outcome.content.contains("1: first line"));
1618        assert!(outcome.content.contains("2: second line"));
1619        assert!(outcome.content.contains("3: third line"));
1620        assert!(!outcome.has_more);
1621        Ok(())
1622    }
1623
1624    #[tokio::test]
1625    async fn byte_range_reads_non_utf8_lossy() -> Result<()> {
1626        let mut temp = NamedTempFile::new()?;
1627        temp.as_file_mut().write_all(b"\xff\xfeplain")?;
1628
1629        let handler = ReadFileHandler;
1630        let args = ReadFileArgs {
1631            file_path: temp.path().to_string_lossy().to_string(),
1632            offset: 1,
1633            limit: 2000,
1634            mode: ReadMode::Slice,
1635            indentation: None,
1636            max_tokens: None,
1637            condense: false,
1638            offset_bytes: Some(0),
1639            page_size_bytes: Some(7), // Read all 7 bytes
1640        };
1641        let outcome = handler.handle_detailed(args).await?;
1642        // Lossy conversion replaces invalid UTF-8 with U+FFFD
1643        assert!(outcome.content.contains('\u{FFFD}'));
1644        assert!(outcome.content.contains("plain"));
1645        Ok(())
1646    }
1647
1648    #[tokio::test]
1649    async fn byte_range_has_more_when_not_at_eof() -> Result<()> {
1650        let mut temp = NamedTempFile::new()?;
1651        writeln!(temp, "line one")?;
1652        writeln!(temp, "line two")?;
1653        writeln!(temp, "line three")?;
1654
1655        let handler = ReadFileHandler;
1656        // Read only first 10 bytes - should get first line only
1657        let args = ReadFileArgs {
1658            file_path: temp.path().to_string_lossy().to_string(),
1659            offset: 1,
1660            limit: 2000,
1661            mode: ReadMode::Slice,
1662            indentation: None,
1663            max_tokens: None,
1664            condense: false,
1665            offset_bytes: Some(0),
1666            page_size_bytes: Some(10),
1667        };
1668        let outcome = handler.handle_detailed(args).await?;
1669        // Should have line number
1670        assert!(outcome.content.contains("1: line one"));
1671        assert!(outcome.has_more);
1672        Ok(())
1673    }
1674
1675    #[tokio::test]
1676    async fn byte_range_defaults_to_8192_when_length_omitted() -> Result<()> {
1677        // Create a file large enough that 8192 bytes doesn't cover everything
1678        let mut temp = NamedTempFile::new()?;
1679        for i in 1..=500 {
1680            writeln!(temp, "line-{i}: {}", "x".repeat(20))?;
1681        }
1682
1683        let handler = ReadFileHandler;
1684        let args = ReadFileArgs {
1685            file_path: temp.path().to_string_lossy().to_string(),
1686            offset: 1,
1687            limit: 2000,
1688            mode: ReadMode::Slice,
1689            indentation: None,
1690            max_tokens: None,
1691            condense: false,
1692            offset_bytes: Some(0),
1693            page_size_bytes: None, // Should default to 8192
1694        };
1695        let outcome = handler.handle_detailed(args).await?;
1696        // Content should have line numbers
1697        assert!(outcome.content.contains("1: line-1"));
1698        assert!(outcome.has_more);
1699        Ok(())
1700    }
1701
1702    #[test]
1703    fn byte_range_args_from_json() {
1704        let args = json!({
1705            "file_path": "/tmp/test.bin",
1706            "offset_bytes": 1024,
1707            "page_size_bytes": 4096
1708        });
1709        let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
1710        assert_eq!(parsed.offset_bytes, Some(1024));
1711        assert_eq!(parsed.page_size_bytes, Some(4096));
1712    }
1713
1714    #[test]
1715    fn byte_range_args_length_alias() {
1716        let args = json!({
1717            "file_path": "/tmp/test.bin",
1718            "offset_bytes": 0,
1719            "length": 2048
1720        });
1721        let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
1722        assert_eq!(parsed.offset_bytes, Some(0));
1723        assert_eq!(parsed.page_size_bytes, Some(2048));
1724    }
1725}