use std::path::{Path, PathBuf};
use prov_graph::fs::ReadStorage;
use prov_graph::graph::{Graph, NodeKind, Target, TreeOptions};
use prov_graph::index::IdIndex;
use prov_graph::link::Link;
use prov_graph::meta::Value;
use crate::error::{Error, Result};
use crate::spec::ViewSpec;
#[derive(Debug, Clone, PartialEq)]
pub struct Row {
pub path: PathBuf,
pub meta: Value,
}
impl Row {
pub fn title(&self) -> Option<&str> {
self.meta.get("title").and_then(Value::as_str)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Selection {
pub view: String,
pub rows: Vec<Row>,
}
impl Selection {
pub fn len(&self) -> usize {
self.rows.len()
}
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
}
pub async fn select<FS: ReadStorage, Ix: IdIndex>(
graph: &Graph<FS, Ix>,
spec: &ViewSpec,
root_doc: impl AsRef<Path>,
) -> Result<Selection> {
let root_doc = root_doc.as_ref();
let _scope = graph.read_scope();
let anchor = match &spec.under {
Some(under) => resolve_anchor(graph, spec, root_doc, under)?,
None => root_doc.to_path_buf(),
};
let tree = graph
.tree_with(
&anchor,
TreeOptions {
ignore_missing: true,
},
)
.await?;
if spec.under.is_some()
&& let Some(why) = unreached(&tree.kind)
{
return Err(Error::AnchorUnresolved {
view: spec.name.clone(),
under: spec.under.clone().unwrap_or_default(),
why,
});
}
let mut scope: Vec<PathBuf> = Vec::new();
collect(&tree, spec.under.is_some(), &mut scope);
scope.sort();
scope.dedup();
let mut rows = Vec::with_capacity(scope.len());
for path in scope {
let doc = graph.document(&path).await?;
let row = Row {
path,
meta: doc.meta,
};
if spec.filter.as_ref().is_none_or(|c| c.matches(&row.meta)) {
rows.push(row);
}
}
Ok(Selection {
view: spec.name.clone(),
rows,
})
}
fn resolve_anchor<FS, Ix: IdIndex>(
graph: &Graph<FS, Ix>,
spec: &ViewSpec,
root_doc: &Path,
under: &str,
) -> Result<PathBuf> {
let unresolved = |why: &str| Error::AnchorUnresolved {
view: spec.name.clone(),
under: under.to_string(),
why: why.to_string(),
};
match graph.resolve_link(root_doc, &Link::parse(under)) {
Target::Path(path) => Ok(path),
Target::UnresolvedId(id) => Err(unresolved(&format!(
"no document is registered under the id `{}`",
id.0
))),
Target::AmbiguousAlias(name) => Err(unresolved(&format!(
"several documents are titled `{name}`, so the anchor names no one of them"
))),
Target::External => Err(unresolved(
"an anchor must name a document in this workspace, and this is a URL",
)),
Target::Foreign { workspace, .. } => Err(unresolved(&format!(
"the anchor names a document in the workspace `{workspace}`, which prov cannot see from here"
))),
}
}
fn unreached(kind: &NodeKind) -> Option<String> {
match kind {
NodeKind::Doc => None,
NodeKind::Missing => Some("no document exists there".to_string()),
NodeKind::Unreadable(why) => Some(format!("that document could not be read: {why}")),
NodeKind::Cycle => Some("that document contains itself".to_string()),
NodeKind::UnresolvedId(id) => Some(format!("no document is registered under `{}`", id.0)),
NodeKind::AmbiguousAlias(name) => Some(format!("several documents are titled `{name}`")),
NodeKind::Foreign { workspace, .. } => Some(format!(
"it names a document in the workspace `{workspace}`, which prov cannot see from here"
)),
}
}
fn collect(node: &prov_graph::graph::Node, skip_root: bool, out: &mut Vec<PathBuf>) {
if !skip_root && matches!(node.kind, NodeKind::Doc) {
out.push(node.path.clone());
}
for child in &node.children {
collect(child, false, out);
}
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::*;
use crate::filter::Condition;
use crate::spec::Grouping;
use prov_graph::exec::block_on;
use prov_graph::fs::StdFs;
use prov_graph::graph::ReadSettings;
use prov_graph::index::NoIndex;
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-select-{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- readme.md\n---\n",
);
write(
&dir,
"readme.md",
"---\ntitle: Readme\npart_of: index.md\ncreated: 2026-01-02\n---\n",
);
write(
&dir,
"daily.md",
"---\ntitle: Daily\npart_of: index.md\ncontents:\n- daily/2026.md\n---\n",
);
write(
&dir,
"daily/2026.md",
"---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
);
write(
&dir,
"daily/07-24.md",
"---\ntitle: July 24\npart_of: 2026.md\ndate_of_document: 2026-07-24\ndraft: true\n---\n",
);
write(
&dir,
"daily/08-01.md",
"---\ntitle: August 1\npart_of: 2026.md\ncreated: 2026-08-01T09:00:00Z\n---\n",
);
dir
}
fn graph(dir: &Path) -> Graph<StdFs, NoIndex> {
Graph::new(StdFs, dir, NoIndex, ReadSettings::default())
}
fn spec(under: Option<&str>, filter: Option<Condition>) -> ViewSpec {
ViewSpec {
name: "daily".into(),
label: None,
icon: None,
group: Grouping {
keys: vec!["date_of_document".into(), "created".into()],
by: None,
},
under: under.map(str::to_string),
filter,
nest: None,
}
}
fn paths(selection: &Selection) -> Vec<String> {
selection
.rows
.iter()
.map(|r| r.path.display().to_string())
.collect()
}
#[test]
fn an_anchor_scopes_the_selection_to_its_subtree_and_excludes_itself() {
let dir = journal("scope");
let selection = block_on(select(
&graph(&dir),
&spec(Some("daily.md"), None),
"index.md",
))
.expect("a selection");
assert_eq!(
paths(&selection),
["daily/07-24.md", "daily/08-01.md", "daily/2026.md"]
);
}
#[test]
fn an_unscoped_view_covers_the_whole_workspace() {
let dir = journal("unscoped");
let selection =
block_on(select(&graph(&dir), &spec(None, None), "index.md")).expect("a selection");
assert_eq!(
paths(&selection),
[
"daily/07-24.md",
"daily/08-01.md",
"daily/2026.md",
"daily.md",
"index.md",
"readme.md",
]
);
}
#[test]
fn scope_survives_moving_the_subtree() {
let dir = journal("moved");
std::fs::rename(dir.join("daily"), dir.join("archive")).unwrap();
write(
&dir,
"daily.md",
"---\ntitle: Daily\npart_of: index.md\ncontents:\n- archive/2026.md\n---\n",
);
write(
&dir,
"archive/2026.md",
"---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
);
let selection = block_on(select(
&graph(&dir),
&spec(Some("daily.md"), None),
"index.md",
))
.expect("a selection");
assert_eq!(
paths(&selection),
["archive/07-24.md", "archive/08-01.md", "archive/2026.md"]
);
}
#[test]
fn a_where_condition_narrows_the_selection() {
let dir = journal("filter");
let no_drafts = Condition::Not(Box::new(Condition::Has("draft".into())));
let selection = block_on(select(
&graph(&dir),
&spec(Some("daily.md"), Some(no_drafts)),
"index.md",
))
.expect("a selection");
assert_eq!(paths(&selection), ["daily/08-01.md", "daily/2026.md"]);
let matches_nothing = Condition::Has("nonexistent".into());
let empty = block_on(select(
&graph(&dir),
&spec(Some("daily.md"), Some(matches_nothing)),
"index.md",
))
.expect("an empty selection is not an error");
assert!(empty.is_empty());
}
#[test]
fn rows_carry_metadata_so_grouping_needs_no_second_read() {
let dir = journal("meta");
let spec = spec(Some("daily.md"), None);
let selection = block_on(select(&graph(&dir), &spec, "index.md")).expect("a selection");
let entry = selection
.rows
.iter()
.find(|r| r.path.ends_with("07-24.md"))
.expect("the entry");
assert_eq!(entry.title(), Some("July 24"));
let rows = crate::group(&selection, &spec.group);
assert_eq!(rows.len(), 3, "documents, not placements");
assert_eq!(rows.groups.len(), 2);
}
#[test]
fn an_unresolvable_anchor_is_an_error_not_an_empty_selection() {
let dir = journal("dead-anchor");
let by_path = spec(Some("[Gone](nowhere.md)"), None);
let err = block_on(select(&graph(&dir), &by_path, "index.md")).unwrap_err();
let Error::AnchorUnresolved { under, why, .. } = &err else {
panic!("got {err:?}");
};
assert_eq!(under, "[Gone](nowhere.md)");
assert_eq!(why, "no document exists there");
let by_id = spec(Some("[Gone](id:abcd123)"), None);
let err = block_on(select(&graph(&dir), &by_id, "index.md")).unwrap_err();
let Error::AnchorUnresolved { view, under, .. } = &err else {
panic!("got {err:?}");
};
assert_eq!(view, "daily");
assert_eq!(under, "[Gone](id:abcd123)");
assert!(err.to_string().contains("is registered under the id"));
}
#[test]
fn selection_is_deterministic() {
let dir = journal("stable");
let spec = spec(Some("daily.md"), None);
let g = graph(&dir);
let first = block_on(select(&g, &spec, "index.md")).unwrap();
let second = block_on(select(&g, &spec, "index.md")).unwrap();
assert_eq!(first, second);
}
}