Skip to main content

clawft_platform/
fs.rs

1//! Filesystem abstraction and native implementation.
2//!
3//! Provides a platform-agnostic [`FileSystem`] trait for file I/O and a native
4//! implementation backed by [`tokio::fs`]. A WASM implementation would use
5//! WASI filesystem or browser-based storage.
6
7use async_trait::async_trait;
8use std::path::{Path, PathBuf};
9
10/// Platform-agnostic filesystem operations.
11///
12/// All path arguments use [`Path`] for proper cross-platform path handling.
13/// Implementations should create parent directories when writing files.
14#[cfg_attr(not(feature = "browser"), async_trait)]
15#[cfg_attr(feature = "browser", async_trait(?Send))]
16pub trait FileSystem: Send + Sync {
17    /// Read a file's entire contents as a UTF-8 string.
18    async fn read_to_string(&self, path: &Path) -> std::io::Result<String>;
19
20    /// Write a string to a file, creating parent directories if needed.
21    ///
22    /// Overwrites the file if it already exists.
23    async fn write_string(&self, path: &Path, content: &str) -> std::io::Result<()>;
24
25    /// Append a string to a file, creating it (and parent dirs) if it does not exist.
26    async fn append_string(&self, path: &Path, content: &str) -> std::io::Result<()>;
27
28    /// Check whether a path exists (file or directory).
29    async fn exists(&self, path: &Path) -> bool;
30
31    /// List all entries in a directory.
32    ///
33    /// Returns the full path of each entry. Does not recurse into subdirectories.
34    async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<PathBuf>>;
35
36    /// Create a directory and all parent directories.
37    async fn create_dir_all(&self, path: &Path) -> std::io::Result<()>;
38
39    /// Remove a file.
40    async fn remove_file(&self, path: &Path) -> std::io::Result<()>;
41
42    /// Get the user's home directory.
43    fn home_dir(&self) -> Option<PathBuf>;
44}
45
46/// Native filesystem implementation using [`tokio::fs`].
47#[cfg(feature = "native")]
48pub struct NativeFileSystem;
49
50#[cfg(feature = "native")]
51#[async_trait]
52impl FileSystem for NativeFileSystem {
53    async fn read_to_string(&self, path: &Path) -> std::io::Result<String> {
54        tokio::fs::read_to_string(path).await
55    }
56
57    async fn write_string(&self, path: &Path, content: &str) -> std::io::Result<()> {
58        if let Some(parent) = path.parent() {
59            tokio::fs::create_dir_all(parent).await?;
60        }
61        tokio::fs::write(path, content).await
62    }
63
64    async fn append_string(&self, path: &Path, content: &str) -> std::io::Result<()> {
65        use tokio::io::AsyncWriteExt;
66
67        if let Some(parent) = path.parent() {
68            tokio::fs::create_dir_all(parent).await?;
69        }
70        let mut file = tokio::fs::OpenOptions::new()
71            .create(true)
72            .append(true)
73            .open(path)
74            .await?;
75        file.write_all(content.as_bytes()).await?;
76        file.flush().await?;
77        Ok(())
78    }
79
80    async fn exists(&self, path: &Path) -> bool {
81        tokio::fs::try_exists(path).await.unwrap_or(false)
82    }
83
84    async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<PathBuf>> {
85        let mut entries = Vec::new();
86        let mut read_dir = tokio::fs::read_dir(path).await?;
87        while let Some(entry) = read_dir.next_entry().await? {
88            entries.push(entry.path());
89        }
90        Ok(entries)
91    }
92
93    async fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
94        tokio::fs::create_dir_all(path).await
95    }
96
97    async fn remove_file(&self, path: &Path) -> std::io::Result<()> {
98        tokio::fs::remove_file(path).await
99    }
100
101    fn home_dir(&self) -> Option<PathBuf> {
102        dirs::home_dir()
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::sync::atomic::{AtomicU64, Ordering};
110
111    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
112
113    /// Generate a unique temp path for test isolation.
114    fn temp_test_path(prefix: &str) -> PathBuf {
115        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
116        let pid = std::process::id();
117        std::env::temp_dir().join(format!("clawft_test_{prefix}_{pid}_{id}"))
118    }
119
120    #[tokio::test]
121    async fn test_write_and_read_string() {
122        let fs = NativeFileSystem;
123        let path = temp_test_path("rw");
124
125        fs.write_string(&path, "hello clawft").await.unwrap();
126        let contents = fs.read_to_string(&path).await.unwrap();
127        assert_eq!(contents, "hello clawft");
128
129        fs.remove_file(&path).await.unwrap();
130    }
131
132    #[tokio::test]
133    async fn test_write_creates_parent_dirs() {
134        let fs = NativeFileSystem;
135        let dir = temp_test_path("parent");
136        let path = dir.join("sub").join("file.txt");
137
138        fs.write_string(&path, "nested").await.unwrap();
139        assert!(fs.exists(&path).await);
140
141        let contents = fs.read_to_string(&path).await.unwrap();
142        assert_eq!(contents, "nested");
143
144        // Cleanup
145        let _ = tokio::fs::remove_dir_all(&dir).await;
146    }
147
148    #[tokio::test]
149    async fn test_append_string() {
150        let fs = NativeFileSystem;
151        let path = temp_test_path("append");
152
153        fs.write_string(&path, "first").await.unwrap();
154        fs.append_string(&path, " second").await.unwrap();
155
156        let contents = fs.read_to_string(&path).await.unwrap();
157        assert_eq!(contents, "first second");
158
159        fs.remove_file(&path).await.unwrap();
160    }
161
162    #[tokio::test]
163    async fn test_exists_true_and_false() {
164        let fs = NativeFileSystem;
165        let path = temp_test_path("exists");
166
167        assert!(!fs.exists(&path).await);
168
169        fs.write_string(&path, "").await.unwrap();
170        assert!(fs.exists(&path).await);
171
172        fs.remove_file(&path).await.unwrap();
173    }
174
175    #[tokio::test]
176    async fn test_list_dir() {
177        let fs = NativeFileSystem;
178        let dir = temp_test_path("listdir");
179        fs.create_dir_all(&dir).await.unwrap();
180
181        fs.write_string(&dir.join("a.txt"), "a").await.unwrap();
182        fs.write_string(&dir.join("b.txt"), "b").await.unwrap();
183
184        let entries = fs.list_dir(&dir).await.unwrap();
185        assert_eq!(entries.len(), 2);
186
187        let names: Vec<String> = entries
188            .iter()
189            .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
190            .collect();
191        assert!(names.contains(&"a.txt".to_string()));
192        assert!(names.contains(&"b.txt".to_string()));
193
194        // Cleanup
195        let _ = tokio::fs::remove_dir_all(&dir).await;
196    }
197
198    #[tokio::test]
199    async fn test_create_dir_all() {
200        let fs = NativeFileSystem;
201        let dir = temp_test_path("mkdirs").join("a").join("b").join("c");
202
203        fs.create_dir_all(&dir).await.unwrap();
204        assert!(fs.exists(&dir).await);
205
206        // Cleanup
207        let root = temp_test_path("mkdirs");
208        let _ = tokio::fs::remove_dir_all(&root).await;
209    }
210
211    #[tokio::test]
212    async fn test_remove_file() {
213        let fs = NativeFileSystem;
214        let path = temp_test_path("remove");
215
216        fs.write_string(&path, "delete me").await.unwrap();
217        assert!(fs.exists(&path).await);
218
219        fs.remove_file(&path).await.unwrap();
220        assert!(!fs.exists(&path).await);
221    }
222
223    #[tokio::test]
224    async fn test_read_nonexistent_file() {
225        let fs = NativeFileSystem;
226        let path = temp_test_path("nonexistent_read");
227        let result = fs.read_to_string(&path).await;
228        assert!(result.is_err());
229    }
230
231    #[test]
232    fn test_home_dir() {
233        let fs = NativeFileSystem;
234        // home_dir should return Some on all standard platforms
235        assert!(fs.home_dir().is_some());
236    }
237}