//! [`LocalFileSystem`] — minimal [`FileSystem`] adapter over [`tokio::fs`]. //! //! Maps [`RemotePath`] (a location-neutral string path) onto the local OS //! filesystem. Only the operations needed to wire up the composition root and //! later lots are implemented; richer behaviour (and SSH/WSL siblings) arrives //! in L2/L9. use std::io; use std::path::Path; use async_trait::async_trait; use domain::ports::{DirEntry, FileSystem, FsError, RemotePath}; use tokio::fs; /// Filesystem adapter backed by the local OS via `tokio::fs`. #[derive(Debug, Default, Clone, Copy)] pub struct LocalFileSystem; impl LocalFileSystem { /// Creates a new [`LocalFileSystem`]. #[must_use] pub const fn new() -> Self { Self } } /// Maps a [`std::io::Error`] to the domain's [`FsError`], preserving the /// not-found / permission distinctions the application layer cares about. fn map_io(path: &RemotePath, err: &io::Error) -> FsError { match err.kind() { io::ErrorKind::NotFound => FsError::NotFound(path.as_str().to_owned()), io::ErrorKind::PermissionDenied => FsError::PermissionDenied(path.as_str().to_owned()), _ => FsError::Io(format!("{}: {err}", path.as_str())), } } #[async_trait] impl FileSystem for LocalFileSystem { async fn read(&self, path: &RemotePath) -> Result, FsError> { fs::read(path.as_str()).await.map_err(|e| map_io(path, &e)) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { fs::write(path.as_str(), data) .await .map_err(|e| map_io(path, &e)) } async fn exists(&self, path: &RemotePath) -> Result { match fs::metadata(path.as_str()).await { Ok(_) => Ok(true), Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), Err(e) => Err(map_io(path, &e)), } } async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> { match fs::remove_file(path.as_str()).await { Ok(()) => Ok(()), // Idempotent best-effort delete: an already-absent file is success. Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), Err(e) => Err(map_io(path, &e)), } } async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { fs::create_dir_all(path.as_str()) .await .map_err(|e| map_io(path, &e)) } async fn list(&self, path: &RemotePath) -> Result, FsError> { let mut entries = Vec::new(); let mut read_dir = fs::read_dir(path.as_str()) .await .map_err(|e| map_io(path, &e))?; while let Some(entry) = read_dir.next_entry().await.map_err(|e| map_io(path, &e))? { let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false); entries.push(DirEntry { name: entry.file_name().to_string_lossy().into_owned(), is_dir, }); } Ok(entries) } async fn symlink(&self, src: &RemotePath, dst: &RemotePath) -> Result<(), FsError> { symlink_impl(Path::new(src.as_str()), Path::new(dst.as_str())) .await .map_err(|e| map_io(dst, &e)) } } #[cfg(unix)] async fn symlink_impl(src: &Path, dst: &Path) -> io::Result<()> { fs::symlink(src, dst).await } #[cfg(windows)] async fn symlink_impl(src: &Path, dst: &Path) -> io::Result<()> { // On Windows we default to a file symlink; directory symlinks require a // different call and are handled when the store layer (L2/L6) needs them. fs::symlink_file(src, dst).await }