1use async_trait::async_trait;
8use std::path::{Path, PathBuf};
9
10#[cfg_attr(not(feature = "browser"), async_trait)]
15#[cfg_attr(feature = "browser", async_trait(?Send))]
16pub trait FileSystem: Send + Sync {
17 async fn read_to_string(&self, path: &Path) -> std::io::Result<String>;
19
20 async fn write_string(&self, path: &Path, content: &str) -> std::io::Result<()>;
24
25 async fn append_string(&self, path: &Path, content: &str) -> std::io::Result<()>;
27
28 async fn exists(&self, path: &Path) -> bool;
30
31 async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<PathBuf>>;
35
36 async fn create_dir_all(&self, path: &Path) -> std::io::Result<()>;
38
39 async fn remove_file(&self, path: &Path) -> std::io::Result<()>;
41
42 fn home_dir(&self) -> Option<PathBuf>;
44}
45
46#[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 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 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 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 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 assert!(fs.home_dir().is_some());
236 }
237}