1use std::io;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use serde::Serialize;
6use thiserror::Error;
7use tokio::io::{AsyncBufReadExt as _, AsyncRead, AsyncReadExt as _, BufReader};
8
9use crate::file_system::FileSystem;
10use crate::tool::ReadArguments;
11
12const MAX_READ_BYTES: usize = 50 * 1024;
13const MAX_READ_LINES: u64 = 2_000;
14const MAX_SCAN_BYTES: usize = 8 * 1024 * 1024;
15
16#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
18pub struct ReadOutput {
19 content: String,
20 end_line: Option<u64>,
21 next_offset: Option<u64>,
22 path: String,
23 start_line: u64,
24 truncated: bool,
25}
26
27impl ReadOutput {
28 pub fn content(&self) -> &str {
30 &self.content
31 }
32
33 pub fn end_line(&self) -> Option<u64> {
35 self.end_line
36 }
37
38 pub fn next_offset(&self) -> Option<u64> {
40 self.next_offset
41 }
42
43 pub fn path(&self) -> &str {
45 &self.path
46 }
47
48 pub fn start_line(&self) -> u64 {
50 self.start_line
51 }
52
53 pub fn truncated(&self) -> bool {
55 self.truncated
56 }
57
58 pub(crate) fn to_tool_result(&self) -> Result<String, serde_json::Error> {
59 serde_json::to_string(self)
60 }
61}
62
63#[derive(Debug, Error)]
65pub enum ReadError {
66 #[error("failed to resolve repository root: {source}")]
68 RepositoryRoot {
69 #[source]
71 source: io::Error,
72 },
73 #[error("failed to resolve read path `{path}`: {source}")]
75 ResolvePath {
76 path: String,
78 #[source]
80 source: io::Error,
81 },
82 #[error("read path `{path}` resolves outside the repository")]
84 OutsideRepository {
85 path: String,
87 },
88 #[error("failed to open read path `{path}`: {source}")]
90 Open {
91 path: String,
93 #[source]
95 source: io::Error,
96 },
97 #[error("failed to read `{path}`: {source}")]
99 Read {
100 path: String,
102 #[source]
104 source: io::Error,
105 },
106 #[error("read offset {offset} is beyond the end of `{path}`")]
108 OffsetBeyondEnd {
109 offset: u64,
111 path: String,
113 },
114 #[error("line {line} in `{path}` exceeds the read size limit")]
116 LineTooLong {
117 line: u64,
119 path: String,
121 },
122 #[error("line {line} in `{path}` is not valid UTF-8")]
124 InvalidUtf8 {
125 line: u64,
127 path: String,
129 },
130 #[error("read of `{path}` exceeds the scan limit of {limit} bytes")]
132 ScanLimitExceeded {
133 limit: usize,
135 path: String,
137 },
138 #[error("failed to encode read result: {0}")]
140 Encode(#[from] serde_json::Error),
141}
142
143pub(crate) struct ReadTool {
144 file_system: Arc<dyn FileSystem>,
145 repository_root: PathBuf,
146}
147
148impl ReadTool {
149 pub(crate) fn new(file_system: Arc<dyn FileSystem>, repository_root: PathBuf) -> Self {
150 Self {
151 file_system,
152 repository_root,
153 }
154 }
155
156 pub(crate) async fn execute(&self, arguments: &ReadArguments) -> Result<ReadOutput, ReadError> {
157 let root = self
158 .file_system
159 .canonicalize(&self.repository_root)
160 .await
161 .map_err(|source| ReadError::RepositoryRoot { source })?;
162 let path = arguments.path().to_string();
163 let candidate = root.join(Path::new(&path));
164 let canonical_path = self
165 .file_system
166 .canonicalize(&candidate)
167 .await
168 .map_err(|source| ReadError::ResolvePath {
169 path: path.clone(),
170 source,
171 })?;
172 if !canonical_path.starts_with(&root) || canonical_path == root {
173 return Err(ReadError::OutsideRepository { path });
174 }
175 let file = self
176 .file_system
177 .open_beneath(&root, Path::new(&path))
178 .await
179 .map_err(|source| ReadError::Open {
180 path: path.clone(),
181 source,
182 })?;
183
184 Self::read(file, arguments, path).await
185 }
186
187 async fn read(
188 file: Box<dyn AsyncRead + Send + Unpin>,
189 arguments: &ReadArguments,
190 path: String,
191 ) -> Result<ReadOutput, ReadError> {
192 let start_line = arguments.offset().unwrap_or(1);
193 let requested_lines = arguments.limit().unwrap_or(MAX_READ_LINES);
194 let selected_lines = requested_lines.min(MAX_READ_LINES);
195 let file: Box<dyn AsyncRead + Send + Unpin> =
196 Box::new(file.take((MAX_SCAN_BYTES + 1) as u64));
197 let mut reader = BufReader::new(file);
198 let mut current_line = 1_u64;
199 let mut remaining_scan_bytes = MAX_SCAN_BYTES;
200
201 while current_line < start_line {
202 if !Self::skip_line(&mut reader, &path, &mut remaining_scan_bytes).await? {
203 return Err(ReadError::OffsetBeyondEnd {
204 offset: start_line,
205 path,
206 });
207 }
208 current_line += 1;
209 }
210
211 let mut content = String::new();
212 let mut lines_read = 0_u64;
213 let mut next_offset = None;
214 while lines_read < selected_lines {
215 let Some(line) =
216 Self::next_line(&mut reader, current_line, &path, &mut remaining_scan_bytes)
217 .await?
218 else {
219 break;
220 };
221 let line = Self::decode_line(line, current_line, &path)?;
222 let separator_bytes = usize::from(lines_read > 0);
223 if content
224 .len()
225 .checked_add(separator_bytes)
226 .and_then(|bytes| bytes.checked_add(line.len()))
227 .is_none_or(|bytes| bytes > MAX_READ_BYTES)
228 {
229 next_offset = Some(current_line);
230 break;
231 }
232 if separator_bytes > 0 {
233 content.push('\n');
234 }
235 content.push_str(&line);
236 lines_read += 1;
237 current_line += 1;
238 }
239
240 if next_offset.is_none()
241 && lines_read == selected_lines
242 && Self::has_more(&mut reader, &path).await?
243 {
244 next_offset = Some(current_line);
245 }
246 if lines_read == 0 && start_line > 1 {
247 return Err(ReadError::OffsetBeyondEnd {
248 offset: start_line,
249 path,
250 });
251 }
252 let end_line = lines_read
253 .checked_sub(1)
254 .and_then(|additional_lines| start_line.checked_add(additional_lines));
255
256 Ok(ReadOutput {
257 content,
258 end_line,
259 next_offset,
260 path,
261 start_line,
262 truncated: next_offset.is_some(),
263 })
264 }
265
266 async fn next_line(
267 reader: &mut BufReader<Box<dyn AsyncRead + Send + Unpin>>,
268 line: u64,
269 path: &str,
270 remaining_scan_bytes: &mut usize,
271 ) -> Result<Option<Vec<u8>>, ReadError> {
272 let mut bytes = Vec::new();
273 let mut limited = (&mut *reader).take((MAX_READ_BYTES + 3) as u64);
274 let bytes_read = limited
275 .read_until(b'\n', &mut bytes)
276 .await
277 .map_err(|source| ReadError::Read {
278 path: path.to_string(),
279 source,
280 })?;
281 if bytes_read == 0 {
282 return Ok(None);
283 }
284 Self::consume_scan_budget(remaining_scan_bytes, bytes.len(), path)?;
285 let line_content_bytes = if let Some(line) = bytes.strip_suffix(b"\n") {
286 line.strip_suffix(b"\r").unwrap_or(line)
287 } else {
288 &bytes
289 };
290 if line_content_bytes.len() > MAX_READ_BYTES {
291 return Err(ReadError::LineTooLong {
292 line,
293 path: path.to_string(),
294 });
295 }
296
297 Ok(Some(bytes))
298 }
299
300 async fn skip_line(
301 reader: &mut BufReader<Box<dyn AsyncRead + Send + Unpin>>,
302 path: &str,
303 remaining_scan_bytes: &mut usize,
304 ) -> Result<bool, ReadError> {
305 let mut saw_bytes = false;
306 loop {
307 let (bytes_to_consume, reached_newline) = {
308 let bytes = reader.fill_buf().await.map_err(|source| ReadError::Read {
309 path: path.to_string(),
310 source,
311 })?;
312 if bytes.is_empty() {
313 return Ok(saw_bytes);
314 }
315 saw_bytes = true;
316
317 bytes
318 .iter()
319 .position(|byte| *byte == b'\n')
320 .map_or((bytes.len(), false), |index| (index + 1, true))
321 };
322 Self::consume_scan_budget(remaining_scan_bytes, bytes_to_consume, path)?;
323 reader.consume(bytes_to_consume);
324 if reached_newline {
325 return Ok(true);
326 }
327 }
328 }
329
330 fn consume_scan_budget(
331 remaining_scan_bytes: &mut usize,
332 bytes: usize,
333 path: &str,
334 ) -> Result<(), ReadError> {
335 if bytes > *remaining_scan_bytes {
336 return Err(ReadError::ScanLimitExceeded {
337 limit: MAX_SCAN_BYTES,
338 path: path.to_string(),
339 });
340 }
341 *remaining_scan_bytes -= bytes;
342
343 Ok(())
344 }
345
346 async fn has_more(
347 reader: &mut BufReader<Box<dyn AsyncRead + Send + Unpin>>,
348 path: &str,
349 ) -> Result<bool, ReadError> {
350 reader
351 .fill_buf()
352 .await
353 .map(|bytes| !bytes.is_empty())
354 .map_err(|source| ReadError::Read {
355 path: path.to_string(),
356 source,
357 })
358 }
359
360 fn decode_line(mut line: Vec<u8>, line_number: u64, path: &str) -> Result<String, ReadError> {
361 if line.last() == Some(&b'\n') {
362 line.pop();
363 if line.last() == Some(&b'\r') {
364 line.pop();
365 }
366 }
367
368 String::from_utf8(line).map_err(|_| ReadError::InvalidUtf8 {
369 line: line_number,
370 path: path.to_string(),
371 })
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use std::io::Cursor;
378 use std::pin::Pin;
379 use std::task::{Context, Poll};
380
381 use mockall::Sequence;
382 use tokio::io::ReadBuf;
383
384 use super::*;
385 use crate::file_system::MockFileSystem;
386
387 struct FailingReader;
388
389 impl AsyncRead for FailingReader {
390 fn poll_read(
391 self: Pin<&mut Self>,
392 _context: &mut Context<'_>,
393 _buffer: &mut ReadBuf<'_>,
394 ) -> Poll<io::Result<()>> {
395 Poll::Ready(Err(io::Error::other("broken stream")))
396 }
397 }
398
399 struct ContentThenFailReader {
400 content: Option<Vec<u8>>,
401 }
402
403 impl AsyncRead for ContentThenFailReader {
404 fn poll_read(
405 mut self: Pin<&mut Self>,
406 _context: &mut Context<'_>,
407 buffer: &mut ReadBuf<'_>,
408 ) -> Poll<io::Result<()>> {
409 let Some(content) = self.content.take() else {
410 return Poll::Ready(Err(io::Error::other("broken continuation probe")));
411 };
412 buffer.put_slice(&content);
413
414 Poll::Ready(Ok(()))
415 }
416 }
417
418 fn arguments(value: serde_json::Value) -> ReadArguments {
419 serde_json::from_value(value).expect("read arguments should be valid")
420 }
421
422 fn file_system(content: impl Into<Vec<u8>>) -> Arc<MockFileSystem> {
423 file_system_reader(Box::new(Cursor::new(content.into())))
424 }
425
426 fn file_system_reader(reader: Box<dyn AsyncRead + Send + Unpin>) -> Arc<MockFileSystem> {
427 let mut file_system = MockFileSystem::new();
428 let mut sequence = Sequence::new();
429 file_system
430 .expect_canonicalize()
431 .withf(|path| path == Path::new("repo"))
432 .times(1)
433 .in_sequence(&mut sequence)
434 .returning(|_| Ok(PathBuf::from("/repo")));
435 file_system
436 .expect_canonicalize()
437 .withf(|path| path == Path::new("/repo/input.txt"))
438 .times(1)
439 .in_sequence(&mut sequence)
440 .returning(|_| Ok(PathBuf::from("/repo/input.txt")));
441 file_system
442 .expect_open_beneath()
443 .withf(|root, path| root == Path::new("/repo") && path == Path::new("input.txt"))
444 .times(1)
445 .return_once(move |_, _| Ok(reader));
446
447 Arc::new(file_system)
448 }
449
450 #[tokio::test]
451 async fn reads_requested_lines_and_reports_continuation() {
452 let tool = ReadTool::new(file_system("one\r\ntwo\nthree\nfour\n"), "repo".into());
454 let arguments = arguments(serde_json::json!({
455 "path": "input.txt",
456 "offset": 2,
457 "limit": 2
458 }));
459
460 let output = tool
462 .execute(&arguments)
463 .await
464 .expect("bounded read should succeed");
465
466 assert_eq!(output.content(), "two\nthree");
468 assert_eq!(output.path(), "input.txt");
469 assert_eq!(output.start_line(), 2);
470 assert_eq!(output.end_line(), Some(3));
471 assert_eq!(output.next_offset(), Some(4));
472 assert!(output.truncated());
473 assert_eq!(
474 output.to_tool_result().expect("output should serialize"),
475 r#"{"content":"two\nthree","end_line":3,"next_offset":4,"path":"input.txt","start_line":2,"truncated":true}"#
476 );
477 }
478
479 #[tokio::test]
480 async fn reads_empty_file_without_truncation() {
481 let tool = ReadTool::new(file_system(Vec::new()), "repo".into());
483 let arguments = arguments(serde_json::json!({ "path": "input.txt" }));
484
485 let output = tool
487 .execute(&arguments)
488 .await
489 .expect("empty file should be readable");
490
491 assert_eq!(output.content(), "");
493 assert_eq!(output.end_line(), None);
494 assert_eq!(output.next_offset(), None);
495 assert!(!output.truncated());
496 }
497
498 #[tokio::test]
499 async fn preserves_leading_and_consecutive_blank_lines() {
500 let tool = ReadTool::new(file_system("\n\nvalue\n\n"), "repo".into());
502 let arguments = arguments(serde_json::json!({
503 "path": "input.txt",
504 "limit": 4
505 }));
506
507 let output = tool
509 .execute(&arguments)
510 .await
511 .expect("blank lines should be preserved");
512
513 assert_eq!(output.content(), "\n\nvalue\n");
515 assert_eq!(output.start_line(), 1);
516 assert_eq!(output.end_line(), Some(4));
517 assert_eq!(output.next_offset(), None);
518 }
519
520 #[tokio::test]
521 async fn reads_to_exact_end_without_truncation() {
522 let tool = ReadTool::new(file_system("one\ntwo"), "repo".into());
524 let arguments = arguments(serde_json::json!({
525 "path": "input.txt",
526 "limit": 2
527 }));
528
529 let output = tool
531 .execute(&arguments)
532 .await
533 .expect("complete bounded read should succeed");
534
535 assert_eq!(output.content(), "one\ntwo");
537 assert_eq!(output.end_line(), Some(2));
538 assert_eq!(output.next_offset(), None);
539 assert!(!output.truncated());
540 }
541
542 #[tokio::test]
543 async fn caps_requested_line_count() {
544 let line_count =
546 usize::try_from(MAX_READ_LINES + 1).expect("read line limit should fit the platform");
547 let content = "line\n".repeat(line_count);
548 let tool = ReadTool::new(file_system(content), "repo".into());
549 let arguments = arguments(serde_json::json!({
550 "path": "input.txt",
551 "limit": u64::MAX
552 }));
553
554 let output = tool
556 .execute(&arguments)
557 .await
558 .expect("line-bounded read should succeed");
559
560 assert_eq!(output.end_line(), Some(MAX_READ_LINES));
562 assert_eq!(output.next_offset(), Some(MAX_READ_LINES + 1));
563 assert!(output.truncated());
564 }
565
566 #[tokio::test]
567 async fn bounds_output_by_bytes() {
568 let first_line = "a".repeat(MAX_READ_BYTES - 1);
570 let content = format!("{first_line}\nsecond\n");
571 let tool = ReadTool::new(file_system(content), "repo".into());
572 let arguments = arguments(serde_json::json!({ "path": "input.txt" }));
573
574 let output = tool
576 .execute(&arguments)
577 .await
578 .expect("byte-bounded read should succeed");
579
580 assert_eq!(output.content(), first_line);
582 assert_eq!(output.next_offset(), Some(2));
583 assert!(output.truncated());
584 }
585
586 #[tokio::test]
587 async fn accepts_exact_byte_limit_before_lf() {
588 let expected = "x".repeat(MAX_READ_BYTES);
590 let tool = ReadTool::new(file_system(format!("{expected}\n")), "repo".into());
591 let arguments = arguments(serde_json::json!({
592 "path": "input.txt",
593 "limit": 1
594 }));
595
596 let output = tool
598 .execute(&arguments)
599 .await
600 .expect("line at the normalized byte limit should succeed");
601
602 assert_eq!(output.content(), expected);
604 assert_eq!(output.end_line(), Some(1));
605 assert!(!output.truncated());
606 }
607
608 #[tokio::test]
609 async fn accepts_exact_byte_limit_before_crlf() {
610 let expected = "x".repeat(MAX_READ_BYTES);
612 let tool = ReadTool::new(file_system(format!("{expected}\r\n")), "repo".into());
613 let arguments = arguments(serde_json::json!({
614 "path": "input.txt",
615 "limit": 1
616 }));
617
618 let output = tool
620 .execute(&arguments)
621 .await
622 .expect("CRLF line at the normalized byte limit should succeed");
623
624 assert_eq!(output.content(), expected);
626 assert_eq!(output.end_line(), Some(1));
627 assert!(!output.truncated());
628 }
629
630 #[tokio::test]
631 async fn does_not_validate_unrequested_oversized_line() {
632 let content = format!("one\n{}", "x".repeat(MAX_READ_BYTES + 1));
634 let tool = ReadTool::new(file_system(content), "repo".into());
635 let arguments = arguments(serde_json::json!({
636 "path": "input.txt",
637 "limit": 1
638 }));
639
640 let output = tool
642 .execute(&arguments)
643 .await
644 .expect("unrequested line should only be probed for presence");
645
646 assert_eq!(output.content(), "one");
648 assert_eq!(output.end_line(), Some(1));
649 assert_eq!(output.next_offset(), Some(2));
650 assert!(output.truncated());
651 }
652
653 #[tokio::test]
654 async fn skips_unrequested_oversized_prefix_line() {
655 let content = format!("{}\nvalue\n", "x".repeat(MAX_READ_BYTES + 1));
657 let tool = ReadTool::new(file_system(content), "repo".into());
658 let arguments = arguments(serde_json::json!({
659 "path": "input.txt",
660 "offset": 2,
661 "limit": 1
662 }));
663
664 let output = tool
666 .execute(&arguments)
667 .await
668 .expect("unrequested prefix line should be discarded");
669
670 assert_eq!(output.content(), "value");
672 assert_eq!(output.start_line(), 2);
673 assert_eq!(output.end_line(), Some(2));
674 assert_eq!(output.next_offset(), None);
675 }
676
677 #[tokio::test]
678 async fn rejects_reads_that_exceed_scan_budget() {
679 let tool = ReadTool::new(file_system(vec![b'x'; MAX_SCAN_BYTES + 1]), "repo".into());
681 let arguments = arguments(serde_json::json!({
682 "path": "input.txt",
683 "offset": 2
684 }));
685
686 let error = tool
688 .execute(&arguments)
689 .await
690 .expect_err("prefix scan beyond the byte budget should fail");
691
692 assert!(matches!(
694 error,
695 ReadError::ScanLimitExceeded { limit, path }
696 if limit == MAX_SCAN_BYTES && path == "input.txt"
697 ));
698 }
699
700 #[tokio::test]
701 async fn reports_continuation_probe_failure() {
702 let reader = ContentThenFailReader {
704 content: Some(b"one\n".to_vec()),
705 };
706 let tool = ReadTool::new(file_system_reader(Box::new(reader)), "repo".into());
707 let arguments = arguments(serde_json::json!({
708 "path": "input.txt",
709 "limit": 1
710 }));
711
712 let error = tool
714 .execute(&arguments)
715 .await
716 .expect_err("failed continuation probe should fail the read");
717
718 assert!(matches!(
720 error,
721 ReadError::Read { path, source }
722 if path == "input.txt" && source.kind() == io::ErrorKind::Other
723 ));
724 }
725
726 #[tokio::test]
727 async fn reports_failure_while_skipping_prefix() {
728 let tool = ReadTool::new(file_system_reader(Box::new(FailingReader)), "repo".into());
730 let arguments = arguments(serde_json::json!({
731 "path": "input.txt",
732 "offset": 2
733 }));
734
735 let error = tool
737 .execute(&arguments)
738 .await
739 .expect_err("failed prefix discard should fail the read");
740
741 assert!(matches!(
743 error,
744 ReadError::Read { path, source }
745 if path == "input.txt" && source.kind() == io::ErrorKind::Other
746 ));
747 }
748
749 #[tokio::test]
750 async fn rejects_offset_beyond_end() {
751 let tool = ReadTool::new(file_system("one\n"), "repo".into());
753 let arguments = arguments(serde_json::json!({
754 "path": "input.txt",
755 "offset": 3
756 }));
757
758 let error = tool
760 .execute(&arguments)
761 .await
762 .expect_err("out-of-range offset should fail");
763
764 assert!(matches!(
766 error,
767 ReadError::OffsetBeyondEnd { offset: 3, path } if path == "input.txt"
768 ));
769 }
770
771 #[tokio::test]
772 async fn rejects_offset_after_unterminated_final_line() {
773 let tool = ReadTool::new(file_system("one"), "repo".into());
775 let arguments = arguments(serde_json::json!({
776 "path": "input.txt",
777 "offset": 2
778 }));
779
780 let error = tool
782 .execute(&arguments)
783 .await
784 .expect_err("offset after an unterminated final line should fail");
785
786 assert!(matches!(
788 error,
789 ReadError::OffsetBeyondEnd { offset: 2, path } if path == "input.txt"
790 ));
791 }
792
793 #[tokio::test]
794 async fn rejects_oversized_line_without_unbounded_read() {
795 let tool = ReadTool::new(file_system(vec![b'x'; MAX_READ_BYTES + 1]), "repo".into());
797 let arguments = arguments(serde_json::json!({ "path": "input.txt" }));
798
799 let error = tool
801 .execute(&arguments)
802 .await
803 .expect_err("oversized line should fail");
804
805 assert!(matches!(
807 error,
808 ReadError::LineTooLong { line: 1, path } if path == "input.txt"
809 ));
810 }
811
812 #[tokio::test]
813 async fn rejects_invalid_utf8() {
814 let tool = ReadTool::new(file_system(vec![0xff, b'\n']), "repo".into());
816 let arguments = arguments(serde_json::json!({ "path": "input.txt" }));
817
818 let error = tool
820 .execute(&arguments)
821 .await
822 .expect_err("invalid UTF-8 should fail");
823
824 assert!(matches!(
826 error,
827 ReadError::InvalidUtf8 { line: 1, path } if path == "input.txt"
828 ));
829 }
830
831 #[tokio::test]
832 async fn rejects_path_that_resolves_outside_repository() {
833 let mut file_system = MockFileSystem::new();
835 let mut sequence = Sequence::new();
836 file_system
837 .expect_canonicalize()
838 .times(1)
839 .in_sequence(&mut sequence)
840 .returning(|_| Ok(PathBuf::from("/repo")));
841 file_system
842 .expect_canonicalize()
843 .times(1)
844 .in_sequence(&mut sequence)
845 .returning(|_| Ok(PathBuf::from("/outside/input.txt")));
846 file_system.expect_open_beneath().times(0);
847 let tool = ReadTool::new(Arc::new(file_system), "repo".into());
848 let arguments = arguments(serde_json::json!({ "path": "input.txt" }));
849
850 let error = tool
852 .execute(&arguments)
853 .await
854 .expect_err("escaping canonical path should fail");
855
856 assert!(matches!(
858 error,
859 ReadError::OutsideRepository { path } if path == "input.txt"
860 ));
861 }
862
863 #[tokio::test]
864 async fn rejects_path_that_resolves_to_repository_root() {
865 let mut file_system = MockFileSystem::new();
867 file_system
868 .expect_canonicalize()
869 .times(2)
870 .returning(|_| Ok(PathBuf::from("/repo")));
871 file_system.expect_open_beneath().times(0);
872 let tool = ReadTool::new(Arc::new(file_system), "repo".into());
873 let arguments = arguments(serde_json::json!({ "path": "input.txt" }));
874
875 let error = tool
877 .execute(&arguments)
878 .await
879 .expect_err("repository directory should not be readable as a file");
880
881 assert!(matches!(
883 error,
884 ReadError::OutsideRepository { path } if path == "input.txt"
885 ));
886 }
887
888 #[tokio::test]
889 async fn reports_path_resolution_failure() {
890 let mut file_system = MockFileSystem::new();
892 let mut sequence = Sequence::new();
893 file_system
894 .expect_canonicalize()
895 .times(1)
896 .in_sequence(&mut sequence)
897 .returning(|_| Ok(PathBuf::from("/repo")));
898 file_system
899 .expect_canonicalize()
900 .times(1)
901 .in_sequence(&mut sequence)
902 .returning(|_| Err(io::Error::new(io::ErrorKind::NotFound, "missing file")));
903 file_system.expect_open_beneath().times(0);
904 let tool = ReadTool::new(Arc::new(file_system), "repo".into());
905 let arguments = arguments(serde_json::json!({ "path": "input.txt" }));
906
907 let error = tool
909 .execute(&arguments)
910 .await
911 .expect_err("missing file should fail path resolution");
912
913 assert!(matches!(
915 error,
916 ReadError::ResolvePath { path, source }
917 if path == "input.txt" && source.kind() == io::ErrorKind::NotFound
918 ));
919 }
920
921 #[tokio::test]
922 async fn reports_file_open_failure() {
923 let mut file_system = MockFileSystem::new();
925 let mut sequence = Sequence::new();
926 file_system
927 .expect_canonicalize()
928 .times(1)
929 .in_sequence(&mut sequence)
930 .returning(|_| Ok(PathBuf::from("/repo")));
931 file_system
932 .expect_canonicalize()
933 .times(1)
934 .in_sequence(&mut sequence)
935 .returning(|_| Ok(PathBuf::from("/repo/input.txt")));
936 file_system
937 .expect_open_beneath()
938 .times(1)
939 .returning(|_, _| {
940 Err(io::Error::new(
941 io::ErrorKind::PermissionDenied,
942 "permission denied",
943 ))
944 });
945 let tool = ReadTool::new(Arc::new(file_system), "repo".into());
946 let arguments = arguments(serde_json::json!({ "path": "input.txt" }));
947
948 let error = tool
950 .execute(&arguments)
951 .await
952 .expect_err("unopenable file should fail");
953
954 assert!(matches!(
956 error,
957 ReadError::Open { path, source }
958 if path == "input.txt" && source.kind() == io::ErrorKind::PermissionDenied
959 ));
960 }
961
962 #[tokio::test]
963 async fn reports_file_read_failure() {
964 let tool = ReadTool::new(file_system_reader(Box::new(FailingReader)), "repo".into());
966 let arguments = arguments(serde_json::json!({ "path": "input.txt" }));
967
968 let error = tool
970 .execute(&arguments)
971 .await
972 .expect_err("broken stream should fail the read");
973
974 assert!(matches!(
976 error,
977 ReadError::Read { path, source }
978 if path == "input.txt" && source.kind() == io::ErrorKind::Other
979 ));
980 }
981}