use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
pub trait ProjectFiles {
fn read(&self, path: &str) -> Result<Vec<u8>>;
fn list(&self, prefix: &str) -> Vec<String>;
fn exists(&self, path: &str) -> bool {
self.read(path).is_ok()
}
fn root(&self) -> Option<&Path> {
None
}
}
pub struct DiskFiles {
root: PathBuf,
}
impl DiskFiles {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
}
impl ProjectFiles for DiskFiles {
fn read(&self, path: &str) -> Result<Vec<u8>> {
let full = self.root.join(path);
std::fs::read(&full).with_context(|| format!("failed to read {}", full.display()))
}
fn list(&self, prefix: &str) -> Vec<String> {
let base = if prefix.is_empty() {
self.root.clone()
} else {
self.root.join(prefix)
};
let mut out = Vec::new();
if base.is_dir() {
walk(&base, &self.root, &mut out);
}
out.sort();
out
}
fn root(&self) -> Option<&Path> {
Some(&self.root)
}
}
pub struct MemoryFiles {
map: HashMap<String, Vec<u8>>,
}
impl MemoryFiles {
pub fn new(map: HashMap<String, Vec<u8>>) -> Self {
Self { map }
}
}
impl From<HashMap<String, Vec<u8>>> for MemoryFiles {
fn from(map: HashMap<String, Vec<u8>>) -> Self {
Self::new(map)
}
}
impl ProjectFiles for MemoryFiles {
fn read(&self, path: &str) -> Result<Vec<u8>> {
self.map
.get(path)
.cloned()
.with_context(|| format!("no such file '{path}'"))
}
fn list(&self, prefix: &str) -> Vec<String> {
let mut out: Vec<String> = self
.map
.keys()
.filter(|k| {
prefix.is_empty()
|| k.as_str() == prefix
|| k.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/'))
})
.cloned()
.collect();
out.sort();
out
}
}
pub fn join_path(base: &str, rel: &str) -> String {
let rel = rel.strip_prefix("./").unwrap_or(rel);
let rel = rel.strip_suffix('/').unwrap_or(rel);
if base.is_empty() {
rel.to_string()
} else if rel.is_empty() {
base.to_string()
} else {
format!("{base}/{rel}")
}
}
fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, root, out);
} else if path.is_file() {
if let Ok(rel) = path.strip_prefix(root) {
let posix = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
out.push(posix);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mem() -> MemoryFiles {
MemoryFiles::new(HashMap::from([
("data/maps/Town/map.tmx.json".to_string(), b"{}".to_vec()),
("data/maps/Town/objects.json".to_string(), b"{}".to_vec()),
("data/maps/Field/map.tmx.json".to_string(), b"{}".to_vec()),
("data/rules.ron".to_string(), b"()".to_vec()),
(".dotzuki-editor.json".to_string(), b"{}".to_vec()),
]))
}
#[test]
fn memory_list_is_recursive_sorted_and_prefix_bounded() {
let files = mem();
assert_eq!(
files.list("data/maps"),
vec![
"data/maps/Field/map.tmx.json".to_string(),
"data/maps/Town/map.tmx.json".to_string(),
"data/maps/Town/objects.json".to_string(),
]
);
assert_eq!(files.list("data/mapss"), Vec::<String>::new());
assert_eq!(files.list("").len(), 5);
}
#[test]
fn memory_read_and_exists() {
let files = mem();
assert_eq!(files.read("data/rules.ron").unwrap(), b"()".to_vec());
assert!(files.read("nope.json").is_err());
assert!(files.exists("data/rules.ron"));
assert!(!files.exists("nope.json"));
}
#[test]
fn join_path_normalizes() {
assert_eq!(join_path("", "data"), "data");
assert_eq!(join_path("", "./data"), "data");
assert_eq!(join_path("data", "maps"), "data/maps");
assert_eq!(join_path("data", "./maps"), "data/maps");
assert_eq!(join_path("data", ""), "data");
}
}