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 pub line_truncated: bool,
40 pub capped_by_limit: bool,
42 pub applied_limit: usize,
44}
45
46#[derive(Deserialize, Serialize, Clone, Debug)]
48pub struct ReadFileArgs {
49 #[serde(alias = "path", alias = "filepath", alias = "target_path", alias = "file")]
51 pub file_path: String,
52 #[serde(default = "defaults::offset", deserialize_with = "deserialize_maybe_quoted")]
54 pub offset: usize,
55 #[serde(default = "defaults::limit", deserialize_with = "deserialize_maybe_quoted")]
57 pub limit: usize,
58 #[serde(default, deserialize_with = "deserialize_read_mode")]
60 pub mode: ReadMode,
61 #[serde(default, deserialize_with = "deserialize_indentation")]
63 pub indentation: Option<IndentationArgs>,
64 #[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
66 pub max_tokens: Option<usize>,
67 #[serde(default = "defaults::condense", deserialize_with = "deserialize_maybe_quoted")]
69 pub condense: bool,
70 #[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
72 pub offset_bytes: Option<u64>,
73 #[serde(default, alias = "length", deserialize_with = "deserialize_opt_maybe_quoted")]
75 pub page_size_bytes: Option<usize>,
76}
77
78impl ReadFileArgs {
79 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#[derive(Deserialize, Serialize, Clone, Debug, Default)]
91pub struct ReadRange {
92 #[serde(default = "defaults::offset", deserialize_with = "deserialize_maybe_quoted")]
94 pub offset: usize,
95 #[serde(default = "defaults::batch_limit", deserialize_with = "deserialize_maybe_quoted")]
97 pub limit: usize,
98 #[serde(default, deserialize_with = "deserialize_read_mode")]
100 pub mode: ReadMode,
101 #[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#[derive(Deserialize, Serialize, Clone, Debug, Default)]
116pub struct IndentationArgs {
117 #[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
119 pub anchor_line: Option<usize>,
120 #[serde(default = "defaults::max_levels", deserialize_with = "deserialize_maybe_quoted")]
122 pub max_levels: usize,
123 #[serde(default = "defaults::include_siblings")]
125 pub include_siblings: bool,
126 #[serde(default = "defaults::include_header")]
128 pub include_header: bool,
129 #[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 pub async fn handle_batch(&self, args: BatchReadArgs) -> Result<Value> {
207 batch::execute(self, args).await
208 }
209
210 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 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 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 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 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 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 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 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 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 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 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 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 let mut i: isize = anchor_index as isize - 1; let mut j: usize = anchor_index + 1; 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 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 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 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 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 lines.len() <= CONDENSED_THRESHOLD {
913 return;
914 }
915
916 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 let mut condensed: Vec<String> = lines[..head_count].to_vec();
924
925 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 let tail_start = lines.len() - tail_count;
936 condensed.extend_from_slice(&lines[tail_start..]);
937
938 *lines = condensed;
940}
941
942fn 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 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 assert!(!content.contains("lines omitted"), "paginated read must not be re-condensed; got: {content}");
1132 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 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 assert_eq!(ReadFileHandler::clamp_to_absolute_cap(1000, 400), (400, true));
1258 assert_eq!(ReadFileHandler::clamp_to_absolute_cap(400, 400), (400, false));
1261 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 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 #[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 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 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 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 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), };
1738 let outcome = handler.handle_detailed(args).await?;
1739 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), };
1764 let outcome = handler.handle_detailed(args).await?;
1765 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 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 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 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, };
1818 let outcome = handler.handle_detailed(args).await?;
1819 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}