use std::io::{self, Write};
use std::path::{Path, PathBuf};
use cap_std::ambient_authority;
use cap_std::fs::Dir;
pub struct DataDir {
dir: Dir,
base: PathBuf,
}
impl DataDir {
pub fn open(base: impl AsRef<Path>) -> io::Result<Self> {
let base = base.as_ref();
let dir = Dir::open_ambient_dir(base, ambient_authority())?;
Ok(Self {
dir,
base: base.to_path_buf(),
})
}
pub fn open_or_create(base: impl AsRef<Path>) -> io::Result<Self> {
let base = base.as_ref();
std::fs::create_dir_all(base)?;
Self::open(base)
}
#[must_use]
pub fn resolve(&self, rel: &str) -> PathBuf {
self.base.join(rel)
}
pub fn read_to_string(&self, rel: &str) -> io::Result<String> {
self.dir.read_to_string(rel)
}
pub fn read(&self, rel: &str) -> io::Result<Vec<u8>> {
self.dir.read(rel)
}
pub fn create_dir_all(&self, rel: &str) -> io::Result<()> {
self.dir.create_dir_all(rel)
}
#[must_use]
pub fn exists(&self, rel: &str) -> bool {
self.dir.exists(rel)
}
pub fn file_len(&self, rel: &str) -> io::Result<u64> {
Ok(self.dir.metadata(rel)?.len())
}
pub fn subdir(&self, rel: &str) -> io::Result<Self> {
let dir = self.dir.open_dir(rel)?;
Ok(Self {
dir,
base: self.base.join(rel),
})
}
pub fn write(&self, rel: &str, bytes: &[u8]) -> io::Result<()> {
let mut file = self.dir.create(rel)?;
file.write_all(bytes)
}
pub fn write_atomic(&self, rel: &str, bytes: &[u8]) -> io::Result<()> {
let tmp = format!("{rel}.tmp");
{
let mut file = self.dir.create(&tmp)?;
file.write_all(bytes)?;
file.sync_all()?;
}
self.dir.rename(&tmp, &self.dir, rel)
}
pub fn remove_file(&self, rel: &str) -> io::Result<()> {
self.dir.remove_file(rel)
}
pub fn walk_files(&self) -> io::Result<Vec<String>> {
let mut out = Vec::new();
walk_dir(&self.dir, "", &mut out)?;
Ok(out)
}
}
fn walk_dir(dir: &Dir, prefix: &str, out: &mut Vec<String>) -> io::Result<()> {
for entry in dir.entries()? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
let rel = if prefix.is_empty() {
name.to_string()
} else {
format!("{prefix}/{name}")
};
let file_type = entry.file_type()?;
if file_type.is_dir() {
let sub = entry.open_dir()?;
walk_dir(&sub, &rel, out)?;
} else if file_type.is_file() {
out.push(rel);
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn datadir() -> (tempfile::TempDir, DataDir) {
let tmp = tempfile::tempdir().expect("tmpdir");
let dd = DataDir::open(tmp.path()).expect("open base");
(tmp, dd)
}
#[test]
fn write_then_read_roundtrip() {
let (_t, dd) = datadir();
dd.create_dir_all("2026/001").unwrap();
dd.write("2026/001/a.json", b"hello").unwrap();
assert_eq!(dd.read_to_string("2026/001/a.json").unwrap(), "hello");
assert_eq!(dd.read("2026/001/a.json").unwrap(), b"hello");
assert!(dd.exists("2026/001/a.json"));
assert_eq!(dd.file_len("2026/001/a.json").unwrap(), 5);
}
#[test]
fn write_atomic_replaces_and_leaves_no_temp() {
let (_t, dd) = datadir();
dd.write_atomic("x.json", b"one").unwrap();
dd.write_atomic("x.json", b"two").unwrap();
assert_eq!(dd.read_to_string("x.json").unwrap(), "two");
assert!(!dd.exists("x.json.tmp"));
}
#[test]
fn traversal_is_refused() {
let (_t, dd) = datadir();
assert!(dd.write("../escape.json", b"x").is_err());
assert!(dd.read_to_string("../../etc/passwd").is_err());
assert!(dd.create_dir_all("../sneaky").is_err());
assert!(dd.read("/etc/passwd").is_err());
}
#[test]
fn subdir_scopes_further() {
let (_t, dd) = datadir();
dd.create_dir_all("sub").unwrap();
let sub = dd.subdir("sub").unwrap();
sub.write("f.json", b"v").unwrap();
assert!(dd.exists("sub/f.json"));
assert!(sub.write("../escape", b"x").is_err());
}
#[test]
fn walk_files_lists_nested_regular_files() {
let (_t, dd) = datadir();
dd.create_dir_all("2026/001").unwrap();
dd.write("2026/001/a.json", b"a").unwrap();
dd.write("top.json", b"t").unwrap();
let mut files = dd.walk_files().unwrap();
files.sort();
assert_eq!(
files,
vec!["2026/001/a.json".to_string(), "top.json".to_string(),]
);
}
#[test]
fn remove_file_works_within_base() {
let (_t, dd) = datadir();
dd.write("gone.json", b"x").unwrap();
assert!(dd.exists("gone.json"));
dd.remove_file("gone.json").unwrap();
assert!(!dd.exists("gone.json"));
}
}