Skip to main content

microclaw_engine/tools/
read_file.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::path::PathBuf;
4use tracing::info;
5
6use crate::config::WorkingDirIsolation;
7use microclaw_core::llm_types::ToolDefinition;
8
9use super::{schema_object, Tool, ToolResult};
10
11pub struct ReadFileTool {
12    working_dir: PathBuf,
13    working_dir_isolation: WorkingDirIsolation,
14}
15
16impl ReadFileTool {
17    pub fn new(working_dir: &str) -> Self {
18        Self::new_with_isolation(working_dir, WorkingDirIsolation::Shared)
19    }
20
21    pub fn new_with_isolation(
22        working_dir: &str,
23        working_dir_isolation: WorkingDirIsolation,
24    ) -> Self {
25        Self {
26            working_dir: PathBuf::from(working_dir),
27            working_dir_isolation,
28        }
29    }
30}
31
32#[async_trait]
33impl Tool for ReadFileTool {
34    fn name(&self) -> &str {
35        "read_file"
36    }
37
38    fn definition(&self) -> ToolDefinition {
39        ToolDefinition {
40            name: "read_file".into(),
41            description: "Read the contents of a file at the given path. Returns the file content with line numbers. Prefer paths relative to the current chat working directory; do not invent machine-specific absolute paths unless the user or a tool already provided them.".into(),
42            input_schema: schema_object(
43                json!({
44                    "path": {
45                        "type": "string",
46                        "description": "The file path to read, usually relative to the current chat working directory"
47                    },
48                    "offset": {
49                        "type": "integer",
50                        "description": "Line number to start reading from (1-based)"
51                    },
52                    "limit": {
53                        "type": "integer",
54                        "description": "Maximum number of lines to read"
55                    }
56                }),
57                &["path"],
58            ),
59        }
60    }
61
62    async fn execute(&self, input: serde_json::Value) -> ToolResult {
63        let path = match input.get("path").and_then(|v| v.as_str()) {
64            Some(p) => p,
65            None => return ToolResult::error("Missing 'path' parameter".into()),
66        };
67        let working_dir =
68            super::resolve_tool_working_dir(&self.working_dir, self.working_dir_isolation, &input);
69        let resolved_path = super::resolve_tool_path(&working_dir, path);
70        let resolved_path_str = resolved_path.to_string_lossy().to_string();
71
72        if let Err(msg) = crate::internal::tool_runtime::path_guard::check_path(&resolved_path_str)
73        {
74            return ToolResult::error(msg);
75        }
76
77        info!("Reading file: {}", resolved_path.display());
78
79        let content = match tokio::fs::read_to_string(&resolved_path).await {
80            Ok(c) => c,
81            Err(e) => return ToolResult::error(format!("Failed to read file: {e}")),
82        };
83
84        let lines: Vec<&str> = content.lines().collect();
85        let offset = input
86            .get("offset")
87            .and_then(|v| v.as_u64())
88            .map(|o| (o as usize).saturating_sub(1))
89            .unwrap_or(0)
90            .min(lines.len());
91        let limit = input
92            .get("limit")
93            .and_then(|v| v.as_u64())
94            .map(|l| l as usize)
95            .unwrap_or(2000);
96
97        let end = offset.saturating_add(limit).min(lines.len());
98        let selected: Vec<String> = lines[offset..end]
99            .iter()
100            .enumerate()
101            .map(|(i, line)| format!("{:>6}\t{}", offset + i + 1, line))
102            .collect();
103
104        ToolResult::success(selected.join("\n"))
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use serde_json::json;
112
113    #[test]
114    fn test_read_file_definition_prefers_relative_paths() {
115        let tool = ReadFileTool::new(".");
116        let def = tool.definition();
117        assert!(def
118            .description
119            .contains("Prefer paths relative to the current chat working directory"));
120        assert_eq!(
121            def.input_schema["properties"]["path"]["description"].as_str(),
122            Some("The file path to read, usually relative to the current chat working directory")
123        );
124    }
125
126    #[tokio::test]
127    async fn test_read_file_success() {
128        let dir = std::env::temp_dir().join(format!("microclaw_rf_{}", uuid::Uuid::new_v4()));
129        std::fs::create_dir_all(&dir).unwrap();
130        let file = dir.join("test.txt");
131        std::fs::write(&file, "line1\nline2\nline3\nline4\nline5").unwrap();
132
133        let tool = ReadFileTool::new(".");
134        let result = tool.execute(json!({"path": file.to_str().unwrap()})).await;
135        assert!(!result.is_error);
136        assert!(result.content.contains("line1"));
137        assert!(result.content.contains("line5"));
138        // Should have line numbers
139        assert!(result.content.contains("1\t"));
140
141        let _ = std::fs::remove_dir_all(&dir);
142    }
143
144    #[tokio::test]
145    async fn test_read_file_with_offset_and_limit() {
146        let dir = std::env::temp_dir().join(format!("microclaw_rf2_{}", uuid::Uuid::new_v4()));
147        std::fs::create_dir_all(&dir).unwrap();
148        let file = dir.join("test.txt");
149        std::fs::write(&file, "a\nb\nc\nd\ne").unwrap();
150
151        let tool = ReadFileTool::new(".");
152        // offset=2 (1-based, becomes index 1), limit=2 -> lines 2 and 3
153        let result = tool
154            .execute(json!({"path": file.to_str().unwrap(), "offset": 2, "limit": 2}))
155            .await;
156        assert!(!result.is_error);
157        assert!(result.content.contains("b"));
158        assert!(result.content.contains("c"));
159        assert!(!result.content.contains("\ta\n") && !result.content.contains("\td"));
160
161        let _ = std::fs::remove_dir_all(&dir);
162    }
163
164    #[tokio::test]
165    async fn test_read_file_offset_past_end_returns_empty() {
166        // Regression: when offset exceeds the file's line count, slicing
167        // lines[offset..end] used to panic with "range start index N out of
168        // range for slice of length M" because offset wasn't clamped.
169        let dir =
170            std::env::temp_dir().join(format!("microclaw_rf_offset_past_{}", uuid::Uuid::new_v4()));
171        std::fs::create_dir_all(&dir).unwrap();
172        let file = dir.join("short.txt");
173        std::fs::write(&file, "a\nb\nc").unwrap();
174
175        let tool = ReadFileTool::new(".");
176        let result = tool
177            .execute(json!({"path": file.to_str().unwrap(), "offset": 369, "limit": 100}))
178            .await;
179        assert!(!result.is_error, "expected ok, got: {}", result.content);
180        assert!(result.content.is_empty());
181
182        let _ = std::fs::remove_dir_all(&dir);
183    }
184
185    #[tokio::test]
186    async fn test_read_file_not_found() {
187        let tool = ReadFileTool::new(".");
188        let result = tool.execute(json!({"path": "/nonexistent/file.txt"})).await;
189        assert!(result.is_error);
190        assert!(result.content.contains("Failed to read file"));
191    }
192
193    #[tokio::test]
194    async fn test_read_file_missing_path() {
195        let tool = ReadFileTool::new(".");
196        let result = tool.execute(json!({})).await;
197        assert!(result.is_error);
198        assert!(result.content.contains("Missing 'path'"));
199    }
200
201    #[tokio::test]
202    async fn test_read_file_resolves_relative_to_working_dir() {
203        let root = std::env::temp_dir().join(format!("microclaw_rf3_{}", uuid::Uuid::new_v4()));
204        let work = root.join("workspace");
205        let shared = work.join("shared");
206        std::fs::create_dir_all(&shared).unwrap();
207        std::fs::write(shared.join("test.txt"), "inside").unwrap();
208
209        let tool = ReadFileTool::new(work.to_str().unwrap());
210        let result = tool.execute(json!({"path": "test.txt"})).await;
211        assert!(!result.is_error);
212        assert!(result.content.contains("inside"));
213
214        let _ = std::fs::remove_dir_all(&root);
215    }
216}