use std::future::Future;
use std::io;
use std::path::{Path, PathBuf};
use prov::fs::{DirEntry, Metadata};
use prov::{
Capabilities, ChangeSet, Discovery, Error, InMemoryFs, InMemoryIndex, Minter, RelationSet,
StdFs, Storage, Workspace, block_on,
};
fn tmp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("prov-pubapi-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_workspace_can_be_built_and_traversed_through_the_public_api() {
let root = tmp("drive");
std::fs::write(root.join("index.md"), "---\ntitle: Home\n---\n# Home\n").unwrap();
std::fs::write(
root.join("child.md"),
"---\ntitle: Child\npart_of: '[Home](/index.md)'\n---\n",
)
.unwrap();
std::fs::write(
root.join("index.md"),
"---\ntitle: Home\ncontents:\n- '[Child](/child.md)'\n---\n# Home\n",
)
.unwrap();
let ws = Workspace::builder(StdFs)
.root(&root)
.relations(RelationSet::diaryx())
.build();
let node = block_on(ws.tree("index.md")).expect("tree");
assert_eq!(node.children.len(), 1, "root reaches its one child");
let findings = block_on(ws.check("index.md")).expect("check");
assert!(
findings.is_empty(),
"consistent workspace has no findings: {findings:?}"
);
}
#[test]
fn a_change_set_lands_through_the_public_api() {
let root = tmp("changeset");
let mut cs = ChangeSet::new();
cs.write("a.md", "---\ntitle: A\n---\n");
cs.write("sub/b.md", "---\ntitle: B\n---\n");
block_on(cs.apply(&StdFs, &root)).expect("apply");
assert!(root.join("a.md").exists());
assert!(root.join("sub/b.md").exists());
}
#[test]
fn discovery_locates_a_root_through_the_public_api() {
let root = tmp("discover");
std::fs::write(root.join("index.md"), "---\ntitle: Home\n---\n").unwrap();
std::fs::create_dir_all(root.join("deep")).unwrap();
match block_on(prov::discover(&StdFs, &root.join("deep"))).expect("discover") {
Discovery::Found(d) => assert_eq!(d.root_doc, Path::new("index.md")),
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn a_path_escaping_the_root_is_refused_by_apply() {
let root = tmp("escape");
let mut cs = ChangeSet::new();
cs.write("../escapee.md", "should never be written");
let err = block_on(cs.apply(&StdFs, &root)).unwrap_err();
assert!(
matches!(err, Error::Escape(_)),
"expected Escape, got {err:?}"
);
assert!(!root.parent().unwrap().join("escapee.md").exists());
}
#[test]
fn in_memory_fs_drives_a_workspace_through_the_public_api() {
let fs = InMemoryFs::new();
block_on(fs.write(Path::new("index.md"), b"---\ntitle: Home\n---\n# Home\n")).unwrap();
let ws = Workspace::builder(fs)
.root(Path::new(""))
.relations(RelationSet::diaryx())
.build();
let node = block_on(ws.tree("index.md")).expect("tree");
assert_eq!(node.title.as_deref(), Some("Home"));
}
#[allow(clippy::needless_borrow)]
#[test]
fn a_borrowed_storage_backend_is_itself_storage() {
let fs = InMemoryFs::new();
block_on((&fs).write(Path::new("doc.md"), b"hello")).unwrap();
assert_eq!(
block_on((&fs).read_to_string(Path::new("doc.md"))).unwrap(),
"hello"
);
assert_eq!((&fs).capabilities(), Capabilities::IN_MEMORY);
}
#[test]
fn an_arc_wrapped_storage_backend_is_itself_storage() {
let fs = std::sync::Arc::new(InMemoryFs::new());
block_on(fs.write(Path::new("doc.md"), b"hello")).unwrap();
assert_eq!(
block_on(fs.read_to_string(Path::new("doc.md"))).unwrap(),
"hello"
);
assert_eq!(fs.capabilities(), Capabilities::IN_MEMORY);
}
#[test]
fn a_borrowed_backend_can_build_a_workspace_and_is_still_usable_afterward() {
let fs = InMemoryFs::new();
block_on(fs.write(Path::new("index.md"), b"---\ntitle: Home\n---\n# Home\n")).unwrap();
let ws = Workspace::builder(&fs)
.root(Path::new(""))
.relations(RelationSet::diaryx())
.build();
let node = block_on(ws.tree("index.md")).expect("tree");
assert_eq!(node.title.as_deref(), Some("Home"));
assert!(block_on(fs.try_exists(Path::new("index.md"))).unwrap());
}
fn assert_send<T: Send>() {}
fn require_send_future<F: Future + Send>(_: F) {}
#[test]
fn public_types_are_send() {
assert_send::<Workspace<StdFs>>();
assert_send::<Workspace<StdFs, Minter, InMemoryIndex>>();
assert_send::<ChangeSet>();
assert_send::<Error>();
assert_send::<prov::Discovered>();
assert_send::<prov::tree::Node>();
}
#[allow(dead_code)]
async fn futures_stay_send(fs: &StdFs, cs: ChangeSet) {
require_send_future(cs.apply(fs, Path::new(".")));
require_send_future(prov::discover(fs, Path::new(".")));
}
#[derive(Clone)]
struct DoubleFault {
restore_victim: PathBuf,
boom_fragment: String,
}
impl DoubleFault {
fn should_fail(&self, path: &Path) -> bool {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let is_restore =
path.file_name() == self.restore_victim.file_name() && !name.contains("prov-tmp");
let is_boom = name.contains(&self.boom_fragment);
is_restore || is_boom
}
}
impl Storage for DoubleFault {
async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
StdFs.read(path).await
}
async fn read_to_string(&self, path: &Path) -> io::Result<String> {
StdFs.read_to_string(path).await
}
async fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
StdFs.read_dir(path).await
}
async fn write(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
if self.should_fail(path) {
return Err(io::Error::other("double fault (test)"));
}
StdFs.write(path, contents).await
}
async fn create_dir_all(&self, path: &Path) -> io::Result<()> {
StdFs.create_dir_all(path).await
}
async fn remove_file(&self, path: &Path) -> io::Result<()> {
StdFs.remove_file(path).await
}
async fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
StdFs.remove_dir_all(path).await
}
async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
StdFs.rename(from, to).await
}
async fn metadata(&self, path: &Path) -> io::Result<Metadata> {
StdFs.metadata(path).await
}
fn capabilities(&self) -> Capabilities {
Capabilities::LOCAL_FS
}
async fn sync(&self, path: &Path) -> io::Result<()> {
StdFs.sync(path).await
}
}
#[test]
fn a_rollback_that_itself_fails_surfaces_as_torn() {
let root = tmp("torn");
std::fs::write(root.join("victim.md"), "original").unwrap();
let fs = DoubleFault {
restore_victim: PathBuf::from("victim.md"),
boom_fragment: "boom".into(),
};
let mut cs = ChangeSet::new();
cs.write("victim.md", "rewritten"); cs.write("boom.md", "never lands");
let err = block_on(cs.apply(&fs, &root)).unwrap_err();
match err {
Error::Torn { cause, rollback } => {
assert!(
cause.contains("double fault"),
"cause names the write fault: {cause}"
);
assert!(
rollback.contains("double fault"),
"rollback names its own fault: {rollback}"
);
}
other => panic!("expected Torn, got {other:?}"),
}
assert!(
root.join(".prov-journal").exists(),
"a torn apply leaves its journal for recovery"
);
}