use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use std::sync::Mutex;
use termesh_filesystem::{
sort_entries, DirEntryInfo, EntryKind, FileSystemService, FsError, FsResult,
};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Node {
Dir,
File(Vec<u8>),
Symlink(PathBuf),
}
#[derive(Debug, Default)]
pub struct FakeFileSystem {
inner: Mutex<Inner>,
}
#[derive(Debug, Default)]
struct Inner {
nodes: BTreeMap<PathBuf, Node>,
failures: BTreeMap<PathBuf, FsError>,
}
impl FakeFileSystem {
pub fn new() -> Self {
let mut inner = Inner::default();
inner.nodes.insert(PathBuf::from("/"), Node::Dir);
Self { inner: Mutex::new(inner) }
}
pub fn with_paths(paths: &[&str]) -> Self {
let fs = Self::new();
for p in paths {
if let Some(dir) = p.strip_suffix('/') {
fs.add_dir(dir);
} else {
fs.add_file(p, b"");
}
}
fs
}
pub fn add_dir(&self, path: impl AsRef<Path>) -> &Self {
let path = normalize(path.as_ref());
let mut inner = self.inner.lock().unwrap();
insert_ancestors(&mut inner.nodes, &path);
inner.nodes.insert(path, Node::Dir);
drop(inner);
self
}
pub fn add_file(&self, path: impl AsRef<Path>, contents: &[u8]) -> &Self {
let path = normalize(path.as_ref());
let mut inner = self.inner.lock().unwrap();
insert_ancestors(&mut inner.nodes, &path);
inner.nodes.insert(path, Node::File(contents.to_vec()));
drop(inner);
self
}
pub fn add_symlink(&self, path: impl AsRef<Path>, target: impl AsRef<Path>) -> &Self {
let path = normalize(path.as_ref());
let mut inner = self.inner.lock().unwrap();
insert_ancestors(&mut inner.nodes, &path);
inner.nodes.insert(path, Node::Symlink(target.as_ref().to_path_buf()));
drop(inner);
self
}
pub fn fail(&self, path: impl AsRef<Path>, error: FsError) -> &Self {
let path = normalize(path.as_ref());
self.inner.lock().unwrap().failures.insert(path, error);
self
}
pub fn paths(&self) -> Vec<PathBuf> {
self.inner.lock().unwrap().nodes.keys().cloned().collect()
}
}
impl Inner {
fn check_failure(&self, path: &Path) -> FsResult<()> {
match self.failures.get(path) {
Some(e) => Err(e.clone()),
None => Ok(()),
}
}
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in path.components() {
match c {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
if out.as_os_str().is_empty() {
PathBuf::from("/")
} else {
out
}
}
fn insert_ancestors(nodes: &mut BTreeMap<PathBuf, Node>, path: &Path) {
let mut cur = PathBuf::new();
for c in path.components() {
cur.push(c.as_os_str());
if cur != path {
nodes.entry(cur.clone()).or_insert(Node::Dir);
}
}
}
fn kind_of(node: &Node) -> EntryKind {
match node {
Node::Dir => EntryKind::Dir,
Node::File(_) => EntryKind::File,
Node::Symlink(_) => EntryKind::Symlink,
}
}
impl FileSystemService for FakeFileSystem {
fn read_dir(&self, path: &Path) -> FsResult<Vec<DirEntryInfo>> {
let path = normalize(path);
let inner = self.inner.lock().unwrap();
inner.check_failure(&path)?;
match inner.nodes.get(&path) {
None => return Err(FsError::NotFound(path)),
Some(Node::Dir) => {}
Some(_) => return Err(FsError::NotADirectory(path)),
}
let mut out: Vec<DirEntryInfo> = inner
.nodes
.iter()
.filter(|(p, _)| p.parent() == Some(path.as_path()))
.map(|(p, node)| DirEntryInfo {
name: p.file_name().unwrap_or_default().to_os_string(),
path: p.clone(),
kind: kind_of(node),
})
.collect();
sort_entries(&mut out);
Ok(out)
}
fn read_file(&self, path: &Path) -> FsResult<Vec<u8>> {
let path = normalize(path);
let inner = self.inner.lock().unwrap();
inner.check_failure(&path)?;
match inner.nodes.get(&path) {
Some(Node::File(bytes)) => Ok(bytes.clone()),
Some(_) => Err(FsError::Other { path, message: "not a regular file".to_string() }),
None => Err(FsError::NotFound(path)),
}
}
fn create_file(&self, path: &Path) -> FsResult<()> {
let path = normalize(path);
let mut inner = self.inner.lock().unwrap();
inner.check_failure(&path)?;
if inner.nodes.contains_key(&path) {
return Err(FsError::AlreadyExists(path));
}
insert_ancestors(&mut inner.nodes, &path);
inner.nodes.insert(path, Node::File(Vec::new()));
Ok(())
}
fn write_file(&self, path: &Path, contents: &[u8]) -> FsResult<()> {
let path = normalize(path);
let mut inner = self.inner.lock().unwrap();
inner.check_failure(&path)?;
insert_ancestors(&mut inner.nodes, &path);
inner.nodes.insert(path, Node::File(contents.to_vec()));
Ok(())
}
fn create_dir(&self, path: &Path) -> FsResult<()> {
let path = normalize(path);
let mut inner = self.inner.lock().unwrap();
inner.check_failure(&path)?;
insert_ancestors(&mut inner.nodes, &path);
inner.nodes.entry(path).or_insert(Node::Dir);
Ok(())
}
fn rename(&self, from: &Path, to: &Path) -> FsResult<()> {
let (from, to) = (normalize(from), normalize(to));
let mut inner = self.inner.lock().unwrap();
inner.check_failure(&from)?;
if !inner.nodes.contains_key(&from) {
return Err(FsError::NotFound(from));
}
if inner.nodes.contains_key(&to) {
return Err(FsError::AlreadyExists(to));
}
let moving: Vec<PathBuf> =
inner.nodes.keys().filter(|p| p.starts_with(&from)).cloned().collect();
for old in moving {
let node = inner.nodes.remove(&old).expect("key came from this map");
let suffix = old.strip_prefix(&from).expect("filtered by starts_with");
inner.nodes.insert(to.join(suffix), node);
}
insert_ancestors(&mut inner.nodes, &to);
Ok(())
}
fn remove_file(&self, path: &Path) -> FsResult<()> {
let path = normalize(path);
let mut inner = self.inner.lock().unwrap();
inner.check_failure(&path)?;
match inner.nodes.get(&path) {
Some(Node::Dir) => Err(FsError::Other { path, message: "is a directory".to_string() }),
Some(_) => {
inner.nodes.remove(&path);
Ok(())
}
None => Err(FsError::NotFound(path)),
}
}
fn remove_dir_all(&self, path: &Path) -> FsResult<()> {
let path = normalize(path);
let mut inner = self.inner.lock().unwrap();
inner.check_failure(&path)?;
if !inner.nodes.contains_key(&path) {
return Err(FsError::NotFound(path));
}
inner.nodes.retain(|p, _| !p.starts_with(&path));
Ok(())
}
fn canonicalize(&self, path: &Path) -> FsResult<PathBuf> {
let path = normalize(path);
let inner = self.inner.lock().unwrap();
inner.check_failure(&path)?;
if inner.nodes.contains_key(&path) {
Ok(path)
} else {
Err(FsError::NotFound(path))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> FakeFileSystem {
FakeFileSystem::with_paths(&[
"/proj/src/main.rs",
"/proj/src/model.rs",
"/proj/Cargo.toml",
"/proj/target/debug/",
])
}
fn names(fs: &FakeFileSystem, at: &str) -> Vec<String> {
fs.read_dir(Path::new(at))
.unwrap()
.iter()
.map(|e| e.name.to_string_lossy().into_owned())
.collect()
}
#[test]
fn read_dir_returns_direct_children_only_dirs_first() {
assert_eq!(names(&sample(), "/proj"), ["src", "target", "Cargo.toml"]);
}
#[test]
fn read_dir_does_not_recurse() {
assert!(!names(&sample(), "/proj").contains(&"main.rs".to_string()));
assert_eq!(names(&sample(), "/proj/src"), ["main.rs", "model.rs"]);
}
#[test]
fn ordering_matches_the_real_service_contract() {
let fs = FakeFileSystem::with_paths(&["/r/README.md", "/r/assets/", "/r/Cargo.toml"]);
assert_eq!(names(&fs, "/r"), ["assets", "Cargo.toml", "README.md"]);
}
#[test]
fn injected_failures_surface_on_read() {
let fs = sample();
let denied = PathBuf::from("/proj/src");
fs.fail(&denied, FsError::PermissionDenied(denied.clone()));
assert_eq!(fs.read_dir(&denied), Err(FsError::PermissionDenied(denied)));
assert!(fs.read_dir(Path::new("/proj")).is_ok());
}
#[test]
fn missing_and_wrong_kind_are_distinguishable() {
let fs = sample();
assert_eq!(
fs.read_dir(Path::new("/proj/nope")),
Err(FsError::NotFound(PathBuf::from("/proj/nope")))
);
assert_eq!(
fs.read_dir(Path::new("/proj/Cargo.toml")),
Err(FsError::NotADirectory(PathBuf::from("/proj/Cargo.toml")))
);
}
#[test]
fn create_file_refuses_to_clobber() {
let fs = sample();
fs.add_file("/proj/keep.txt", b"precious");
assert_eq!(
fs.create_file(Path::new("/proj/keep.txt")),
Err(FsError::AlreadyExists(PathBuf::from("/proj/keep.txt")))
);
assert_eq!(fs.read_file(Path::new("/proj/keep.txt")).unwrap(), b"precious");
}
#[test]
fn rename_moves_the_whole_subtree() {
let fs = sample();
fs.rename(Path::new("/proj/src"), Path::new("/proj/lib")).unwrap();
assert_eq!(names(&fs, "/proj/lib"), ["main.rs", "model.rs"]);
assert_eq!(
fs.read_dir(Path::new("/proj/src")),
Err(FsError::NotFound(PathBuf::from("/proj/src")))
);
}
#[test]
fn remove_dir_all_takes_descendants_but_not_siblings() {
let fs = sample();
fs.remove_dir_all(Path::new("/proj/src")).unwrap();
assert!(!fs.paths().iter().any(|p| p.starts_with("/proj/src")));
assert!(fs.paths().contains(&PathBuf::from("/proj/Cargo.toml")));
}
#[test]
fn remove_file_refuses_directories() {
let fs = sample();
assert!(fs.remove_file(Path::new("/proj/src")).is_err());
assert!(fs.read_dir(Path::new("/proj/src")).is_ok(), "directory survives");
}
#[test]
fn symlinks_are_listed_but_typed_as_symlinks() {
let fs = sample();
fs.add_symlink("/proj/link", "/proj/src");
let entries = fs.read_dir(Path::new("/proj")).unwrap();
let link = entries.iter().find(|e| e.name == "link").unwrap();
assert_eq!(link.kind, EntryKind::Symlink);
}
#[test]
fn paths_are_normalized_before_lookup() {
let fs = sample();
assert_eq!(names(&fs, "/proj/src/../src"), ["main.rs", "model.rs"]);
assert_eq!(names(&fs, "/proj/./src"), ["main.rs", "model.rs"]);
}
}