use std::path::{Path, PathBuf};
use proptest::prelude::*;
use crate::validate::{CheckDiff, Finding};
use crate::workspace::Workspace;
use prov_graph::exec::block_on;
use prov_graph::relation::RelationSet;
use prov_store::fs::InMemoryFs;
const ROOT: &str = "index.md";
const NAMES: [&str; 3] = ["p", "q", "r"];
const DIRS: [&str; 3] = ["", "n", "d"];
const TITLES: [&str; 2] = ["Renamed", "Retitled"];
#[derive(Debug, Clone)]
enum Op {
Create {
parent: usize,
name: usize,
dir: usize,
},
Rename {
subject: usize,
name: usize,
dir: usize,
},
Reparent {
child: usize,
parent: usize,
},
Adopt {
child: usize,
parent: usize,
},
Duplicate {
subject: usize,
},
Retitle {
subject: usize,
title: usize,
},
Separate {
subject: usize,
},
Combine {
subject: usize,
},
Delete {
subject: usize,
},
}
fn op() -> impl Strategy<Value = Op> {
let ix = 0..6usize;
prop_oneof![
(ix.clone(), 0..NAMES.len(), 0..DIRS.len()).prop_map(|(parent, name, dir)| Op::Create {
parent,
name,
dir
}),
(ix.clone(), 0..NAMES.len(), 0..DIRS.len()).prop_map(|(subject, name, dir)| Op::Rename {
subject,
name,
dir
}),
(ix.clone(), ix.clone()).prop_map(|(child, parent)| Op::Reparent { child, parent }),
(ix.clone(), ix.clone()).prop_map(|(child, parent)| Op::Adopt { child, parent }),
ix.clone().prop_map(|subject| Op::Duplicate { subject }),
(ix.clone(), 0..TITLES.len()).prop_map(|(subject, title)| Op::Retitle { subject, title }),
ix.clone().prop_map(|subject| Op::Separate { subject }),
ix.clone().prop_map(|subject| Op::Combine { subject }),
ix.prop_map(|subject| Op::Delete { subject }),
]
}
fn seeded() -> InMemoryFs {
InMemoryFs::with_files(
[
(
ROOT,
"---\ntitle: Home\ncontents:\n- '[A](/a.md)'\n- '[B](/b.md)'\n---\n# Home\n",
),
(
"a.md",
"---\ntitle: A\npart_of: '[Home](/index.md)'\ncontents:\n- '[C](/n/c.md)'\n---\nA body.\n",
),
(
"n/c.md",
"---\ntitle: C\npart_of: '[A](/a.md)'\n---\nC body.\n",
),
(
"b.md",
"---\ntitle: B\npart_of: '[Home](/index.md)'\n---\nB body.\n",
),
]
.into_iter()
.map(|(p, t)| (PathBuf::from(p), t.to_string()))
.collect(),
)
}
fn cases() -> u32 {
std::env::var("PROPTEST_CASES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(64)
}
fn build(fs: &InMemoryFs) -> Workspace<&InMemoryFs> {
Workspace::builder(fs)
.root(Path::new(""))
.relations(RelationSet::diaryx())
.build()
}
fn nodes(ws: &Workspace<&InMemoryFs>) -> Vec<PathBuf> {
fn walk(node: &prov_graph::graph::Node, out: &mut Vec<PathBuf>) {
out.push(node.path.clone());
for child in &node.children {
walk(child, out);
}
}
let mut out = Vec::new();
if let Ok(root) = block_on(ws.tree(ROOT)) {
walk(&root, &mut out);
}
out.sort();
out.dedup();
out
}
fn snapshot(fs: &InMemoryFs) -> Vec<(String, String)> {
let mut entries = fs.export_entries();
entries.sort();
entries
}
fn pick(ix: usize, candidates: &[PathBuf]) -> Option<PathBuf> {
if candidates.is_empty() {
return None;
}
Some(candidates[ix % candidates.len()].clone())
}
fn apply(ws: &mut Workspace<&InMemoryFs>, op: &Op) -> Option<crate::Result<Vec<Finding>>> {
let all = nodes(ws);
let subjects: Vec<PathBuf> = all
.iter()
.filter(|p| p.as_path() != Path::new(ROOT))
.cloned()
.collect();
let authored = |name: usize, dir: usize| -> PathBuf {
Path::new(DIRS[dir]).join(format!("{}.md", NAMES[name]))
};
Some(match op {
Op::Create { parent, name, dir } => {
let parent = pick(*parent, &all)?;
block_on(ws.create(&authored(*name, *dir), &parent)).map(|_| Vec::new())
}
Op::Rename { subject, name, dir } => {
let subject = pick(*subject, &subjects)?;
block_on(ws.rename(&subject, &authored(*name, *dir))).map(|_| Vec::new())
}
Op::Reparent { child, parent } => {
let (child, parent) = (pick(*child, &subjects)?, pick(*parent, &all)?);
block_on(ws.reparent(&child, &parent)).map(|_| Vec::new())
}
Op::Adopt { child, parent } => {
let (child, parent) = (pick(*child, &subjects)?, pick(*parent, &all)?);
block_on(ws.adopt(&child, &parent)).map(|_| Vec::new())
}
Op::Duplicate { subject } => {
let subject = pick(*subject, &subjects)?;
block_on(ws.duplicate(&subject)).map(|_| Vec::new())
}
Op::Retitle { subject, title } => {
let subject = pick(*subject, &subjects)?;
block_on(ws.retitle(&subject, TITLES[*title])).map(|_| Vec::new())
}
Op::Separate { subject } => {
let subject = pick(*subject, &subjects)?;
block_on(ws.separate(&subject)).map(|_| Vec::new())
}
Op::Combine { subject } => {
let subject = pick(*subject, &subjects)?;
block_on(ws.combine(&subject)).map(|_| Vec::new())
}
Op::Delete { subject } => {
let subject = pick(*subject, &subjects)?;
block_on(ws.delete(&subject, false))
}
})
}
proptest! {
#![proptest_config(ProptestConfig { cases: cases(), ..ProptestConfig::default() })]
#[test]
fn a_workspace_that_was_whole_stays_whole(ops in prop::collection::vec(op(), 1..9)) {
let fs = seeded();
let mut ws = build(&fs);
let seed_findings = block_on(ws.check(ROOT)).expect("the seed is readable");
prop_assert!(
seed_findings.is_empty(),
"the fixture must start clean, or every law below is vacuous: {seed_findings:?}"
);
for (n, op) in ops.iter().enumerate() {
let before = block_on(ws.check(ROOT)).expect("check before");
let Some(outcome) = apply(&mut ws, op) else { continue };
let Ok(reported) = outcome else { continue };
let after = block_on(ws.check(ROOT)).expect("check after");
let unreported: Vec<_> = CheckDiff::between(&before, &after)
.introduced
.into_iter()
.filter(|f| !reported.contains(f))
.collect();
prop_assert!(
unreported.is_empty(),
"op {n} of {ops:?} — {op:?} — introduced {unreported:?} without reporting it"
);
}
}
#[test]
fn a_refused_op_leaves_the_workspace_byte_for_byte(
ops in prop::collection::vec(op(), 1..9),
) {
let fs = seeded();
let mut ws = build(&fs);
for (n, op) in ops.iter().enumerate() {
let before = snapshot(&fs);
let Some(outcome) = apply(&mut ws, op) else { continue };
if let Err(refusal) = outcome {
prop_assert_eq!(
&snapshot(&fs),
&before,
"op {} of {:?} — {:?} — refused ({}) but still wrote",
n,
ops,
op,
refusal
);
}
}
}
}