use crate::fs::{FileSystem, FsError, FsResult, Metadata};
use crate::ipc::GrepMatch;
use async_trait::async_trait;
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Default, Clone, Copy)]
pub struct LocalFileSystem;
impl LocalFileSystem {
pub const fn new() -> Self {
Self
}
}
#[async_trait]
impl FileSystem for LocalFileSystem {
async fn read(&self, path: &Path, max_bytes: Option<usize>) -> FsResult<Vec<u8>> {
let mut buf = fs::read(path).await?;
if let Some(cap) = max_bytes
&& buf.len() > cap
{
buf.truncate(cap);
}
Ok(buf)
}
async fn write(&self, path: &Path, content: &[u8]) -> FsResult<usize> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).await?;
}
fs::write(path, content).await?;
Ok(content.len())
}
async fn edit(
&self,
path: &Path,
old_string: &str,
new_string: &str,
all: bool,
) -> FsResult<usize> {
let original = fs::read_to_string(path).await?;
let (replaced, count) = if all {
let n = original.matches(old_string).count();
if n == 0 {
return Err(FsError::EditNotFound {
path: path.to_path_buf(),
});
}
(original.replace(old_string, new_string), n)
} else if original.contains(old_string) {
(original.replacen(old_string, new_string, 1), 1)
} else {
return Err(FsError::EditNotFound {
path: path.to_path_buf(),
});
};
let _ = &replaced;
fs::write(path, replaced.as_bytes()).await?;
Ok(count)
}
async fn glob(&self, pattern: &str, root: &Path) -> FsResult<Vec<PathBuf>> {
let pattern = pattern.to_string();
let root = root.to_path_buf();
tokio::task::spawn_blocking(move || -> FsResult<Vec<PathBuf>> {
let full = if Path::new(&pattern).is_absolute() {
pattern.clone()
} else {
root.join(&pattern).to_string_lossy().into_owned()
};
let mut out: Vec<PathBuf> = glob::glob(&full)
.map_err(|e| FsError::InvalidPattern {
message: format!("glob pattern {pattern:?}: {e}"),
})?
.filter_map(Result::ok)
.collect();
out.sort();
Ok(out)
})
.await
.map_err(|e| FsError::Transport {
message: format!("glob spawn_blocking join error: {e}"),
})?
}
async fn grep(
&self,
pattern: &str,
root: &Path,
include: Option<&str>,
) -> FsResult<Vec<GrepMatch>> {
let pattern = pattern.to_string();
let include = include.map(str::to_string);
let root = root.to_path_buf();
tokio::task::spawn_blocking(move || -> FsResult<Vec<GrepMatch>> {
let re = regex::Regex::new(&pattern).map_err(|e| FsError::InvalidPattern {
message: format!("grep regex {pattern:?}: {e}"),
})?;
let include_glob = include
.as_ref()
.map(|g| {
glob::Pattern::new(g).map_err(|e| FsError::InvalidPattern {
message: format!("grep include {g:?}: {e}"),
})
})
.transpose()?;
let mut matches = Vec::new();
for entry in ignore::Walk::new(&root) {
let entry = match entry {
Ok(e) => e,
Err(_) => continue, };
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let path = entry.path();
if let Some(g) = &include_glob {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !g.matches(name) {
continue;
}
}
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
for (lineno, line) in content.lines().enumerate() {
if re.is_match(line) {
matches.push(GrepMatch {
path: path.to_path_buf(),
line: lineno + 1, text: line.to_string(),
});
}
}
}
Ok(matches)
})
.await
.map_err(|e| FsError::Transport {
message: format!("grep spawn_blocking join error: {e}"),
})?
}
async fn stat(&self, path: &Path) -> FsResult<Metadata> {
let m = fs::symlink_metadata(path).await?;
Ok(Metadata {
size: m.len(),
is_dir: m.is_dir(),
is_symlink: m.file_type().is_symlink(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fixture() -> (TempDir, LocalFileSystem) {
(TempDir::new().expect("tempdir"), LocalFileSystem::new())
}
#[tokio::test]
async fn read_returns_full_contents_when_no_cap() {
let (dir, fs) = fixture();
let path = dir.path().join("hello.txt");
std::fs::write(&path, b"hello world").unwrap();
let got = fs.read(&path, None).await.unwrap();
assert_eq!(got, b"hello world");
}
#[tokio::test]
async fn read_truncates_to_max_bytes() {
let (dir, fs) = fixture();
let path = dir.path().join("big.txt");
std::fs::write(&path, b"abcdefghij").unwrap();
let got = fs.read(&path, Some(4)).await.unwrap();
assert_eq!(got, b"abcd");
}
#[tokio::test]
async fn read_max_bytes_above_size_returns_full_file() {
let (dir, fs) = fixture();
let path = dir.path().join("small.txt");
std::fs::write(&path, b"hi").unwrap();
let got = fs.read(&path, Some(1024)).await.unwrap();
assert_eq!(got, b"hi");
}
#[tokio::test]
async fn read_missing_file_returns_io_error() {
let (dir, fs) = fixture();
let err = fs
.read(&dir.path().join("nope"), None)
.await
.expect_err("missing file must error");
assert!(matches!(err, FsError::Io(_)));
}
#[tokio::test]
async fn write_creates_file_and_returns_byte_count() {
let (dir, fs) = fixture();
let path = dir.path().join("out.txt");
let n = fs.write(&path, b"abc").await.unwrap();
assert_eq!(n, 3);
assert_eq!(std::fs::read(&path).unwrap(), b"abc");
}
#[tokio::test]
async fn write_creates_missing_parent_dirs() {
let (dir, fs) = fixture();
let path = dir.path().join("a/b/c/deep.txt");
let n = fs.write(&path, b"hi").await.unwrap();
assert_eq!(n, 2);
assert!(path.exists());
}
#[tokio::test]
async fn write_overwrites_existing_file() {
let (dir, fs) = fixture();
let path = dir.path().join("over.txt");
std::fs::write(&path, b"old contents").unwrap();
fs.write(&path, b"new").await.unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
}
#[tokio::test]
async fn edit_replaces_first_occurrence_when_all_false() {
let (dir, fs) = fixture();
let path = dir.path().join("e.txt");
std::fs::write(&path, b"foo bar foo baz foo").unwrap();
let n = fs.edit(&path, "foo", "FOO", false).await.unwrap();
assert_eq!(n, 1);
assert_eq!(std::fs::read(&path).unwrap(), b"FOO bar foo baz foo");
}
#[tokio::test]
async fn edit_replaces_all_occurrences_when_all_true() {
let (dir, fs) = fixture();
let path = dir.path().join("e.txt");
std::fs::write(&path, b"foo bar foo baz foo").unwrap();
let n = fs.edit(&path, "foo", "FOO", true).await.unwrap();
assert_eq!(n, 3);
assert_eq!(std::fs::read(&path).unwrap(), b"FOO bar FOO baz FOO");
}
#[tokio::test]
async fn edit_missing_old_string_returns_edit_not_found() {
let (dir, fs) = fixture();
let path = dir.path().join("e.txt");
std::fs::write(&path, b"hello").unwrap();
let err = fs
.edit(&path, "world", "X", false)
.await
.expect_err("must fail");
assert!(matches!(err, FsError::EditNotFound { .. }));
assert_eq!(std::fs::read(&path).unwrap(), b"hello");
}
#[tokio::test]
async fn glob_matches_files_relative_to_root() {
let (dir, fs) = fixture();
std::fs::write(dir.path().join("a.rs"), b"").unwrap();
std::fs::write(dir.path().join("b.rs"), b"").unwrap();
std::fs::write(dir.path().join("c.txt"), b"").unwrap();
let got = fs.glob("*.rs", dir.path()).await.unwrap();
assert_eq!(got.len(), 2);
assert!(got[0].file_name().unwrap() < got[1].file_name().unwrap());
}
#[tokio::test]
async fn glob_recursive_pattern_works() {
let (dir, fs) = fixture();
std::fs::create_dir(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("top.rs"), b"").unwrap();
std::fs::write(dir.path().join("sub/inner.rs"), b"").unwrap();
let got = fs.glob("**/*.rs", dir.path()).await.unwrap();
assert_eq!(got.len(), 2);
}
#[tokio::test]
async fn glob_invalid_pattern_returns_invalid_pattern() {
let (dir, fs) = fixture();
let err = fs
.glob("[abc", dir.path())
.await
.expect_err("invalid pattern must error");
assert!(matches!(err, FsError::InvalidPattern { .. }));
}
#[tokio::test]
async fn grep_finds_matches_with_line_numbers() {
let (dir, fs) = fixture();
std::fs::write(dir.path().join("a.txt"), b"hello\nworld\nhello again\n").unwrap();
let got = fs.grep("hello", dir.path(), None).await.unwrap();
assert_eq!(got.len(), 2);
assert_eq!(got[0].line, 1);
assert_eq!(got[1].line, 3);
assert!(got[0].text.contains("hello"));
}
#[tokio::test]
async fn grep_respects_include_filter() {
let (dir, fs) = fixture();
std::fs::write(dir.path().join("a.rs"), b"fn target() {}").unwrap();
std::fs::write(dir.path().join("b.txt"), b"fn target() {}").unwrap();
let got = fs.grep("target", dir.path(), Some("*.rs")).await.unwrap();
assert_eq!(got.len(), 1);
assert!(got[0].path.extension().unwrap() == "rs");
}
#[tokio::test]
async fn grep_skips_binary_files_silently() {
let (dir, fs) = fixture();
std::fs::write(dir.path().join("a.txt"), b"hello text\n").unwrap();
std::fs::write(dir.path().join("b.bin"), b"\x00\xc3\x28hello\x00\xc3\x28").unwrap();
let got = fs.grep("hello", dir.path(), None).await.unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].path.extension().unwrap(), "txt");
}
#[tokio::test]
async fn grep_invalid_regex_returns_invalid_pattern() {
let (dir, fs) = fixture();
std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
let err = fs
.grep("[unclosed", dir.path(), None)
.await
.expect_err("invalid regex must error");
assert!(matches!(err, FsError::InvalidPattern { .. }));
}
#[tokio::test]
async fn grep_honors_ignore_files() {
let (dir, fs) = fixture();
std::fs::write(dir.path().join(".ignore"), b"ignored.txt\n").unwrap();
std::fs::write(dir.path().join("ignored.txt"), b"target line").unwrap();
std::fs::write(dir.path().join("kept.txt"), b"target line").unwrap();
let got = fs.grep("target", dir.path(), None).await.unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].path.file_name().unwrap(), "kept.txt");
}
#[tokio::test]
async fn stat_reports_file_size_and_type() {
let (dir, fs) = fixture();
let path = dir.path().join("s.txt");
std::fs::write(&path, b"abcdef").unwrap();
let m = fs.stat(&path).await.unwrap();
assert_eq!(m.size, 6);
assert!(!m.is_dir);
assert!(!m.is_symlink);
}
#[tokio::test]
async fn stat_reports_directory() {
let (dir, fs) = fixture();
let m = fs.stat(dir.path()).await.unwrap();
assert!(m.is_dir);
assert!(!m.is_symlink);
}
#[cfg(unix)]
#[tokio::test]
async fn stat_reports_symlink_without_following() {
let (dir, fs) = fixture();
let target = dir.path().join("real.txt");
std::fs::write(&target, b"target").unwrap();
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&target, &link).unwrap();
let m = fs.stat(&link).await.unwrap();
assert!(m.is_symlink);
assert!(!m.is_dir);
}
}