mixtape_tools/filesystem/
read_file.rs1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use std::path::PathBuf;
4
5#[derive(Debug, Deserialize, JsonSchema)]
7pub struct ReadFileInput {
8 pub path: PathBuf,
10
11 #[serde(default)]
13 pub offset: Option<usize>,
14
15 #[serde(default)]
17 pub length: Option<usize>,
18}
19
20pub struct ReadFileTool {
22 base_path: PathBuf,
23}
24
25impl Default for ReadFileTool {
26 fn default() -> Self {
27 Self::new()
28 }
29}
30
31impl ReadFileTool {
32 pub fn new() -> Self {
41 Self {
42 base_path: std::env::current_dir().expect("Failed to get current working directory"),
43 }
44 }
45
46 pub fn try_new() -> std::io::Result<Self> {
50 Ok(Self {
51 base_path: std::env::current_dir()?,
52 })
53 }
54
55 pub fn with_base_path(base_path: PathBuf) -> Self {
59 Self { base_path }
60 }
61}
62
63impl Tool for ReadFileTool {
64 type Input = ReadFileInput;
65
66 fn name(&self) -> &str {
67 "read_file"
68 }
69
70 fn description(&self) -> &str {
71 "Read the contents of a file from the filesystem. Supports reading entire files or specific line ranges."
72 }
73
74 fn format_output_plain(&self, result: &ToolResult) -> String {
75 let content = result.as_text();
76 if content.is_empty() {
77 return "(empty file)".to_string();
78 }
79
80 let lines: Vec<&str> = content.lines().collect();
81 let width = lines.len().to_string().len().max(3);
82
83 let mut out = String::new();
84 for (i, line) in lines.iter().enumerate() {
85 out.push_str(&format!("{:>width$} │ {}\n", i + 1, line, width = width));
86 }
87 out
88 }
89
90 fn format_output_ansi(&self, result: &ToolResult) -> String {
91 let content = result.as_text();
92 if content.is_empty() {
93 return "\x1b[2m(empty file)\x1b[0m".to_string();
94 }
95
96 let lines: Vec<&str> = content.lines().collect();
97 let width = lines.len().to_string().len().max(3);
98
99 let mut out = String::new();
100 for (i, line) in lines.iter().enumerate() {
101 out.push_str(&format!(
102 "\x1b[36m{:>width$}\x1b[0m \x1b[2m│\x1b[0m {}\n",
103 i + 1,
104 line,
105 width = width
106 ));
107 }
108 out
109 }
110
111 fn format_output_markdown(&self, result: &ToolResult) -> String {
112 let content = result.as_text();
113 if content.is_empty() {
114 return "*Empty file*".to_string();
115 }
116 format!("```\n{}\n```", content)
117 }
118
119 async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
120 let path = validate_path(&self.base_path, &input.path)
121 .map_err(|e| ToolError::from(e.to_string()))?;
122
123 let content = tokio::fs::read_to_string(&path)
124 .await
125 .map_err(|e| ToolError::from(format!("Failed to read file: {}", e)))?;
126
127 let result = if input.offset.is_some() || input.length.is_some() {
128 let lines: Vec<&str> = content.lines().collect();
129 let offset = input.offset.unwrap_or(0);
130 let length = input.length.unwrap_or(lines.len().saturating_sub(offset));
131
132 let selected_lines: Vec<&str> =
133 lines.iter().skip(offset).take(length).copied().collect();
134
135 selected_lines.join("\n")
136 } else {
137 content
138 };
139
140 Ok(result.into())
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use std::fs;
148 use tempfile::TempDir;
149
150 #[test]
151 fn test_tool_metadata() {
152 let tool: ReadFileTool = Default::default();
154 assert_eq!(tool.name(), "read_file");
155 assert!(!tool.description().is_empty());
156
157 let tool2 = ReadFileTool::new();
158 assert_eq!(tool2.name(), "read_file");
159 }
160
161 #[test]
162 fn test_try_new() {
163 let tool = ReadFileTool::try_new();
164 assert!(tool.is_ok());
165 }
166
167 #[test]
168 fn test_format_methods() {
169 let tool = ReadFileTool::new();
170 let params = serde_json::json!({"path": "test.txt"});
171
172 assert!(!tool.format_input_plain(¶ms).is_empty());
173 assert!(!tool.format_input_ansi(¶ms).is_empty());
174 assert!(!tool.format_input_markdown(¶ms).is_empty());
175
176 let result = ToolResult::from("file content");
177 assert!(!tool.format_output_plain(&result).is_empty());
178 assert!(!tool.format_output_ansi(&result).is_empty());
179 assert!(!tool.format_output_markdown(&result).is_empty());
180 }
181
182 #[tokio::test]
183 async fn test_read_file_full() {
184 let temp_dir = TempDir::new().unwrap();
185 let file_path = temp_dir.path().join("test.txt");
186 fs::write(&file_path, "line1\nline2\nline3").unwrap();
187
188 let tool = ReadFileTool::with_base_path(temp_dir.path().to_path_buf());
189 let input = ReadFileInput {
190 path: PathBuf::from("test.txt"),
191 offset: None,
192 length: None,
193 };
194
195 let result = tool.execute(input).await.unwrap();
196 assert_eq!(result.as_text(), "line1\nline2\nline3");
197 }
198
199 #[tokio::test]
200 async fn test_read_file_with_offset() {
201 let temp_dir = TempDir::new().unwrap();
202 let file_path = temp_dir.path().join("test.txt");
203 fs::write(&file_path, "line1\nline2\nline3\nline4").unwrap();
204
205 let tool = ReadFileTool::with_base_path(temp_dir.path().to_path_buf());
206 let input = ReadFileInput {
207 path: PathBuf::from("test.txt"),
208 offset: Some(1),
209 length: Some(2),
210 };
211
212 let result = tool.execute(input).await.unwrap();
213 assert_eq!(result.as_text(), "line2\nline3");
214 }
215
216 #[tokio::test]
217 async fn test_read_file_rejects_traversal() {
218 let temp_dir = TempDir::new().unwrap();
219 let tool = ReadFileTool::with_base_path(temp_dir.path().to_path_buf());
220
221 let input = ReadFileInput {
222 path: PathBuf::from("../../../etc/passwd"),
223 offset: None,
224 length: None,
225 };
226
227 let result = tool.execute(input).await;
228 assert!(result.is_err());
229 let err = result.unwrap_err().to_string();
231 assert!(
232 err.contains("canonicalize") || err.contains("escapes") || err.contains("Invalid path")
233 );
234 }
235
236 #[tokio::test]
239 async fn test_read_file_utf8_characters() {
240 let temp_dir = TempDir::new().unwrap();
241 let utf8_content = "Hello 世界! Ümläüts: äöü 🎵";
242 fs::write(temp_dir.path().join("utf8.txt"), utf8_content).unwrap();
243
244 let tool = ReadFileTool::with_base_path(temp_dir.path().to_path_buf());
245 let input = ReadFileInput {
246 path: PathBuf::from("utf8.txt"),
247 offset: None,
248 length: None,
249 };
250
251 let result = tool.execute(input).await.unwrap();
252 assert_eq!(result.as_text(), utf8_content);
253 }
254
255 #[tokio::test]
256 async fn test_read_file_empty() {
257 let temp_dir = TempDir::new().unwrap();
258 fs::write(temp_dir.path().join("empty.txt"), "").unwrap();
259
260 let tool = ReadFileTool::with_base_path(temp_dir.path().to_path_buf());
261 let input = ReadFileInput {
262 path: PathBuf::from("empty.txt"),
263 offset: None,
264 length: None,
265 };
266
267 let result = tool.execute(input).await.unwrap();
268 assert_eq!(result.as_text(), "");
269 }
270
271 #[tokio::test]
272 async fn test_read_file_preserves_line_endings() {
273 let temp_dir = TempDir::new().unwrap();
274 let crlf_content = "Line 1\r\nLine 2\r\nLine 3\r\n";
275 std::fs::write(temp_dir.path().join("crlf.txt"), crlf_content).unwrap();
276
277 let tool = ReadFileTool::with_base_path(temp_dir.path().to_path_buf());
278 let input = ReadFileInput {
279 path: PathBuf::from("crlf.txt"),
280 offset: None,
281 length: None,
282 };
283
284 let result = tool.execute(input).await.unwrap();
285 let content = result.as_text();
286 assert!(content.contains("\r\n"));
288 assert_eq!(content, crlf_content);
289 }
290
291 #[tokio::test]
292 async fn test_read_file_nonexistent() {
293 let temp_dir = TempDir::new().unwrap();
294 let tool = ReadFileTool::with_base_path(temp_dir.path().to_path_buf());
295
296 let input = ReadFileInput {
297 path: PathBuf::from("nonexistent.txt"),
298 offset: None,
299 length: None,
300 };
301
302 let result = tool.execute(input).await;
303 assert!(result.is_err());
304 let err = result.unwrap_err().to_string();
305 assert!(err.contains("Failed to read file") || err.contains("No such file"));
306 }
307}