use std::collections::HashSet;
use std::path::{Path, PathBuf};
use prov_graph::fs::ReadStorage;
use prov_graph::graph::{Graph, NodeKind, TreeOptions};
use prov_graph::index::IdIndex;
use prov_views::{Row, ViewSpec};
use crate::error::{Error, Result};
use crate::spec::ExportSpec;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportDoc {
pub path: PathBuf,
pub title: Option<String>,
pub declared: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Withheld {
pub path: PathBuf,
pub title: Option<String>,
pub declared: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportPlan {
pub export: String,
pub entries: Vec<ExportDoc>,
pub outside_view: Vec<PathBuf>,
pub withheld: Vec<Withheld>,
}
pub fn compose(
spec: &ExportSpec,
rows: &[Row],
view_scope: Option<&HashSet<PathBuf>>,
) -> ExportPlan {
let mut entries = Vec::new();
let mut withheld = Vec::new();
for row in rows {
let title = row.title().map(str::to_string);
match spec.gate.declared_in(&row.meta) {
Some(declared) if declared.iter().any(|v| v == spec.gate.value.trim()) => {
entries.push(ExportDoc {
path: row.path.clone(),
title,
declared,
});
}
declared => withheld.push(Withheld {
path: row.path.clone(),
title,
declared,
}),
}
}
let mut outside_view = Vec::new();
if let Some(scope) = view_scope {
entries.retain(|doc| {
if scope.contains(&doc.path) {
true
} else {
outside_view.push(doc.path.clone());
false
}
});
}
ExportPlan {
export: spec.name.clone(),
entries,
outside_view,
withheld,
}
}
pub async fn plan<FS: ReadStorage, Ix: IdIndex>(
graph: &Graph<FS, Ix>,
spec: &ExportSpec,
views: &[ViewSpec],
root_doc: impl AsRef<Path>,
) -> Result<ExportPlan> {
let root_doc = root_doc.as_ref();
let _scope = graph.read_scope();
let view = match &spec.view {
Some(name) => {
Some(
views
.iter()
.find(|v| v.name == *name)
.ok_or_else(|| Error::ViewUnknown {
export: spec.name.clone(),
view: name.clone(),
})?,
)
}
None => None,
};
let tree = graph
.tree_with(
root_doc,
TreeOptions {
ignore_missing: true,
},
)
.await?;
let mut reachable: Vec<PathBuf> = Vec::new();
collect(&tree, &mut reachable);
reachable.sort();
reachable.dedup();
let mut rows = Vec::with_capacity(reachable.len());
for path in reachable {
let doc = graph.document(&path).await?;
rows.push(Row {
path,
meta: doc.meta,
});
}
let view_scope = match view {
Some(view) => {
let selection = prov_views::select(graph, view, root_doc).await?;
Some(selection.rows.into_iter().map(|r| r.path).collect())
}
None => None,
};
Ok(compose(spec, &rows, view_scope.as_ref()))
}
fn collect(node: &prov_graph::graph::Node, out: &mut Vec<PathBuf>) {
if matches!(node.kind, NodeKind::Doc) {
out.push(node.path.clone());
}
for child in &node.children {
collect(child, out);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::Gate;
use prov_graph::meta::{Mapping, Value};
fn spec(name: &str, value: &str, view: Option<&str>) -> ExportSpec {
ExportSpec {
name: name.to_string(),
label: None,
gate: Gate {
field: "audience".into(),
value: value.to_string(),
},
view: view.map(str::to_string),
}
}
fn row(path: &str, audience: Option<&[&str]>) -> Row {
let mut meta = Mapping::new();
if let Some(values) = audience {
meta.insert(
"audience".into(),
Value::Sequence(values.iter().map(|v| Value::String((*v).into())).collect()),
);
}
Row {
path: PathBuf::from(path),
meta: Value::Mapping(meta),
}
}
fn scope(paths: &[&str]) -> HashSet<PathBuf> {
paths.iter().map(PathBuf::from).collect()
}
fn entry_paths(plan: &ExportPlan) -> Vec<&str> {
plan.entries
.iter()
.map(|d| d.path.to_str().unwrap())
.collect()
}
#[test]
fn an_unarranged_export_is_the_gate_set_whole() {
let rows = [
row("index.md", Some(&["family"])),
row("trip.md", Some(&["family", "friends"])),
row("secret.md", None),
];
let plan = compose(&spec("letters", "family", None), &rows, None);
assert_eq!(entry_paths(&plan), ["index.md", "trip.md"]);
assert!(plan.outside_view.is_empty());
assert_eq!(plan.withheld.len(), 1);
assert_eq!(plan.withheld[0].declared, None, "undeclared, not refused");
}
#[test]
fn a_view_can_narrow_an_export_but_never_widen_it() {
let rows = [
row("daily/index.md", Some(&["family"])),
row("daily/monday.md", Some(&["family"])),
row("daily/private.md", None),
row("drafts/note.md", Some(&["family"])),
];
let in_scope = scope(&["daily/index.md", "daily/monday.md", "daily/private.md"]);
let plan = compose(
&spec("letters", "family", Some("daily")),
&rows,
Some(&in_scope),
);
assert_eq!(
entry_paths(&plan),
["daily/index.md", "daily/monday.md"],
"in scope AND admitted — the undeclared file stays out despite the scope"
);
assert_eq!(
plan.outside_view,
vec![PathBuf::from("drafts/note.md")],
"admitted by the gate, scoped out by the view"
);
}
#[test]
fn the_export_set_is_always_a_subset_of_the_gate_set() {
let rows = [
row("a.md", Some(&["family"])),
row("b.md", Some(&["internal"])),
row("c.md", None),
];
let everything = scope(&["a.md", "b.md", "c.md"]);
let plan = compose(
&spec("letters", "family", Some("all")),
&rows,
Some(&everything),
);
assert_eq!(entry_paths(&plan), ["a.md"]);
let admitted: HashSet<&Path> = rows
.iter()
.filter(|r| {
Gate {
field: "audience".into(),
value: "family".into(),
}
.admits(&r.meta)
})
.map(|r| r.path.as_path())
.collect();
assert!(
plan.entries
.iter()
.all(|d| admitted.contains(d.path.as_path()))
);
}
#[test]
fn withheld_distinguishes_undeclared_from_otherwise_declared() {
let rows = [row("private.md", None), row("work.md", Some(&["internal"]))];
let plan = compose(&spec("letters", "family", None), &rows, None);
assert!(plan.entries.is_empty());
assert_eq!(plan.withheld[0].declared, None);
assert_eq!(
plan.withheld[1].declared,
Some(vec!["internal".to_string()])
);
}
#[test]
fn an_empty_view_scope_exports_nothing() {
let rows = [row("a.md", Some(&["family"]))];
let plan = compose(
&spec("letters", "family", Some("none")),
&rows,
Some(&scope(&[])),
);
assert!(plan.entries.is_empty());
assert_eq!(plan.outside_view, vec![PathBuf::from("a.md")]);
}
#[test]
fn entries_carry_their_declared_values() {
let rows = [row("trip.md", Some(&["family", "friends"]))];
let plan = compose(&spec("letters", "family", None), &rows, None);
assert_eq!(plan.entries[0].declared, ["family", "friends"]);
}
}
#[cfg(all(test, feature = "yaml"))]
mod fs_tests {
use super::*;
use crate::spec::Gate;
use prov_graph::exec::block_on;
use prov_graph::fs::StdFs;
use prov_graph::graph::ReadSettings;
use prov_graph::index::NoIndex;
use prov_views::Grouping;
fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
}
fn tempdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("prov-exports-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn journal(tag: &str) -> PathBuf {
let dir = tempdir(tag);
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- daily.md\n- note.md\n---\n",
);
write(
&dir,
"note.md",
"---\ntitle: Note\npart_of: index.md\naudience: family\n---\n",
);
write(
&dir,
"daily.md",
"---\ntitle: Daily\npart_of: index.md\naudience: family\ncontents:\n- daily/2026.md\n---\n",
);
write(
&dir,
"daily/2026.md",
"---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n---\n",
);
write(
&dir,
"daily/07-24.md",
"---\ntitle: July 24\npart_of: 2026.md\naudience:\n- family\n- friends\n---\n",
);
dir
}
fn graph(dir: &Path) -> Graph<StdFs, NoIndex> {
Graph::new(StdFs, dir, NoIndex, ReadSettings::default())
}
fn export(view: Option<&str>) -> ExportSpec {
ExportSpec {
name: "letters".into(),
label: None,
gate: Gate {
field: "audience".into(),
value: "family".into(),
},
view: view.map(str::to_string),
}
}
fn daily_view() -> ViewSpec {
ViewSpec {
name: "daily".into(),
label: None,
icon: None,
group: Grouping::field("title"),
under: Some("daily.md".into()),
filter: None,
nest: None,
}
}
fn entry_paths(plan: &ExportPlan) -> Vec<String> {
plan.entries
.iter()
.map(|d| d.path.display().to_string())
.collect()
}
#[test]
fn an_unarranged_export_walks_the_whole_workspace() {
let dir = journal("whole");
let plan = block_on(plan(&graph(&dir), &export(None), &[], "index.md")).expect("a plan");
assert_eq!(
entry_paths(&plan),
["daily/07-24.md", "daily.md", "note.md"]
);
assert_eq!(plan.withheld.len(), 2);
assert!(plan.withheld.iter().all(|w| w.declared.is_none()));
}
#[test]
fn a_view_narrows_the_export_and_the_valve_holds() {
let dir = journal("valve");
let plan = block_on(plan(
&graph(&dir),
&export(Some("daily")),
&[daily_view()],
"index.md",
))
.expect("a plan");
assert_eq!(entry_paths(&plan), ["daily/07-24.md"]);
assert_eq!(
plan.outside_view,
vec![PathBuf::from("daily.md"), PathBuf::from("note.md")],
"admitted by the gate, outside the view's scope"
);
assert!(
plan.withheld.iter().any(|w| w.path.ends_with("2026.md")),
"in the view's scope, held back by the gate"
);
}
#[test]
fn an_unknown_view_is_an_error_not_an_unarranged_export() {
let dir = journal("unknown-view");
let err = block_on(plan(
&graph(&dir),
&export(Some("dialy")),
&[daily_view()],
"index.md",
))
.unwrap_err();
let Error::ViewUnknown { export, view } = &err else {
panic!("got {err:?}");
};
assert_eq!(export, "letters");
assert_eq!(view, "dialy");
}
#[test]
fn a_broken_view_is_an_error_not_a_fallback() {
let dir = journal("broken-view");
let mut view = daily_view();
view.under = Some("[Gone](nowhere.md)".into());
let err = block_on(plan(
&graph(&dir),
&export(Some("daily")),
&[view],
"index.md",
))
.unwrap_err();
assert!(matches!(err, Error::View(_)), "got {err:?}");
}
#[test]
fn a_plan_is_deterministic() {
let dir = journal("stable");
let g = graph(&dir);
let views = [daily_view()];
let spec = export(Some("daily"));
let first = block_on(plan(&g, &spec, &views, "index.md")).unwrap();
let second = block_on(plan(&g, &spec, &views, "index.md")).unwrap();
assert_eq!(first, second);
}
}