microclaw_engine/tools/
read_file.rs1use 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 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 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 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}