Skip to main content

capo_agent/tools/
read.rs

1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3use std::future::Future;
4use std::path::PathBuf;
5use std::pin::Pin;
6use std::sync::Arc;
7
8use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
9use serde_json::{json, Value};
10
11use crate::tools::ToolCtx;
12
13pub struct ReadTool {
14    ctx: Arc<ToolCtx>,
15}
16
17impl ReadTool {
18    pub fn new(ctx: Arc<ToolCtx>) -> Self {
19        Self { ctx }
20    }
21}
22
23impl Tool for ReadTool {
24    fn def(&self) -> ToolDef {
25        ToolDef {
26            name: "read".to_string(),
27            description: "Read the contents of a UTF-8 text file from disk.".to_string(),
28            input_schema: json!({
29                "type": "object",
30                "properties": {
31                    "path": { "type": "string", "description": "Path to file (absolute or cwd-relative)." },
32                    "offset": { "type": "integer", "description": "1-based starting line (optional)." },
33                    "limit":  { "type": "integer", "description": "Number of lines to read (default 2000)." }
34                },
35                "required": ["path"]
36            }),
37        }
38    }
39
40    fn call(
41        &self,
42        args: Value,
43        _ctx: &ToolContext,
44    ) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
45        const MAX_BYTES: u64 = 2 * 1024 * 1024;
46
47        let ctx = Arc::clone(&self.ctx);
48        Box::pin(async move {
49            let path = match args.get("path").and_then(|v| v.as_str()) {
50                Some(p) => PathBuf::from(p),
51                None => return ToolResult::error("missing 'path' argument"),
52            };
53            let abs = if path.is_absolute() {
54                path.clone()
55            } else {
56                ctx.cwd.join(&path)
57            };
58
59            let metadata = match tokio::fs::metadata(&abs).await {
60                Ok(m) => m,
61                Err(e) => {
62                    return ToolResult::error(format!("failed to stat {}: {e}", abs.display()))
63                }
64            };
65            if metadata.len() > MAX_BYTES {
66                return ToolResult::error(format!(
67                    "file {} too large ({} bytes > {} byte cap)",
68                    abs.display(),
69                    metadata.len(),
70                    MAX_BYTES
71                ));
72            }
73
74            let bytes = match tokio::fs::read(&abs).await {
75                Ok(b) => b,
76                Err(e) => {
77                    return ToolResult::error(format!("failed to read {}: {e}", abs.display()))
78                }
79            };
80            let text = match String::from_utf8(bytes) {
81                Ok(s) => s,
82                Err(_) => {
83                    return ToolResult::error(format!(
84                        "file {} appears to be binary (non-UTF-8); use `bash` + `head`/`xxd` instead",
85                        abs.display()
86                    ));
87                }
88            };
89
90            let offset = args
91                .get("offset")
92                .and_then(|v| v.as_u64())
93                .map(|n| n.saturating_sub(1) as usize)
94                .unwrap_or(0);
95            let limit = args
96                .get("limit")
97                .and_then(|v| v.as_u64())
98                .map(|n| n as usize)
99                .unwrap_or(2000);
100            let sliced = text
101                .split_inclusive('\n')
102                .skip(offset)
103                .take(limit)
104                .collect::<String>();
105
106            let canonical = tokio::fs::canonicalize(&abs)
107                .await
108                .unwrap_or_else(|_| abs.clone());
109            ctx.mark_read(&canonical).await;
110            ToolResult::text(sliced)
111        })
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::permissions::NoOpPermissionGate;
119    use tempfile::tempdir;
120    use tokio::sync::mpsc;
121
122    fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
123        let (tx, _rx) = mpsc::channel(8);
124        Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
125    }
126
127    #[tokio::test]
128    async fn reads_existing_file_and_records_in_read_set() {
129        let dir = tempdir().expect("tempdir");
130        let file = dir.path().join("hello.txt");
131        tokio::fs::write(&file, "hello, world\n")
132            .await
133            .expect("write");
134
135        let ctx = test_ctx(dir.path());
136        let tool = ReadTool::new(Arc::clone(&ctx));
137        let result = tool
138            .call(json!({ "path": "hello.txt" }), &ToolContext::default())
139            .await;
140
141        let debug = format!("{result:?}");
142        assert!(
143            debug.contains("hello, world"),
144            "unexpected ToolResult: {debug}"
145        );
146
147        let canonical = tokio::fs::canonicalize(&file).await.expect("canonicalize");
148        assert!(ctx.has_been_read(&canonical).await);
149    }
150
151    #[tokio::test]
152    async fn rejects_file_larger_than_2mb() {
153        let dir = tempdir().expect("tempdir");
154        let file = dir.path().join("big.bin");
155        let payload: Vec<u8> = std::iter::repeat_n(b'x', 2_500_000).collect();
156        tokio::fs::write(&file, &payload).await.expect("write");
157
158        let ctx = test_ctx(dir.path());
159        let tool = ReadTool::new(Arc::clone(&ctx));
160        let result = tool
161            .call(json!({ "path": "big.bin" }), &ToolContext::default())
162            .await;
163
164        let debug = format!("{result:?}");
165        assert!(debug.to_lowercase().contains("too large"), "got: {debug}");
166    }
167
168    #[tokio::test]
169    async fn rejects_binary_file() {
170        let dir = tempdir().expect("tempdir");
171        let file = dir.path().join("pic.bin");
172        tokio::fs::write(&file, [0xff_u8, 0xfe, 0xfd, 0xfc, 0x00, 0x01, 0x02])
173            .await
174            .expect("write");
175
176        let ctx = test_ctx(dir.path());
177        let tool = ReadTool::new(Arc::clone(&ctx));
178        let result = tool
179            .call(json!({ "path": "pic.bin" }), &ToolContext::default())
180            .await;
181
182        let debug = format!("{result:?}");
183        assert!(debug.to_lowercase().contains("binary"), "got: {debug}");
184    }
185
186    #[tokio::test]
187    async fn errors_on_missing_file() {
188        let dir = tempdir().expect("tempdir");
189        let ctx = test_ctx(dir.path());
190        let tool = ReadTool::new(Arc::clone(&ctx));
191        let result = tool
192            .call(
193                json!({ "path": "does_not_exist.txt" }),
194                &ToolContext::default(),
195            )
196            .await;
197
198        let debug = format!("{result:?}");
199        assert!(
200            debug.to_lowercase().contains("failed to stat"),
201            "got: {debug}"
202        );
203    }
204
205    #[tokio::test]
206    async fn respects_offset_and_limit() {
207        let dir = tempdir().expect("tempdir");
208        let file = dir.path().join("lines.txt");
209        let body: String = (1..=10).map(|n| format!("line{n}\n")).collect();
210        tokio::fs::write(&file, body).await.expect("write");
211
212        let ctx = test_ctx(dir.path());
213        let tool = ReadTool::new(Arc::clone(&ctx));
214        let result = tool
215            .call(
216                json!({ "path": "lines.txt", "offset": 3, "limit": 2 }),
217                &ToolContext::default(),
218            )
219            .await;
220
221        let debug = format!("{result:?}");
222        assert!(debug.contains("line3"), "missing line3: {debug}");
223        assert!(debug.contains("line4"), "missing line4: {debug}");
224        assert!(!debug.contains("line5"), "unexpected line5 leaked: {debug}");
225    }
226
227    #[tokio::test]
228    async fn preserves_crlf_and_trailing_newline() {
229        let dir = tempdir().expect("tempdir");
230        let file = dir.path().join("windows.txt");
231        tokio::fs::write(&file, b"line1\r\nline2\r\n")
232            .await
233            .expect("write");
234
235        let ctx = test_ctx(dir.path());
236        let tool = ReadTool::new(Arc::clone(&ctx));
237        let result = tool
238            .call(
239                json!({ "path": "windows.txt", "offset": 1, "limit": 2 }),
240                &ToolContext::default(),
241            )
242            .await;
243
244        let text = result.as_text().unwrap_or_default();
245        assert_eq!(text, "line1\r\nline2\r\n");
246    }
247
248    #[tokio::test]
249    async fn offset_zero_equals_offset_one_documented_behavior() {
250        let dir = tempdir().expect("tempdir");
251        let file = dir.path().join("lines.txt");
252        tokio::fs::write(&file, "a\nb\nc\n").await.expect("write");
253        let ctx = test_ctx(dir.path());
254        let tool = ReadTool::new(Arc::clone(&ctx));
255
256        let r0 = tool
257            .call(
258                json!({"path":"lines.txt","offset":0,"limit":2}),
259                &ToolContext::default(),
260            )
261            .await;
262        let r1 = tool
263            .call(
264                json!({"path":"lines.txt","offset":1,"limit":2}),
265                &ToolContext::default(),
266            )
267            .await;
268
269        assert_eq!(format!("{r0:?}"), format!("{r1:?}"));
270    }
271}