use async_trait::async_trait;
use std::path::{Path, PathBuf};
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
pub trait FileSystem: Send + Sync {
async fn read_to_string(&self, path: &Path) -> std::io::Result<String>;
async fn write_string(&self, path: &Path, content: &str) -> std::io::Result<()>;
async fn append_string(&self, path: &Path, content: &str) -> std::io::Result<()>;
async fn exists(&self, path: &Path) -> bool;
async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<PathBuf>>;
async fn create_dir_all(&self, path: &Path) -> std::io::Result<()>;
async fn remove_file(&self, path: &Path) -> std::io::Result<()>;
fn home_dir(&self) -> Option<PathBuf>;
}
#[cfg(feature = "native")]
pub struct NativeFileSystem;
#[cfg(feature = "native")]
#[async_trait]
impl FileSystem for NativeFileSystem {
async fn read_to_string(&self, path: &Path) -> std::io::Result<String> {
tokio::fs::read_to_string(path).await
}
async fn write_string(&self, path: &Path, content: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(path, content).await
}
async fn append_string(&self, path: &Path, content: &str) -> std::io::Result<()> {
use tokio::io::AsyncWriteExt;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await?;
file.write_all(content.as_bytes()).await?;
file.flush().await?;
Ok(())
}
async fn exists(&self, path: &Path) -> bool {
tokio::fs::try_exists(path).await.unwrap_or(false)
}
async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut entries = Vec::new();
let mut read_dir = tokio::fs::read_dir(path).await?;
while let Some(entry) = read_dir.next_entry().await? {
entries.push(entry.path());
}
Ok(entries)
}
async fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
tokio::fs::create_dir_all(path).await
}
async fn remove_file(&self, path: &Path) -> std::io::Result<()> {
tokio::fs::remove_file(path).await
}
fn home_dir(&self) -> Option<PathBuf> {
dirs::home_dir()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_test_path(prefix: &str) -> PathBuf {
let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
std::env::temp_dir().join(format!("clawft_test_{prefix}_{pid}_{id}"))
}
#[tokio::test]
async fn test_write_and_read_string() {
let fs = NativeFileSystem;
let path = temp_test_path("rw");
fs.write_string(&path, "hello clawft").await.unwrap();
let contents = fs.read_to_string(&path).await.unwrap();
assert_eq!(contents, "hello clawft");
fs.remove_file(&path).await.unwrap();
}
#[tokio::test]
async fn test_write_creates_parent_dirs() {
let fs = NativeFileSystem;
let dir = temp_test_path("parent");
let path = dir.join("sub").join("file.txt");
fs.write_string(&path, "nested").await.unwrap();
assert!(fs.exists(&path).await);
let contents = fs.read_to_string(&path).await.unwrap();
assert_eq!(contents, "nested");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn test_append_string() {
let fs = NativeFileSystem;
let path = temp_test_path("append");
fs.write_string(&path, "first").await.unwrap();
fs.append_string(&path, " second").await.unwrap();
let contents = fs.read_to_string(&path).await.unwrap();
assert_eq!(contents, "first second");
fs.remove_file(&path).await.unwrap();
}
#[tokio::test]
async fn test_exists_true_and_false() {
let fs = NativeFileSystem;
let path = temp_test_path("exists");
assert!(!fs.exists(&path).await);
fs.write_string(&path, "").await.unwrap();
assert!(fs.exists(&path).await);
fs.remove_file(&path).await.unwrap();
}
#[tokio::test]
async fn test_list_dir() {
let fs = NativeFileSystem;
let dir = temp_test_path("listdir");
fs.create_dir_all(&dir).await.unwrap();
fs.write_string(&dir.join("a.txt"), "a").await.unwrap();
fs.write_string(&dir.join("b.txt"), "b").await.unwrap();
let entries = fs.list_dir(&dir).await.unwrap();
assert_eq!(entries.len(), 2);
let names: Vec<String> = entries
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert!(names.contains(&"a.txt".to_string()));
assert!(names.contains(&"b.txt".to_string()));
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn test_create_dir_all() {
let fs = NativeFileSystem;
let dir = temp_test_path("mkdirs").join("a").join("b").join("c");
fs.create_dir_all(&dir).await.unwrap();
assert!(fs.exists(&dir).await);
let root = temp_test_path("mkdirs");
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test]
async fn test_remove_file() {
let fs = NativeFileSystem;
let path = temp_test_path("remove");
fs.write_string(&path, "delete me").await.unwrap();
assert!(fs.exists(&path).await);
fs.remove_file(&path).await.unwrap();
assert!(!fs.exists(&path).await);
}
#[tokio::test]
async fn test_read_nonexistent_file() {
let fs = NativeFileSystem;
let path = temp_test_path("nonexistent_read");
let result = fs.read_to_string(&path).await;
assert!(result.is_err());
}
#[test]
fn test_home_dir() {
let fs = NativeFileSystem;
assert!(fs.home_dir().is_some());
}
}