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