Skip to main content

ag_harness/
read.rs

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/// Bounded text returned by one successful `read` execution.
17#[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    /// Returns the selected text with line endings normalized to `\n`.
29    pub fn content(&self) -> &str {
30        &self.content
31    }
32
33    /// Returns the final included one-based line, or `None` for empty output.
34    pub fn end_line(&self) -> Option<u64> {
35        self.end_line
36    }
37
38    /// Returns the next one-based line to request when output was truncated.
39    pub fn next_offset(&self) -> Option<u64> {
40        self.next_offset
41    }
42
43    /// Returns the repository-relative path that was read.
44    pub fn path(&self) -> &str {
45        &self.path
46    }
47
48    /// Returns the requested one-based starting line.
49    pub fn start_line(&self) -> u64 {
50        self.start_line
51    }
52
53    /// Returns whether additional file content follows this result.
54    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/// Failure while safely executing one repository-relative read.
64#[derive(Debug, Error)]
65pub enum ReadError {
66    /// The repository root could not be resolved.
67    #[error("failed to resolve repository root: {source}")]
68    RepositoryRoot {
69        /// Underlying filesystem failure.
70        #[source]
71        source: io::Error,
72    },
73    /// The requested path could not be resolved.
74    #[error("failed to resolve read path `{path}`: {source}")]
75    ResolvePath {
76        /// Repository-relative requested path.
77        path: String,
78        /// Underlying filesystem failure.
79        #[source]
80        source: io::Error,
81    },
82    /// The canonical requested path escapes the canonical repository root.
83    #[error("read path `{path}` resolves outside the repository")]
84    OutsideRepository {
85        /// Repository-relative requested path.
86        path: String,
87    },
88    /// The requested file could not be opened.
89    #[error("failed to open read path `{path}`: {source}")]
90    Open {
91        /// Repository-relative requested path.
92        path: String,
93        /// Underlying filesystem failure.
94        #[source]
95        source: io::Error,
96    },
97    /// The requested file could not be read.
98    #[error("failed to read `{path}`: {source}")]
99    Read {
100        /// Repository-relative requested path.
101        path: String,
102        /// Underlying filesystem failure.
103        #[source]
104        source: io::Error,
105    },
106    /// The requested line does not exist.
107    #[error("read offset {offset} is beyond the end of `{path}`")]
108    OffsetBeyondEnd {
109        /// Requested one-based line.
110        offset: u64,
111        /// Repository-relative requested path.
112        path: String,
113    },
114    /// A line cannot fit in the bounded tool result.
115    #[error("line {line} in `{path}` exceeds the read size limit")]
116    LineTooLong {
117        /// One-based line whose content exceeded the cap.
118        line: u64,
119        /// Repository-relative requested path.
120        path: String,
121    },
122    /// File content is not valid UTF-8 text.
123    #[error("line {line} in `{path}` is not valid UTF-8")]
124    InvalidUtf8 {
125        /// One-based invalid line.
126        line: u64,
127        /// Repository-relative requested path.
128        path: String,
129    },
130    /// The read consumed its bounded file-scan allowance.
131    #[error("read of `{path}` exceeds the scan limit of {limit} bytes")]
132    ScanLimitExceeded {
133        /// Maximum file bytes one read may consume.
134        limit: usize,
135        /// Repository-relative requested path.
136        path: String,
137    },
138    /// The successful result could not be encoded for the model.
139    #[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        // Arrange
453        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        // Act
461        let output = tool
462            .execute(&arguments)
463            .await
464            .expect("bounded read should succeed");
465
466        // Assert
467        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        // Arrange
482        let tool = ReadTool::new(file_system(Vec::new()), "repo".into());
483        let arguments = arguments(serde_json::json!({ "path": "input.txt" }));
484
485        // Act
486        let output = tool
487            .execute(&arguments)
488            .await
489            .expect("empty file should be readable");
490
491        // Assert
492        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        // Arrange
501        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        // Act
508        let output = tool
509            .execute(&arguments)
510            .await
511            .expect("blank lines should be preserved");
512
513        // Assert
514        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        // Arrange
523        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        // Act
530        let output = tool
531            .execute(&arguments)
532            .await
533            .expect("complete bounded read should succeed");
534
535        // Assert
536        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        // Arrange
545        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        // Act
555        let output = tool
556            .execute(&arguments)
557            .await
558            .expect("line-bounded read should succeed");
559
560        // Assert
561        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        // Arrange
569        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        // Act
575        let output = tool
576            .execute(&arguments)
577            .await
578            .expect("byte-bounded read should succeed");
579
580        // Assert
581        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        // Arrange
589        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        // Act
597        let output = tool
598            .execute(&arguments)
599            .await
600            .expect("line at the normalized byte limit should succeed");
601
602        // Assert
603        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        // Arrange
611        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        // Act
619        let output = tool
620            .execute(&arguments)
621            .await
622            .expect("CRLF line at the normalized byte limit should succeed");
623
624        // Assert
625        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        // Arrange
633        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        // Act
641        let output = tool
642            .execute(&arguments)
643            .await
644            .expect("unrequested line should only be probed for presence");
645
646        // Assert
647        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        // Arrange
656        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        // Act
665        let output = tool
666            .execute(&arguments)
667            .await
668            .expect("unrequested prefix line should be discarded");
669
670        // Assert
671        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        // Arrange
680        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        // Act
687        let error = tool
688            .execute(&arguments)
689            .await
690            .expect_err("prefix scan beyond the byte budget should fail");
691
692        // Assert
693        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        // Arrange
703        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        // Act
713        let error = tool
714            .execute(&arguments)
715            .await
716            .expect_err("failed continuation probe should fail the read");
717
718        // Assert
719        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        // Arrange
729        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        // Act
736        let error = tool
737            .execute(&arguments)
738            .await
739            .expect_err("failed prefix discard should fail the read");
740
741        // Assert
742        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        // Arrange
752        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        // Act
759        let error = tool
760            .execute(&arguments)
761            .await
762            .expect_err("out-of-range offset should fail");
763
764        // Assert
765        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        // Arrange
774        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        // Act
781        let error = tool
782            .execute(&arguments)
783            .await
784            .expect_err("offset after an unterminated final line should fail");
785
786        // Assert
787        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        // Arrange
796        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        // Act
800        let error = tool
801            .execute(&arguments)
802            .await
803            .expect_err("oversized line should fail");
804
805        // Assert
806        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        // Arrange
815        let tool = ReadTool::new(file_system(vec![0xff, b'\n']), "repo".into());
816        let arguments = arguments(serde_json::json!({ "path": "input.txt" }));
817
818        // Act
819        let error = tool
820            .execute(&arguments)
821            .await
822            .expect_err("invalid UTF-8 should fail");
823
824        // Assert
825        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        // Arrange
834        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        // Act
851        let error = tool
852            .execute(&arguments)
853            .await
854            .expect_err("escaping canonical path should fail");
855
856        // Assert
857        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        // Arrange
866        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        // Act
876        let error = tool
877            .execute(&arguments)
878            .await
879            .expect_err("repository directory should not be readable as a file");
880
881        // Assert
882        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        // Arrange
891        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        // Act
908        let error = tool
909            .execute(&arguments)
910            .await
911            .expect_err("missing file should fail path resolution");
912
913        // Assert
914        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        // Arrange
924        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        // Act
949        let error = tool
950            .execute(&arguments)
951            .await
952            .expect_err("unopenable file should fail");
953
954        // Assert
955        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        // Arrange
965        let tool = ReadTool::new(file_system_reader(Box::new(FailingReader)), "repo".into());
966        let arguments = arguments(serde_json::json!({ "path": "input.txt" }));
967
968        // Act
969        let error = tool
970            .execute(&arguments)
971            .await
972            .expect_err("broken stream should fail the read");
973
974        // Assert
975        assert!(matches!(
976            error,
977            ReadError::Read { path, source }
978                if path == "input.txt" && source.kind() == io::ErrorKind::Other
979        ));
980    }
981}