1use std::path::{Path, PathBuf};
27
28use prov_graph::fs::ReadStorage;
29use prov_graph::graph::{Graph, NodeKind, Target, TreeOptions};
30use prov_graph::index::IdIndex;
31use prov_graph::link::Link;
32use prov_graph::meta::Value;
33
34use crate::error::{Error, Result};
35use crate::spec::ViewSpec;
36
37#[derive(Debug, Clone, PartialEq)]
43pub struct Row {
44 pub path: PathBuf,
47 pub meta: Value,
49}
50
51impl Row {
52 pub fn title(&self) -> Option<&str> {
54 self.meta.get("title").and_then(Value::as_str)
55 }
56}
57
58#[derive(Debug, Clone, PartialEq)]
66pub struct Selection {
67 pub view: String,
69 pub rows: Vec<Row>,
71}
72
73impl Selection {
74 pub fn len(&self) -> usize {
76 self.rows.len()
77 }
78
79 pub fn is_empty(&self) -> bool {
81 self.rows.is_empty()
82 }
83}
84
85pub async fn select<FS: ReadStorage, Ix: IdIndex>(
99 graph: &Graph<FS, Ix>,
100 spec: &ViewSpec,
101 root_doc: impl AsRef<Path>,
102) -> Result<Selection> {
103 let root_doc = root_doc.as_ref();
104 let _scope = graph.read_scope();
108
109 let anchor = match &spec.under {
110 Some(under) => resolve_anchor(graph, spec, root_doc, under)?,
111 None => root_doc.to_path_buf(),
112 };
113
114 let tree = graph
119 .tree_with(
120 &anchor,
121 TreeOptions {
122 ignore_missing: true,
123 },
124 )
125 .await?;
126
127 if spec.under.is_some()
132 && let Some(why) = unreached(&tree.kind)
133 {
134 return Err(Error::AnchorUnresolved {
135 view: spec.name.clone(),
136 under: spec.under.clone().unwrap_or_default(),
137 why,
138 });
139 }
140
141 let mut scope: Vec<PathBuf> = Vec::new();
142 collect(&tree, spec.under.is_some(), &mut scope);
143 scope.sort();
148 scope.dedup();
149
150 let mut rows = Vec::with_capacity(scope.len());
151 for path in scope {
152 let doc = graph.document(&path).await?;
153 let row = Row {
154 path,
155 meta: doc.meta,
156 };
157 if spec.filter.as_ref().is_none_or(|c| c.matches(&row.meta)) {
158 rows.push(row);
159 }
160 }
161
162 Ok(Selection {
163 view: spec.name.clone(),
164 rows,
165 })
166}
167
168fn resolve_anchor<FS, Ix: IdIndex>(
170 graph: &Graph<FS, Ix>,
171 spec: &ViewSpec,
172 root_doc: &Path,
173 under: &str,
174) -> Result<PathBuf> {
175 let unresolved = |why: &str| Error::AnchorUnresolved {
176 view: spec.name.clone(),
177 under: under.to_string(),
178 why: why.to_string(),
179 };
180 match graph.resolve_link(root_doc, &Link::parse(under)) {
181 Target::Path(path) => Ok(path),
182 Target::UnresolvedId(id) => Err(unresolved(&format!(
183 "no document is registered under the id `{}`",
184 id.0
185 ))),
186 Target::AmbiguousAlias(name) => Err(unresolved(&format!(
187 "several documents are titled `{name}`, so the anchor names no one of them"
188 ))),
189 Target::External => Err(unresolved(
190 "an anchor must name a document in this workspace, and this is a URL",
191 )),
192 Target::Foreign { workspace, .. } => Err(unresolved(&format!(
193 "the anchor names a document in the workspace `{workspace}`, which prov cannot see from here"
194 ))),
195 }
196}
197
198fn unreached(kind: &NodeKind) -> Option<String> {
206 match kind {
207 NodeKind::Doc => None,
208 NodeKind::Missing => Some("no document exists there".to_string()),
209 NodeKind::Unreadable(why) => Some(format!("that document could not be read: {why}")),
210 NodeKind::Cycle => Some("that document contains itself".to_string()),
211 NodeKind::UnresolvedId(id) => Some(format!("no document is registered under `{}`", id.0)),
212 NodeKind::AmbiguousAlias(name) => Some(format!("several documents are titled `{name}`")),
213 NodeKind::Foreign { workspace, .. } => Some(format!(
214 "it names a document in the workspace `{workspace}`, which prov cannot see from here"
215 )),
216 }
217}
218
219fn collect(node: &prov_graph::graph::Node, skip_root: bool, out: &mut Vec<PathBuf>) {
230 if !skip_root && matches!(node.kind, NodeKind::Doc) {
231 out.push(node.path.clone());
232 }
233 for child in &node.children {
234 collect(child, false, out);
235 }
236}
237
238#[cfg(all(test, feature = "yaml"))]
241mod tests {
242 use super::*;
243 use crate::filter::Condition;
244 use crate::spec::Grouping;
245 use prov_graph::exec::block_on;
246 use prov_graph::fs::StdFs;
247 use prov_graph::graph::ReadSettings;
248 use prov_graph::index::NoIndex;
249
250 use prov_testkit::write;
251 fn tempdir(tag: &str) -> PathBuf {
252 prov_testkit::scratch("select", tag)
253 }
254
255 fn journal(tag: &str) -> PathBuf {
259 let dir = tempdir(tag);
260 write(
261 &dir,
262 "index.md",
263 "---\ntitle: Home\ncontents:\n- daily.md\n- readme.md\n---\n",
264 );
265 write(
266 &dir,
267 "readme.md",
268 "---\ntitle: Readme\npart_of: index.md\ncreated: 2026-01-02\n---\n",
269 );
270 write(
271 &dir,
272 "daily.md",
273 "---\ntitle: Daily\npart_of: index.md\ncontents:\n- daily/2026.md\n---\n",
274 );
275 write(
276 &dir,
277 "daily/2026.md",
278 "---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
279 );
280 write(
281 &dir,
282 "daily/07-24.md",
283 "---\ntitle: July 24\npart_of: 2026.md\ndate_of_document: 2026-07-24\ndraft: true\n---\n",
284 );
285 write(
286 &dir,
287 "daily/08-01.md",
288 "---\ntitle: August 1\npart_of: 2026.md\ncreated: 2026-08-01T09:00:00Z\n---\n",
289 );
290 dir
291 }
292
293 fn graph(dir: &Path) -> Graph<StdFs, NoIndex> {
294 Graph::new(StdFs, dir, NoIndex, ReadSettings::default())
295 }
296
297 fn spec(under: Option<&str>, filter: Option<Condition>) -> ViewSpec {
298 ViewSpec {
299 name: "daily".into(),
300 label: None,
301 icon: None,
302 group: Grouping {
303 keys: vec!["date_of_document".into(), "created".into()],
304 by: None,
305 },
306 under: under.map(str::to_string),
307 filter,
308 nest: None,
309 }
310 }
311
312 fn paths(selection: &Selection) -> Vec<String> {
313 selection
314 .rows
315 .iter()
316 .map(|r| r.path.display().to_string())
317 .collect()
318 }
319
320 #[test]
324 fn an_anchor_scopes_the_selection_to_its_subtree_and_excludes_itself() {
325 let dir = journal("scope");
326 let selection = block_on(select(
327 &graph(&dir),
328 &spec(Some("daily.md"), None),
329 "index.md",
330 ))
331 .expect("a selection");
332 assert_eq!(
333 paths(&selection),
334 ["daily/07-24.md", "daily/08-01.md", "daily/2026.md"]
335 );
336 }
337
338 #[test]
346 fn an_unscoped_view_covers_the_whole_workspace() {
347 let dir = journal("unscoped");
348 let selection =
349 block_on(select(&graph(&dir), &spec(None, None), "index.md")).expect("a selection");
350 assert_eq!(
351 paths(&selection),
352 [
353 "daily/07-24.md",
354 "daily/08-01.md",
355 "daily/2026.md",
356 "daily.md",
357 "index.md",
358 "readme.md",
359 ]
360 );
361 }
362
363 #[test]
367 fn scope_survives_moving_the_subtree() {
368 let dir = journal("moved");
369 std::fs::rename(dir.join("daily"), dir.join("archive")).unwrap();
370 write(
371 &dir,
372 "daily.md",
373 "---\ntitle: Daily\npart_of: index.md\ncontents:\n- archive/2026.md\n---\n",
374 );
375 write(
376 &dir,
377 "archive/2026.md",
378 "---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
379 );
380
381 let selection = block_on(select(
382 &graph(&dir),
383 &spec(Some("daily.md"), None),
384 "index.md",
385 ))
386 .expect("a selection");
387 assert_eq!(
388 paths(&selection),
389 ["archive/07-24.md", "archive/08-01.md", "archive/2026.md"]
390 );
391 }
392
393 #[test]
396 fn a_where_condition_narrows_the_selection() {
397 let dir = journal("filter");
398 let no_drafts = Condition::Not(Box::new(Condition::Has("draft".into())));
399 let selection = block_on(select(
400 &graph(&dir),
401 &spec(Some("daily.md"), Some(no_drafts)),
402 "index.md",
403 ))
404 .expect("a selection");
405 assert_eq!(paths(&selection), ["daily/08-01.md", "daily/2026.md"]);
406
407 let matches_nothing = Condition::Has("nonexistent".into());
408 let empty = block_on(select(
409 &graph(&dir),
410 &spec(Some("daily.md"), Some(matches_nothing)),
411 "index.md",
412 ))
413 .expect("an empty selection is not an error");
414 assert!(empty.is_empty());
415 }
416
417 #[test]
420 fn rows_carry_metadata_so_grouping_needs_no_second_read() {
421 let dir = journal("meta");
422 let spec = spec(Some("daily.md"), None);
423 let selection = block_on(select(&graph(&dir), &spec, "index.md")).expect("a selection");
424
425 let entry = selection
426 .rows
427 .iter()
428 .find(|r| r.path.ends_with("07-24.md"))
429 .expect("the entry");
430 assert_eq!(entry.title(), Some("July 24"));
431
432 let rows = crate::group(&selection, &spec.group);
434 assert_eq!(rows.len(), 3, "documents, not placements");
435 assert_eq!(rows.groups.len(), 2);
436 }
437
438 #[test]
441 fn an_unresolvable_anchor_is_an_error_not_an_empty_selection() {
442 let dir = journal("dead-anchor");
443 let by_path = spec(Some("[Gone](nowhere.md)"), None);
446 let err = block_on(select(&graph(&dir), &by_path, "index.md")).unwrap_err();
447 let Error::AnchorUnresolved { under, why, .. } = &err else {
448 panic!("got {err:?}");
449 };
450 assert_eq!(under, "[Gone](nowhere.md)");
451 assert_eq!(why, "no document exists there");
452
453 let by_id = spec(Some("[Gone](id:abcd123)"), None);
454 let err = block_on(select(&graph(&dir), &by_id, "index.md")).unwrap_err();
455 let Error::AnchorUnresolved { view, under, .. } = &err else {
456 panic!("got {err:?}");
457 };
458 assert_eq!(view, "daily");
459 assert_eq!(under, "[Gone](id:abcd123)");
460 assert!(err.to_string().contains("is registered under the id"));
461 }
462
463 #[test]
466 fn selection_is_deterministic() {
467 let dir = journal("stable");
468 let spec = spec(Some("daily.md"), None);
469 let g = graph(&dir);
470 let first = block_on(select(&g, &spec, "index.md")).unwrap();
471 let second = block_on(select(&g, &spec, "index.md")).unwrap();
472 assert_eq!(first, second);
473 }
474}