Skip to main content

sage_runtime/tools/
filesystem.rs

1//! RFC-0011: FileSystem tool for Sage agents.
2//!
3//! Provides the `Fs` tool with file operations.
4
5use crate::error::{SageError, SageResult};
6use crate::mock::{try_get_mock, MockResponse};
7use std::path::PathBuf;
8
9/// FileSystem client for Sage agents.
10///
11/// Created via `FsClient::new()` or `FsClient::with_root()`.
12#[derive(Debug, Clone)]
13pub struct FsClient {
14    root: PathBuf,
15}
16
17impl FsClient {
18    /// Create a new filesystem client with current directory as root.
19    pub fn new() -> Self {
20        Self {
21            root: PathBuf::from("."),
22        }
23    }
24
25    /// Create a new filesystem client from environment variables.
26    ///
27    /// Reads:
28    /// - `SAGE_FS_ROOT`: Root directory for filesystem operations (default: ".")
29    pub fn from_env() -> Self {
30        let root = std::env::var("SAGE_FS_ROOT")
31            .map(PathBuf::from)
32            .unwrap_or_else(|_| PathBuf::from("."));
33
34        Self { root }
35    }
36
37    /// Create a new filesystem client with the given root directory.
38    pub fn with_root(root: PathBuf) -> Self {
39        Self { root }
40    }
41
42    /// Resolve a path relative to the root directory.
43    fn resolve_path(&self, path: &str) -> PathBuf {
44        self.root.join(path)
45    }
46
47    /// Read a file's contents as a string.
48    ///
49    /// # Arguments
50    /// * `path` - Path to the file (relative to root)
51    ///
52    /// # Returns
53    /// The file contents as a string.
54    pub async fn read(&self, path: String) -> SageResult<String> {
55        // Check for mock response first
56        if let Some(mock_response) = try_get_mock("Fs", "read") {
57            return Self::apply_mock_string(mock_response);
58        }
59
60        let full_path = self.resolve_path(&path);
61        let content = tokio::fs::read_to_string(&full_path).await?;
62        Ok(content)
63    }
64
65    /// Write content to a file.
66    ///
67    /// # Arguments
68    /// * `path` - Path to the file (relative to root)
69    /// * `content` - Content to write
70    ///
71    /// # Returns
72    /// Unit on success.
73    pub async fn write(&self, path: String, content: String) -> SageResult<()> {
74        // Check for mock response first
75        if let Some(mock_response) = try_get_mock("Fs", "write") {
76            return Self::apply_mock_unit(mock_response);
77        }
78
79        let full_path = self.resolve_path(&path);
80        // Create parent directories if they don't exist
81        if let Some(parent) = full_path.parent() {
82            tokio::fs::create_dir_all(parent).await?;
83        }
84        tokio::fs::write(&full_path, content).await?;
85        Ok(())
86    }
87
88    /// Check if a path exists.
89    ///
90    /// # Arguments
91    /// * `path` - Path to check (relative to root)
92    ///
93    /// # Returns
94    /// `true` if the path exists, `false` otherwise.
95    pub async fn exists(&self, path: String) -> SageResult<bool> {
96        // Check for mock response first
97        if let Some(mock_response) = try_get_mock("Fs", "exists") {
98            return Self::apply_mock_bool(mock_response);
99        }
100
101        let full_path = self.resolve_path(&path);
102        Ok(full_path.exists())
103    }
104
105    /// List files and directories in a path.
106    ///
107    /// # Arguments
108    /// * `path` - Directory path (relative to root)
109    ///
110    /// # Returns
111    /// List of file/directory names.
112    pub async fn list(&self, path: String) -> SageResult<Vec<String>> {
113        // Check for mock response first
114        if let Some(mock_response) = try_get_mock("Fs", "list") {
115            return Self::apply_mock_vec_string(mock_response);
116        }
117
118        let full_path = self.resolve_path(&path);
119        let mut entries = tokio::fs::read_dir(&full_path).await?;
120        let mut names = Vec::new();
121        while let Some(entry) = entries.next_entry().await? {
122            if let Some(name) = entry.file_name().to_str() {
123                names.push(name.to_string());
124            }
125        }
126        Ok(names)
127    }
128
129    /// Delete a file.
130    ///
131    /// # Arguments
132    /// * `path` - Path to the file (relative to root)
133    ///
134    /// # Returns
135    /// Unit on success.
136    pub async fn delete(&self, path: String) -> SageResult<()> {
137        // Check for mock response first
138        if let Some(mock_response) = try_get_mock("Fs", "delete") {
139            return Self::apply_mock_unit(mock_response);
140        }
141
142        let full_path = self.resolve_path(&path);
143        tokio::fs::remove_file(&full_path).await?;
144        Ok(())
145    }
146
147    /// Apply a mock response for String.
148    fn apply_mock_string(mock_response: MockResponse) -> SageResult<String> {
149        match mock_response {
150            MockResponse::Value(v) => serde_json::from_value(v)
151                .map_err(|e| SageError::Tool(format!("mock deserialize: {e}"))),
152            MockResponse::Fail(msg) => Err(SageError::Tool(msg)),
153        }
154    }
155
156    /// Apply a mock response for ().
157    fn apply_mock_unit(mock_response: MockResponse) -> SageResult<()> {
158        match mock_response {
159            MockResponse::Value(_) => Ok(()),
160            MockResponse::Fail(msg) => Err(SageError::Tool(msg)),
161        }
162    }
163
164    /// Apply a mock response for bool.
165    fn apply_mock_bool(mock_response: MockResponse) -> SageResult<bool> {
166        match mock_response {
167            MockResponse::Value(v) => serde_json::from_value(v)
168                .map_err(|e| SageError::Tool(format!("mock deserialize: {e}"))),
169            MockResponse::Fail(msg) => Err(SageError::Tool(msg)),
170        }
171    }
172
173    /// Apply a mock response for Vec<String>.
174    fn apply_mock_vec_string(mock_response: MockResponse) -> SageResult<Vec<String>> {
175        match mock_response {
176            MockResponse::Value(v) => serde_json::from_value(v)
177                .map_err(|e| SageError::Tool(format!("mock deserialize: {e}"))),
178            MockResponse::Fail(msg) => Err(SageError::Tool(msg)),
179        }
180    }
181}
182
183impl Default for FsClient {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn filesystem_client_creates() {
195        let client = FsClient::new();
196        assert_eq!(client.root, PathBuf::from("."));
197    }
198
199    #[test]
200    fn filesystem_client_with_root() {
201        let client = FsClient::with_root(PathBuf::from("/tmp"));
202        assert_eq!(client.root, PathBuf::from("/tmp"));
203    }
204
205    #[tokio::test]
206    async fn filesystem_read_write() {
207        let temp_dir = tempfile::tempdir().unwrap();
208        let client = FsClient::with_root(temp_dir.path().to_path_buf());
209
210        // Write a file
211        client
212            .write("test.txt".to_string(), "Hello, World!".to_string())
213            .await
214            .unwrap();
215
216        // Read it back
217        let content = client.read("test.txt".to_string()).await.unwrap();
218        assert_eq!(content, "Hello, World!");
219
220        // Check it exists
221        assert!(client.exists("test.txt".to_string()).await.unwrap());
222
223        // Delete it
224        client.delete("test.txt".to_string()).await.unwrap();
225
226        // Check it's gone
227        assert!(!client.exists("test.txt".to_string()).await.unwrap());
228    }
229
230    #[tokio::test]
231    async fn filesystem_list() {
232        let temp_dir = tempfile::tempdir().unwrap();
233        let client = FsClient::with_root(temp_dir.path().to_path_buf());
234
235        // Create some files
236        client
237            .write("a.txt".to_string(), "a".to_string())
238            .await
239            .unwrap();
240        client
241            .write("b.txt".to_string(), "b".to_string())
242            .await
243            .unwrap();
244
245        // List the directory
246        let mut files = client.list(".".to_string()).await.unwrap();
247        files.sort();
248        assert_eq!(files, vec!["a.txt", "b.txt"]);
249    }
250
251    #[tokio::test]
252    async fn filesystem_write_creates_parents() {
253        let temp_dir = tempfile::tempdir().unwrap();
254        let client = FsClient::with_root(temp_dir.path().to_path_buf());
255
256        // Write to a nested path
257        client
258            .write("nested/dir/file.txt".to_string(), "content".to_string())
259            .await
260            .unwrap();
261
262        // Verify it was created
263        assert!(client
264            .exists("nested/dir/file.txt".to_string())
265            .await
266            .unwrap());
267    }
268}