Skip to main content

mixtape_tools/filesystem/
read_file.rs

1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use std::path::PathBuf;
4
5/// Input for reading a file
6#[derive(Debug, Deserialize, JsonSchema)]
7pub struct ReadFileInput {
8    /// Path to the file to read (relative to base path or absolute)
9    pub path: PathBuf,
10
11    /// Starting line number (0-indexed, optional)
12    #[serde(default)]
13    pub offset: Option<usize>,
14
15    /// Maximum number of lines to read (optional)
16    #[serde(default)]
17    pub length: Option<usize>,
18}
19
20/// Tool for reading file contents from the filesystem
21pub 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    /// Creates a new tool using the current working directory as the base path.
33    ///
34    /// Equivalent to `Default::default()`.
35    ///
36    /// # Panics
37    ///
38    /// Panics if the current working directory cannot be determined.
39    /// Use [`try_new`](Self::try_new) or [`with_base_path`](Self::with_base_path) instead.
40    pub fn new() -> Self {
41        Self {
42            base_path: std::env::current_dir().expect("Failed to get current working directory"),
43        }
44    }
45
46    /// Creates a new tool using the current working directory as the base path.
47    ///
48    /// Returns an error if the current working directory cannot be determined.
49    pub fn try_new() -> std::io::Result<Self> {
50        Ok(Self {
51            base_path: std::env::current_dir()?,
52        })
53    }
54
55    /// Creates a tool with a custom base directory.
56    ///
57    /// All file operations will be constrained to this directory.
58    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        // Exercise Default, new(), name(), description()
153        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(&params).is_empty());
173        assert!(!tool.format_input_ansi(&params).is_empty());
174        assert!(!tool.format_input_markdown(&params).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        // The error should be about path traversal or canonicalization
230        let err = result.unwrap_err().to_string();
231        assert!(
232            err.contains("canonicalize") || err.contains("escapes") || err.contains("Invalid path")
233        );
234    }
235
236    // ===== Edge Case Tests =====
237
238    #[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        // Verify CRLF is preserved
287        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}