prov_views/select.rs
1//! Selecting the documents a view covers: scope, then conditions.
2//!
3//! This is the half that touches the workspace. It answers one question — *which
4//! documents does this view cover?* — and answers it as a flat, deduplicated set
5//! in path order. How those documents become groups is [`group`](fn@crate::group), which
6//! is a pure function over what this returns.
7//!
8//! The split is what makes a [`Selection`] worth having as a value: one
9//! selection can be grouped several ways, and every grouping question is
10//! testable without a filesystem.
11//!
12//! # Scope is a traversal, not a path filter
13//!
14//! A view's [`under`](ViewSpec::under) is resolved by walking the **spanning
15//! relation** below the anchor it names, never by matching a path prefix or a
16//! title. That is the difference between a view and a saved search: `path
17//! starts-with "Daily/"` breaks the moment someone renames the folder, and
18//! matching every index *titled* `2026` finds the one under `Trips/` just as
19//! happily as the one under `Daily/`. A traversal survives a rename, a move and
20//! a retitle, because it follows the same declarations that make the workspace
21//! a workspace.
22//!
23//! The anchor itself is any link the workspace can resolve: a path
24//! (`[Daily](/daily.md)`), an id (`[Daily](id:abc1234)`), or a title
25//! (`[[Daily]]`). A title anchor names *one* index — several documents so
26//! titled is an error, not a union — which is what keeps this a traversal from
27//! a chosen node rather than a search. It is what lets a stencil declare a view
28//! before the index exists at any path: the workspace that applies it makes an
29//! index called `Daily` wherever it likes, and the view finds it.
30//!
31//! The scope is the whole subtree below the anchor, not its direct children —
32//! see the inheritance note in [`crate::spec`].
33
34use std::path::{Path, PathBuf};
35
36use prov_graph::fs::ReadStorage;
37use prov_graph::graph::{Graph, NodeKind, Target, TreeOptions};
38use prov_graph::index::IdIndex;
39use prov_graph::link::Link;
40use prov_graph::meta::Value;
41use prov_graph::title::{self, TitleIndex};
42
43use crate::error::{Error, Result};
44use crate::spec::ViewSpec;
45
46/// One document a view covers.
47///
48/// Carries the document's whole metadata block, which is what lets grouping and
49/// filtering be pure functions over a selection rather than passes that have to
50/// go back to disk.
51#[derive(Debug, Clone, PartialEq)]
52pub struct Row {
53 /// Workspace-relative, normalized path — join it onto the root with
54 /// [`Graph::fs_path`] before reading.
55 pub path: PathBuf,
56 /// The document's parsed metadata block.
57 pub meta: Value,
58}
59
60impl Row {
61 /// The document's `title`, when it declares one.
62 pub fn title(&self) -> Option<&str> {
63 self.meta.get("title").and_then(Value::as_str)
64 }
65}
66
67/// The documents a view covers: in scope, past its conditions, deduplicated,
68/// ordered by path.
69///
70/// Each document appears **once**, however many groups it will later fall into.
71/// That is the difference between this and a [`RowSet`](crate::RowSet), and it
72/// is why "how many documents does this view cover" is a question only this type
73/// can answer.
74#[derive(Debug, Clone, PartialEq)]
75pub struct Selection {
76 /// The name of the view that produced this.
77 pub view: String,
78 /// The documents, ordered by path.
79 pub rows: Vec<Row>,
80}
81
82impl Selection {
83 /// How many documents the view covers.
84 pub fn len(&self) -> usize {
85 self.rows.len()
86 }
87
88 /// Whether the view covers nothing.
89 pub fn is_empty(&self) -> bool {
90 self.rows.is_empty()
91 }
92}
93
94/// Select the documents `spec` covers, walking from `root_doc`.
95///
96/// `root_doc` is the workspace's root document: the spanning start for a view
97/// that declares no anchor, and the document an `under:` link resolves relative
98/// to. It is deliberately *not* the config surface the view was declared in — a
99/// view is a property of the workspace, so moving the config document that
100/// carries it must not change what it points at.
101///
102/// A view whose anchor names nothing is an [`Error::AnchorUnresolved`], not an
103/// empty result. Those two states look identical to a reader and mean opposite
104/// things: one is an archive with nothing in it yet, the other is a
105/// misconfigured lens, and swallowing the second is how a broken view gets read
106/// as an empty one for a year.
107pub async fn select<FS: ReadStorage, Ix: IdIndex>(
108 graph: &Graph<FS, Ix>,
109 spec: &ViewSpec,
110 root_doc: impl AsRef<Path>,
111) -> Result<Selection> {
112 select_with(graph, spec, root_doc, None).await
113}
114
115/// [`select`], with a title index for a nominal anchor (`under: '[[Daily]]'`).
116///
117/// Without one, a title anchor is resolved through an index this function
118/// builds itself, scoped to what the workspace reaches from `root_doc` — one
119/// scan, only when the anchor is title-shaped, and never for a path or an id.
120/// What that scan cannot know is which directories are the workspace's own
121/// parked bookkeeping (a retired history store, a recycle bin's items), so a
122/// caller that does know — `prov`'s `Workspace` — passes an index built with
123/// them excluded, and a title kept only inside one cannot make an anchor
124/// ambiguous.
125pub async fn select_with<FS: ReadStorage, Ix: IdIndex>(
126 graph: &Graph<FS, Ix>,
127 spec: &ViewSpec,
128 root_doc: impl AsRef<Path>,
129 titles: Option<&TitleIndex>,
130) -> Result<Selection> {
131 let root_doc = root_doc.as_ref();
132 // One scope for the whole selection: the spanning walk reads every document
133 // in scope, and so does the metadata pass immediately after. Without this
134 // they are two reads of every file for one view.
135 let _scope = graph.read_scope();
136
137 let anchor = match &spec.under {
138 Some(under) => resolve_anchor(graph, spec, root_doc, under, titles).await?,
139 None => root_doc.to_path_buf(),
140 };
141
142 // A dead spanning link has nothing to show in a view — no title, no
143 // children, no file — so it is dropped rather than materialized as a
144 // `Missing` node this pass would then have to filter out. `check` is where
145 // a broken link is a finding; a view is not a validator.
146 let tree = graph
147 .tree_with(
148 &anchor,
149 TreeOptions {
150 ignore_missing: true,
151 },
152 )
153 .await?;
154
155 // Resolving is not the same as arriving. A path anchor always *resolves* —
156 // a path is a path — so `Daily/gone.md` gets this far and then walks to
157 // nothing, which is the empty-vs-broken confusion again, one step later.
158 // The walk's own verdict on the anchor node is what settles it.
159 if spec.under.is_some()
160 && let Some(why) = unreached(&tree.kind)
161 {
162 return Err(Error::AnchorUnresolved {
163 view: spec.name.clone(),
164 under: spec.under.clone().unwrap_or_default(),
165 why,
166 });
167 }
168
169 let mut scope: Vec<PathBuf> = Vec::new();
170 collect(&tree, spec.under.is_some(), &mut scope);
171 // A spanning tree reaches each document once, so this only matters for a
172 // workspace that has already broken the single-parent invariant — where a
173 // view listing a document twice would be a second, confusing symptom of a
174 // fault `check` already reports properly.
175 scope.sort();
176 scope.dedup();
177
178 let mut rows = Vec::with_capacity(scope.len());
179 for path in scope {
180 let doc = graph.document(&path).await?;
181 let row = Row {
182 path,
183 meta: doc.meta,
184 };
185 if spec.filter.as_ref().is_none_or(|c| c.matches(&row.meta)) {
186 rows.push(row);
187 }
188 }
189
190 Ok(Selection {
191 view: spec.name.clone(),
192 rows,
193 })
194}
195
196/// Whether `link` addresses a document by name rather than by path or id — the
197/// one case resolving needs a title index.
198fn is_nominal(link: &Link) -> bool {
199 !link.is_external()
200 && !link.is_same_document()
201 && link.id_ref().is_none()
202 && title::is_alias_shaped(link.addressed_target())
203}
204
205/// The path a view's `under:` link names, or why it does not name one.
206async fn resolve_anchor<FS: ReadStorage, Ix: IdIndex>(
207 graph: &Graph<FS, Ix>,
208 spec: &ViewSpec,
209 root_doc: &Path,
210 under: &str,
211 titles: Option<&TitleIndex>,
212) -> Result<PathBuf> {
213 let unresolved = |why: &str| Error::AnchorUnresolved {
214 view: spec.name.clone(),
215 under: under.to_string(),
216 why: why.to_string(),
217 };
218 let link = Link::parse(under);
219 // A title index costs a scan, so it is built only for an anchor that needs
220 // one and that the caller did not already provide.
221 let scanned;
222 let titles = match titles {
223 Some(titles) => Some(titles),
224 None if is_nominal(&link) => {
225 scanned = graph.title_index_scoped(root_doc, &[]).await?;
226 Some(&scanned)
227 }
228 None => None,
229 };
230 match graph.resolve_link_with(root_doc, &link, titles) {
231 Target::Path(path) => Ok(path),
232 Target::UnresolvedId(id) => Err(unresolved(&format!(
233 "no document is registered under the id `{}`",
234 id.0
235 ))),
236 Target::AmbiguousAlias(name) => Err(unresolved(&format!(
237 "several documents are titled `{name}`, so the anchor names no one of them"
238 ))),
239 Target::External => Err(unresolved(
240 "an anchor must name a document in this workspace, and this is a URL",
241 )),
242 Target::SameDocument => Err(unresolved(
243 "an anchor must name a document, and this names only a place inside one",
244 )),
245 Target::Foreign { workspace, .. } => Err(unresolved(&format!(
246 "the anchor names a document in the workspace `{workspace}`, which prov cannot see from here"
247 ))),
248 }
249}
250
251/// Why a walk did not arrive at a readable document, or `None` when it did.
252///
253/// The remaining [`NodeKind`]s cannot occur at the root of a walk — a cycle
254/// needs a trail behind it, and the id/alias/foreign kinds are how a *link*
255/// failed, which [`resolve_anchor`] has already had its say about — but they
256/// are spelled out rather than swept into a wildcard, so a new node kind
257/// arrives here as a compile error instead of as a silently empty view.
258fn unreached(kind: &NodeKind) -> Option<String> {
259 match kind {
260 NodeKind::Doc => None,
261 NodeKind::Missing => Some("no document exists there".to_string()),
262 NodeKind::Unreadable(why) => Some(format!("that document could not be read: {why}")),
263 NodeKind::Cycle => Some("that document contains itself".to_string()),
264 NodeKind::UnresolvedId(id) => Some(format!("no document is registered under `{}`", id.0)),
265 NodeKind::AmbiguousAlias(name) => Some(format!("several documents are titled `{name}`")),
266 NodeKind::Foreign { workspace, .. } => Some(format!(
267 "it names a document in the workspace `{workspace}`, which prov cannot see from here"
268 )),
269 }
270}
271
272/// Flatten the readable documents of a spanning tree into `out`.
273///
274/// `skip_root` drops the anchor itself: an index is what a scoped view's
275/// records hang *under*, not one of them. An unscoped view keeps its start,
276/// because there the start is the workspace root and there is nothing it would
277/// be an index *of*.
278///
279/// Every other [`NodeKind`] is skipped — a cycle marker, an unreadable file, an
280/// unresolved id and a foreign leaf are all things `check` reports on and a
281/// view has no row for.
282fn collect(node: &prov_graph::graph::Node, skip_root: bool, out: &mut Vec<PathBuf>) {
283 if !skip_root && matches!(node.kind, NodeKind::Doc) {
284 out.push(node.path.clone());
285 }
286 for child in &node.children {
287 collect(child, false, out);
288 }
289}
290
291// These tests use YAML frontmatter fixtures, so they run under the `yaml`
292// feature.
293#[cfg(all(test, feature = "yaml"))]
294mod tests {
295 use super::*;
296 use crate::filter::Condition;
297 use crate::spec::Grouping;
298 use prov_graph::exec::block_on;
299 use prov_graph::fs::StdFs;
300 use prov_graph::graph::ReadSettings;
301 use prov_graph::index::NoIndex;
302
303 use prov_testkit::write;
304 fn tempdir(tag: &str) -> PathBuf {
305 prov_testkit::scratch("select", tag)
306 }
307
308 /// A journal: a `Daily/` index with entries under it, plus a README beside
309 /// them that carries a `created` stamp and is *not* a daily entry. The
310 /// README is the reason a view needs scope at all.
311 fn journal(tag: &str) -> PathBuf {
312 let dir = tempdir(tag);
313 write(
314 &dir,
315 "index.md",
316 "---\ntitle: Home\ncontents:\n- daily.md\n- readme.md\n---\n",
317 );
318 write(
319 &dir,
320 "readme.md",
321 "---\ntitle: Readme\npart_of: index.md\ncreated: 2026-01-02\n---\n",
322 );
323 write(
324 &dir,
325 "daily.md",
326 "---\ntitle: Daily\npart_of: index.md\ncontents:\n- daily/2026.md\n---\n",
327 );
328 write(
329 &dir,
330 "daily/2026.md",
331 "---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
332 );
333 write(
334 &dir,
335 "daily/07-24.md",
336 "---\ntitle: July 24\npart_of: 2026.md\ndate_of_document: 2026-07-24\ndraft: true\n---\n",
337 );
338 write(
339 &dir,
340 "daily/08-01.md",
341 "---\ntitle: August 1\npart_of: 2026.md\ncreated: 2026-08-01T09:00:00Z\n---\n",
342 );
343 dir
344 }
345
346 fn graph(dir: &Path) -> Graph<StdFs, NoIndex> {
347 Graph::new(StdFs, dir, NoIndex, ReadSettings::default())
348 }
349
350 fn spec(under: Option<&str>, filter: Option<Condition>) -> ViewSpec {
351 ViewSpec {
352 name: "daily".into(),
353 label: None,
354 icon: None,
355 group: Grouping {
356 keys: vec!["date_of_document".into(), "created".into()],
357 by: None,
358 },
359 under: under.map(str::to_string),
360 filter,
361 nest: None,
362 }
363 }
364
365 fn paths(selection: &Selection) -> Vec<String> {
366 selection
367 .rows
368 .iter()
369 .map(|r| r.path.display().to_string())
370 .collect()
371 }
372
373 /// An anchor by title resolves to the one index so titled, wherever it
374 /// sits — in either link notation — and the walk from there is the same
375 /// walk a path anchor gives. Two indexes with the title are a refusal with
376 /// the reason in it, and a title nothing carries reads as missing, like a
377 /// dead path.
378 #[test]
379 fn an_anchor_may_name_its_index_by_title() {
380 let dir = journal("title-anchor");
381 for under in ["[[Daily]]", "[Daily](Daily)"] {
382 let selection = block_on(select(&graph(&dir), &spec(Some(under), None), "index.md"))
383 .unwrap_or_else(|e| panic!("{under}: {e}"));
384 assert_eq!(
385 paths(&selection),
386 ["daily/07-24.md", "daily/08-01.md", "daily/2026.md"],
387 "{under}"
388 );
389 }
390 // The file stem is a name too, as it is for any nominal link.
391 let selection = block_on(select(
392 &graph(&dir),
393 &spec(Some("[[2026]]"), None),
394 "index.md",
395 ))
396 .unwrap();
397 assert_eq!(paths(&selection), ["daily/07-24.md", "daily/08-01.md"]);
398
399 let err = block_on(select(
400 &graph(&dir),
401 &spec(Some("[[Nowhere]]"), None),
402 "index.md",
403 ))
404 .unwrap_err()
405 .to_string();
406 assert!(err.contains("no document exists there"), "{err}");
407
408 write(
409 &dir,
410 "trips.md",
411 "---\ntitle: Daily\npart_of: index.md\n---\n",
412 );
413 let err = block_on(select(
414 &graph(&dir),
415 &spec(Some("[[Daily]]"), None),
416 "index.md",
417 ))
418 .unwrap_err()
419 .to_string();
420 assert!(
421 err.contains("several documents are titled `Daily`"),
422 "{err}"
423 );
424 }
425
426 /// The whole point of `under:`: the README carries a `created` date and is
427 /// still not selected, because it is not under `Daily`. And the anchor
428 /// itself is what the records hang under, not one of them.
429 #[test]
430 fn an_anchor_scopes_the_selection_to_its_subtree_and_excludes_itself() {
431 let dir = journal("scope");
432 let selection = block_on(select(
433 &graph(&dir),
434 &spec(Some("daily.md"), None),
435 "index.md",
436 ))
437 .expect("a selection");
438 assert_eq!(
439 paths(&selection),
440 ["daily/07-24.md", "daily/08-01.md", "daily/2026.md"]
441 );
442 }
443
444 /// Without an anchor the view is the whole workspace — the difference the
445 /// previous test isolated, in the other direction.
446 ///
447 /// The order is `Path`'s, which compares **component-wise**, not by bytes:
448 /// the component `daily` sorts before `daily.md`, so the directory's
449 /// contents precede the file beside it. Spelled out because it reads like a
450 /// bug otherwise.
451 #[test]
452 fn an_unscoped_view_covers_the_whole_workspace() {
453 let dir = journal("unscoped");
454 let selection =
455 block_on(select(&graph(&dir), &spec(None, None), "index.md")).expect("a selection");
456 assert_eq!(
457 paths(&selection),
458 [
459 "daily/07-24.md",
460 "daily/08-01.md",
461 "daily/2026.md",
462 "daily.md",
463 "index.md",
464 "readme.md",
465 ]
466 );
467 }
468
469 /// Scope follows the spanning links, so moving the whole subtree to a new
470 /// directory changes nothing. A `path starts-with "Daily/"` filter would
471 /// have returned an empty selection here.
472 #[test]
473 fn scope_survives_moving_the_subtree() {
474 let dir = journal("moved");
475 std::fs::rename(dir.join("daily"), dir.join("archive")).unwrap();
476 write(
477 &dir,
478 "daily.md",
479 "---\ntitle: Daily\npart_of: index.md\ncontents:\n- archive/2026.md\n---\n",
480 );
481 write(
482 &dir,
483 "archive/2026.md",
484 "---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
485 );
486
487 let selection = block_on(select(
488 &graph(&dir),
489 &spec(Some("daily.md"), None),
490 "index.md",
491 ))
492 .expect("a selection");
493 assert_eq!(
494 paths(&selection),
495 ["archive/07-24.md", "archive/08-01.md", "archive/2026.md"]
496 );
497 }
498
499 /// `where:` narrows what scope reached — and, unlike a broken anchor,
500 /// matching nothing is an ordinary answer rather than an error.
501 #[test]
502 fn a_where_condition_narrows_the_selection() {
503 let dir = journal("filter");
504 let no_drafts = Condition::Not(Box::new(Condition::Has("draft".into())));
505 let selection = block_on(select(
506 &graph(&dir),
507 &spec(Some("daily.md"), Some(no_drafts)),
508 "index.md",
509 ))
510 .expect("a selection");
511 assert_eq!(paths(&selection), ["daily/08-01.md", "daily/2026.md"]);
512
513 let matches_nothing = Condition::Has("nonexistent".into());
514 let empty = block_on(select(
515 &graph(&dir),
516 &spec(Some("daily.md"), Some(matches_nothing)),
517 "index.md",
518 ))
519 .expect("an empty selection is not an error");
520 assert!(empty.is_empty());
521 }
522
523 /// Rows carry their metadata, which is what lets grouping be a pure
524 /// function rather than a second pass over the disk.
525 #[test]
526 fn rows_carry_metadata_so_grouping_needs_no_second_read() {
527 let dir = journal("meta");
528 let spec = spec(Some("daily.md"), None);
529 let selection = block_on(select(&graph(&dir), &spec, "index.md")).expect("a selection");
530
531 let entry = selection
532 .rows
533 .iter()
534 .find(|r| r.path.ends_with("07-24.md"))
535 .expect("the entry");
536 assert_eq!(entry.title(), Some("July 24"));
537
538 // No graph, no filesystem, no async.
539 let rows = crate::group(&selection, &spec.group);
540 assert_eq!(rows.len(), 3, "documents, not placements");
541 assert_eq!(rows.groups.len(), 2);
542 }
543
544 /// An anchor that names nothing is an error, not an empty result. The two
545 /// look identical to a reader and mean opposite things.
546 #[test]
547 fn an_unresolvable_anchor_is_an_error_not_an_empty_selection() {
548 let dir = journal("dead-anchor");
549 // A path anchor always *resolves* — a path is a path — so this one is
550 // only caught by the walk failing to arrive.
551 let by_path = spec(Some("[Gone](nowhere.md)"), None);
552 let err = block_on(select(&graph(&dir), &by_path, "index.md")).unwrap_err();
553 let Error::AnchorUnresolved { under, why, .. } = &err else {
554 panic!("got {err:?}");
555 };
556 assert_eq!(under, "[Gone](nowhere.md)");
557 assert_eq!(why, "no document exists there");
558
559 let by_id = spec(Some("[Gone](id:abcd123)"), None);
560 let err = block_on(select(&graph(&dir), &by_id, "index.md")).unwrap_err();
561 let Error::AnchorUnresolved { view, under, .. } = &err else {
562 panic!("got {err:?}");
563 };
564 assert_eq!(view, "daily");
565 assert_eq!(under, "[Gone](id:abcd123)");
566 assert!(err.to_string().contains("is registered under the id"));
567 }
568
569 /// Selecting twice over an unchanged workspace produces the identical set —
570 /// the property that lets a consumer diff two runs.
571 #[test]
572 fn selection_is_deterministic() {
573 let dir = journal("stable");
574 let spec = spec(Some("daily.md"), None);
575 let g = graph(&dir);
576 let first = block_on(select(&g, &spec, "index.md")).unwrap();
577 let second = block_on(select(&g, &spec, "index.md")).unwrap();
578 assert_eq!(first, second);
579 }
580}