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 pub line_truncated: bool,
41 pub capped_by_limit: bool,
43 pub applied_limit: usize,
45}
46
47#[derive(Deserialize, Serialize, Clone, Debug)]
49pub struct ReadFileArgs {
50 #[serde(alias = "path", alias = "filepath", alias = "target_path", alias = "file")]
52 pub file_path: String,
53 #[serde(default = "defaults::offset", deserialize_with = "deserialize_maybe_quoted")]
55 pub offset: usize,
56 #[serde(default = "defaults::limit", deserialize_with = "deserialize_maybe_quoted")]
58 pub limit: usize,
59 #[serde(default, deserialize_with = "deserialize_read_mode")]
61 pub mode: ReadMode,
62 #[serde(default, deserialize_with = "deserialize_indentation")]
64 pub indentation: Option<IndentationArgs>,
65 #[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
67 pub max_tokens: Option<usize>,
68 #[serde(default = "defaults::condense", deserialize_with = "deserialize_maybe_quoted")]
70 pub condense: bool,
71 #[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
73 pub offset_bytes: Option<u64>,
74 #[serde(default, alias = "length", deserialize_with = "deserialize_opt_maybe_quoted")]
76 pub page_size_bytes: Option<usize>,
77}
78
79impl ReadFileArgs {
80 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#[derive(Deserialize, Serialize, Clone, Debug, Default)]
92pub struct ReadRange {
93 #[serde(default = "defaults::offset", deserialize_with = "deserialize_maybe_quoted")]
95 pub offset: usize,
96 #[serde(default = "defaults::batch_limit", deserialize_with = "deserialize_maybe_quoted")]
98 pub limit: usize,
99 #[serde(default, deserialize_with = "deserialize_read_mode")]
101 pub mode: ReadMode,
102 #[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#[derive(Deserialize, Serialize, Clone, Debug, Default)]
117pub struct IndentationArgs {
118 #[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
120 pub anchor_line: Option<usize>,
121 #[serde(default = "defaults::max_levels", deserialize_with = "deserialize_maybe_quoted")]
123 pub max_levels: usize,
124 #[serde(default = "defaults::include_siblings")]
126 pub include_siblings: bool,
127 #[serde(default = "defaults::include_header")]
129 pub include_header: bool,
130 #[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 pub async fn handle_batch(&self, args: BatchReadArgs) -> Result<Value> {
208 batch::execute(self, args).await
209 }
210
211 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 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 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 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 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 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 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 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 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 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 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 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 let mut i: isize = anchor_index as isize - 1; let mut j: usize = anchor_index + 1; 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 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 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 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 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 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 assert!(!content.contains("lines omitted"), "paginated read must not be re-condensed; got: {content}");
1009 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 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 assert_eq!(ReadFileHandler::clamp_to_absolute_cap(1000, 400), (400, true));
1135 assert_eq!(ReadFileHandler::clamp_to_absolute_cap(400, 400), (400, false));
1138 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 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 #[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 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 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 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 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), };
1615 let outcome = handler.handle_detailed(args).await?;
1616 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), };
1641 let outcome = handler.handle_detailed(args).await?;
1642 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 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 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 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, };
1695 let outcome = handler.handle_detailed(args).await?;
1696 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}