use std::collections::BTreeMap;
use std::io::{self, Read as _};
use std::ops::Bound;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct DirEntry {
pub path: PathBuf,
pub is_dir: bool,
pub is_file: bool,
pub is_symlink: bool,
}
impl DirEntry {
#[must_use]
pub const fn new(path: PathBuf, is_dir: bool, is_file: bool) -> Self {
Self {
path,
is_dir,
is_file,
is_symlink: false,
}
}
#[must_use]
pub const fn symlink(mut self) -> Self {
self.is_symlink = true;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct PathKind {
pub is_file: bool,
pub is_dir: bool,
}
impl PathKind {
pub const NONE: Self = Self {
is_file: false,
is_dir: false,
};
pub const FILE: Self = Self {
is_file: true,
is_dir: false,
};
pub const DIR: Self = Self {
is_file: false,
is_dir: true,
};
}
pub trait FileSystem: Send + Sync {
fn read_to_string(&self, path: &Path) -> io::Result<Arc<str>>;
fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>>;
fn read_dir_shared(&self, path: &Path) -> io::Result<Arc<[DirEntry]>> {
self.read_dir(path).map(Arc::from)
}
fn is_file(&self, path: &Path) -> bool;
fn is_dir(&self, path: &Path) -> bool;
fn kind(&self, path: &Path) -> PathKind {
let is_file = self.is_file(path);
PathKind {
is_file,
is_dir: !is_file && self.is_dir(path),
}
}
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
Ok(path.to_path_buf())
}
fn read_bytes(&self, path: &Path) -> io::Result<Vec<u8>> {
self.read_to_string(path).map(|text| text.as_bytes().to_vec())
}
fn read_prefix(&self, path: &Path, limit: usize) -> io::Result<Vec<u8>> {
self.read_bytes(path).map(|mut bytes| {
bytes.truncate(limit);
bytes
})
}
fn modified(&self, path: &Path) -> Option<SystemTime> {
let _ = path;
None
}
}
macro_rules! forward_file_system {
($($pointer:ty),+ $(,)?) => {$(
impl<F: FileSystem + ?Sized> FileSystem for $pointer {
fn read_to_string(&self, path: &Path) -> io::Result<Arc<str>> {
(**self).read_to_string(path)
}
fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
(**self).read_dir(path)
}
fn read_dir_shared(&self, path: &Path) -> io::Result<Arc<[DirEntry]>> {
(**self).read_dir_shared(path)
}
fn is_file(&self, path: &Path) -> bool {
(**self).is_file(path)
}
fn is_dir(&self, path: &Path) -> bool {
(**self).is_dir(path)
}
fn kind(&self, path: &Path) -> PathKind {
(**self).kind(path)
}
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
(**self).canonicalize(path)
}
fn read_bytes(&self, path: &Path) -> io::Result<Vec<u8>> {
(**self).read_bytes(path)
}
fn read_prefix(&self, path: &Path, limit: usize) -> io::Result<Vec<u8>> {
(**self).read_prefix(path, limit)
}
fn modified(&self, path: &Path) -> Option<SystemTime> {
(**self).modified(path)
}
}
)+};
}
forward_file_system!(&F, Box<F>, Arc<F>);
#[derive(Debug, Clone, Copy, Default)]
pub struct OsFs;
impl FileSystem for OsFs {
fn read_to_string(&self, path: &Path) -> io::Result<Arc<str>> {
let bytes = std::fs::read(path)?;
let text = simdutf8::basic::from_utf8(&bytes)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stream did not contain valid UTF-8"))?;
Ok(Arc::from(text))
}
fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
let mut entries = Vec::new();
for entry in std::fs::read_dir(path)? {
let Ok(entry) = entry else { continue };
let path = entry.path();
let (is_dir, is_file, is_symlink) = match entry.file_type() {
Ok(file_type) if file_type.is_symlink() => {
let (is_dir, is_file) = std::fs::metadata(&path)
.map_or((false, false), |metadata| (metadata.is_dir(), metadata.is_file()));
(is_dir, is_file, true)
}
Ok(file_type) => (file_type.is_dir(), file_type.is_file(), false),
Err(_) => match std::fs::metadata(&path) {
Ok(metadata) => (metadata.is_dir(), metadata.is_file(), false),
Err(_) => continue,
},
};
let entry = DirEntry::new(path, is_dir, is_file);
entries.push(if is_symlink { entry.symlink() } else { entry });
}
entries.sort_unstable_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
Ok(entries)
}
fn is_file(&self, path: &Path) -> bool {
path.is_file()
}
fn is_dir(&self, path: &Path) -> bool {
path.is_dir()
}
fn kind(&self, path: &Path) -> PathKind {
std::fs::metadata(path).map_or(PathKind::NONE, |metadata| PathKind {
is_file: metadata.is_file(),
is_dir: metadata.is_dir(),
})
}
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
std::fs::canonicalize(path)
}
fn read_bytes(&self, path: &Path) -> io::Result<Vec<u8>> {
std::fs::read(path)
}
fn read_prefix(&self, path: &Path, limit: usize) -> io::Result<Vec<u8>> {
let mut head = Vec::new();
std::fs::File::open(path)?
.take(u64::try_from(limit).unwrap_or(u64::MAX))
.read_to_end(&mut head)?;
Ok(head)
}
fn modified(&self, path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).and_then(|metadata| metadata.modified()).ok()
}
}
#[derive(Debug, Clone, Default)]
pub struct MemoryFs {
files: BTreeMap<PathBuf, Arc<str>>,
}
impl MemoryFs {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_file(mut self, path: impl Into<PathBuf>, content: impl AsRef<str>) -> Self {
self.insert(path, content);
self
}
pub fn insert(&mut self, path: impl Into<PathBuf>, content: impl AsRef<str>) {
self.files.insert(path.into(), Arc::from(content.as_ref()));
}
fn below<'a>(&'a self, dir: &'a Path) -> impl Iterator<Item = &'a PathBuf> + 'a {
self.files
.range::<Path, _>((Bound::Included(dir), Bound::Unbounded))
.map(|(path, _)| path)
.take_while(move |path| path.starts_with(dir))
.filter(move |path| path.as_path() != dir)
}
}
fn not_found(path: &Path) -> io::Error {
io::Error::new(io::ErrorKind::NotFound, format!("{} not found", path.display()))
}
impl FileSystem for MemoryFs {
fn read_to_string(&self, path: &Path) -> io::Result<Arc<str>> {
self.files.get(path).cloned().ok_or_else(|| not_found(path))
}
fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
let mut children: BTreeMap<PathBuf, (bool, bool)> = BTreeMap::new();
for file in self.below(path) {
let Ok(rest) = file.strip_prefix(path) else { continue };
let mut components = rest.components();
let Some(first) = components.next() else { continue };
let is_file = components.next().is_none();
let flags = children.entry(path.join(first.as_os_str())).or_default();
if is_file {
flags.1 = true;
} else {
flags.0 = true;
}
}
if children.is_empty() {
return Err(not_found(path));
}
Ok(children
.into_iter()
.map(|(path, (is_dir, is_file))| DirEntry::new(path, is_dir, is_file))
.collect())
}
fn is_file(&self, path: &Path) -> bool {
self.files.contains_key(path)
}
fn is_dir(&self, path: &Path) -> bool {
self.below(path).next().is_some()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn tree() -> MemoryFs {
MemoryFs::new()
.with_file("/ws/a/package.json", "{}")
.with_file("/ws/a/src/index.js", "")
.with_file("/ws/a-b/package.json", "{}")
.with_file("/ws/ab", "file")
.with_file("/ws/z/package.json", "{}")
}
fn names(entries: &[DirEntry]) -> Vec<(String, bool, bool)> {
entries
.iter()
.map(|e| {
(
e.path.file_name().unwrap().to_string_lossy().into_owned(),
e.is_dir,
e.is_file,
)
})
.collect()
}
#[test]
fn memory_listing_is_a_sorted_range_that_ignores_prefix_siblings() {
let fs = tree();
assert_eq!(
names(&fs.read_dir(Path::new("/ws")).unwrap()),
[
("a".into(), true, false),
("a-b".into(), true, false),
("ab".into(), false, true),
("z".into(), true, false)
]
);
assert_eq!(
names(&fs.read_dir(Path::new("/ws/a")).unwrap()),
[("package.json".into(), false, true), ("src".into(), true, false)]
);
}
#[test]
fn memory_missing_and_file_paths_are_not_directories() {
let fs = tree();
assert!(fs.read_dir(Path::new("/ws/missing")).is_err());
assert!(fs.read_dir(Path::new("/ws/ab")).is_err());
assert!(fs.is_dir(Path::new("/ws/a")) && fs.is_dir(Path::new("/ws/a/src")) && fs.is_dir(Path::new("/ws")));
assert!(!fs.is_dir(Path::new("/ws/ab")) && !fs.is_dir(Path::new("/ws/a/sr")) && !fs.is_dir(Path::new("/x")));
assert!(fs.is_file(Path::new("/ws/ab")) && !fs.is_file(Path::new("/ws/a")));
}
#[test]
fn memory_reads_share_one_allocation() {
let fs = tree();
let first = fs.read_to_string(Path::new("/ws/ab")).unwrap();
let second = fs.read_to_string(Path::new("/ws/ab")).unwrap();
assert_eq!(&*first, "file");
assert!(Arc::ptr_eq(&first, &second));
assert_eq!(
fs.read_to_string(Path::new("/nope")).unwrap_err().kind(),
io::ErrorKind::NotFound
);
}
#[test]
fn a_prefix_read_stops_at_the_limit_or_the_end_of_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("yarn.lock");
std::fs::write(&path, "0123456789").unwrap();
let memory = MemoryFs::new().with_file("/ws/yarn.lock", "0123456789");
let memory_path = Path::new("/ws/yarn.lock");
for (limit, expected) in [(0, ""), (4, "0123"), (10, "0123456789"), (64, "0123456789")] {
assert_eq!(OsFs.read_prefix(&path, limit).unwrap(), expected.as_bytes(), "{limit}");
assert_eq!(
memory.read_prefix(memory_path, limit).unwrap(),
expected.as_bytes(),
"{limit}"
);
}
assert_eq!(
OsFs.read_prefix(&dir.path().join("missing"), 4).unwrap_err().kind(),
io::ErrorKind::NotFound
);
assert!(memory.read_prefix(Path::new("/ws/missing"), 4).is_err());
}
#[test]
fn os_listing_reports_kinds_sorted() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("b.txt"), "hello").unwrap();
std::fs::write(dir.path().join("a.txt"), "").unwrap();
let entries = OsFs.read_dir(dir.path()).unwrap();
assert_eq!(
names(&entries),
[
("a.txt".into(), false, true),
("b.txt".into(), false, true),
("sub".into(), true, false)
]
);
assert_eq!(&*OsFs.read_to_string(&dir.path().join("b.txt")).unwrap(), "hello");
assert!(OsFs.is_dir(dir.path()) && OsFs.is_file(&dir.path().join("a.txt")));
}
#[test]
fn os_reads_fail_on_invalid_utf8_as_the_standard_library_does() {
let dir = tempfile::tempdir().unwrap();
let text = "r\u{e9}act \u{65e5}\u{672c} \u{1f600}\n";
std::fs::write(dir.path().join("valid.txt"), text).unwrap();
assert_eq!(&*OsFs.read_to_string(&dir.path().join("valid.txt")).unwrap(), text);
for bytes in [&b"ok \xff"[..], b"\xc3", b"a\xed\xa0\x80b"] {
let path = dir.path().join("invalid.txt");
std::fs::write(&path, bytes).unwrap();
let ours = OsFs.read_to_string(&path).unwrap_err();
let expected = std::fs::read_to_string(&path).unwrap_err();
assert_eq!(ours.kind(), io::ErrorKind::InvalidData, "{bytes:?}");
assert_eq!(
(ours.kind(), ours.to_string()),
(expected.kind(), expected.to_string()),
"{bytes:?}"
);
}
assert_eq!(
OsFs.read_to_string(&dir.path().join("missing.txt")).unwrap_err().kind(),
io::ErrorKind::NotFound
);
}
#[cfg(unix)]
#[test]
fn os_listing_marks_symlinks() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("real")).unwrap();
std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("link")).unwrap();
let flags: Vec<(String, bool)> = OsFs
.read_dir(dir.path())
.unwrap()
.iter()
.map(|e| (e.path.file_name().unwrap().to_string_lossy().into_owned(), e.is_symlink))
.collect();
assert_eq!(flags, [("link".to_owned(), true), ("real".to_owned(), false)]);
}
#[test]
fn memory_entries_are_not_symlinks() {
let entries = tree().read_dir(Path::new("/ws")).unwrap();
assert!(entries.iter().all(|entry| !entry.is_symlink));
}
#[cfg(unix)]
#[test]
fn os_listing_follows_symlinks() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("real")).unwrap();
std::fs::write(dir.path().join("file.txt"), "").unwrap();
std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("dir-link")).unwrap();
std::os::unix::fs::symlink(dir.path().join("file.txt"), dir.path().join("file-link")).unwrap();
std::os::unix::fs::symlink(dir.path().join("gone"), dir.path().join("dangling")).unwrap();
let entries = OsFs.read_dir(dir.path()).unwrap();
assert_eq!(
names(&entries),
[
("dangling".into(), false, false),
("dir-link".into(), true, false),
("file-link".into(), false, true),
("file.txt".into(), false, true),
("real".into(), true, false)
]
);
}
#[test]
fn pointers_to_a_file_system_are_file_systems() {
fn is_file(fs: impl FileSystem, path: &str) -> bool {
fs.is_file(Path::new(path))
}
let fs = tree();
assert!(is_file(&fs, "/ws/ab"));
assert!(is_file(Box::new(fs.clone()), "/ws/ab"));
assert!(is_file(Arc::new(fs.clone()), "/ws/ab"));
let dynamic: Arc<dyn FileSystem> = Arc::new(fs);
assert!(is_file(Arc::clone(&dynamic), "/ws/ab"));
assert!(!is_file(dynamic, "/ws/a"));
}
#[test]
fn bytes_default_to_the_text_and_modification_time_to_unknown() {
let fs = tree();
assert_eq!(fs.read_bytes(Path::new("/ws/ab")).unwrap(), b"file");
assert_eq!(fs.modified(Path::new("/ws/ab")), None);
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.bin"), [0xff, 0xfe]).unwrap();
assert_eq!(OsFs.read_bytes(&dir.path().join("a.bin")).unwrap(), [0xff, 0xfe]);
assert!(OsFs.modified(&dir.path().join("a.bin")).is_some());
assert!(OsFs.modified(&dir.path().join("missing")).is_none());
}
}