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/// Every document the workspace reaches from `root_doc` — the root included —
116/// each once, with its metadata, in path order.
117///
118/// This is the census a view *narrows*: the same walk [`select`] makes for a
119/// view with no `under:` and no `where:`, offered without a [`ViewSpec`]
120/// because the question needs none. A consumer building its own index over a
121/// workspace — a query engine, a search table, a shell pipeline — wants the
122/// whole reached set with the metadata attached, and asking it to declare a
123/// view that says "everything" first would be ceremony for a lens with no
124/// glass in it.
125///
126/// Reached, not present: a file in a directory nothing links into is not a
127/// row, for the same reason `check` does not report it. The spine decides what
128/// the workspace contains; this lists it.
129pub async fn documents<FS: ReadStorage, Ix: IdIndex>(
130 graph: &Graph<FS, Ix>,
131 root_doc: impl AsRef<Path>,
132) -> Result<Vec<Row>> {
133 let _scope = graph.read_scope();
134 let tree = graph
135 .tree_with(
136 root_doc.as_ref(),
137 TreeOptions {
138 ignore_missing: true,
139 },
140 )
141 .await?;
142 rows_of(graph, &tree, false).await
143}
144
145/// [`select`], with a title index for a nominal anchor (`under: '[[Daily]]'`).
146///
147/// Without one, a title anchor is resolved through an index this function
148/// builds itself, scoped to what the workspace reaches from `root_doc` — one
149/// scan, only when the anchor is title-shaped, and never for a path or an id.
150/// What that scan cannot know is which directories are the workspace's own
151/// parked bookkeeping (a retired history store, a recycle bin's items), so a
152/// caller that does know — `prov`'s `Workspace` — passes an index built with
153/// them excluded, and a title kept only inside one cannot make an anchor
154/// ambiguous.
155pub async fn select_with<FS: ReadStorage, Ix: IdIndex>(
156 graph: &Graph<FS, Ix>,
157 spec: &ViewSpec,
158 root_doc: impl AsRef<Path>,
159 titles: Option<&TitleIndex>,
160) -> Result<Selection> {
161 let root_doc = root_doc.as_ref();
162 // One scope for the whole selection: the spanning walk reads every document
163 // in scope, and so does the metadata pass immediately after. Without this
164 // they are two reads of every file for one view.
165 let _scope = graph.read_scope();
166
167 let anchor = match &spec.under {
168 Some(under) => resolve_anchor(graph, spec, root_doc, under, titles).await?,
169 None => root_doc.to_path_buf(),
170 };
171
172 // A dead spanning link has nothing to show in a view — no title, no
173 // children, no file — so it is dropped rather than materialized as a
174 // `Missing` node this pass would then have to filter out. `check` is where
175 // a broken link is a finding; a view is not a validator.
176 let tree = graph
177 .tree_with(
178 &anchor,
179 TreeOptions {
180 ignore_missing: true,
181 },
182 )
183 .await?;
184
185 // Resolving is not the same as arriving. A path anchor always *resolves* —
186 // a path is a path — so `Daily/gone.md` gets this far and then walks to
187 // nothing, which is the empty-vs-broken confusion again, one step later.
188 // The walk's own verdict on the anchor node is what settles it.
189 if spec.under.is_some()
190 && let Some(why) = unreached(&tree.kind)
191 {
192 return Err(Error::AnchorUnresolved {
193 view: spec.name.clone(),
194 under: spec.under.clone().unwrap_or_default(),
195 why,
196 });
197 }
198
199 let mut rows = rows_of(graph, &tree, spec.under.is_some()).await?;
200 if let Some(condition) = &spec.filter {
201 rows.retain(|row| condition.matches(&row.meta));
202 }
203
204 Ok(Selection {
205 view: spec.name.clone(),
206 rows,
207 })
208}
209
210/// The readable documents of a walked spanning tree, each once, in path order,
211/// with their metadata read.
212///
213/// Shared by [`select_with`] and [`documents`] so that a view and the census it
214/// narrows cannot disagree about which files are documents. `skip_root` is
215/// [`collect`]'s: dropped for a scoped view, kept otherwise.
216async fn rows_of<FS: ReadStorage, Ix: IdIndex>(
217 graph: &Graph<FS, Ix>,
218 tree: &prov_graph::graph::Node,
219 skip_root: bool,
220) -> Result<Vec<Row>> {
221 let mut scope: Vec<PathBuf> = Vec::new();
222 collect(tree, skip_root, &mut scope);
223 // A spanning tree reaches each document once, so this only matters for a
224 // workspace that has already broken the single-parent invariant — where a
225 // view listing a document twice would be a second, confusing symptom of a
226 // fault `check` already reports properly.
227 scope.sort();
228 scope.dedup();
229
230 let mut rows = Vec::with_capacity(scope.len());
231 for path in scope {
232 let doc = graph.document(&path).await?;
233 rows.push(Row {
234 path,
235 meta: doc.meta,
236 });
237 }
238 Ok(rows)
239}
240
241/// Whether `link` addresses a document by name rather than by path or id — the
242/// one case resolving needs a title index.
243fn is_nominal(link: &Link) -> bool {
244 !link.is_external()
245 && !link.is_same_document()
246 && link.id_ref().is_none()
247 && title::is_alias_shaped(link.addressed_target())
248}
249
250/// The path a view's `under:` link names, or why it does not name one.
251async fn resolve_anchor<FS: ReadStorage, Ix: IdIndex>(
252 graph: &Graph<FS, Ix>,
253 spec: &ViewSpec,
254 root_doc: &Path,
255 under: &str,
256 titles: Option<&TitleIndex>,
257) -> Result<PathBuf> {
258 let unresolved = |why: &str| Error::AnchorUnresolved {
259 view: spec.name.clone(),
260 under: under.to_string(),
261 why: why.to_string(),
262 };
263 let link = Link::parse(under);
264 // A title index costs a scan, so it is built only for an anchor that needs
265 // one and that the caller did not already provide.
266 let scanned;
267 let titles = match titles {
268 Some(titles) => Some(titles),
269 None if is_nominal(&link) => {
270 scanned = graph.title_index_scoped(root_doc, &[]).await?;
271 Some(&scanned)
272 }
273 None => None,
274 };
275 match graph.resolve_link_with(root_doc, &link, titles) {
276 Target::Path(path) => Ok(path),
277 Target::UnresolvedId(id) => Err(unresolved(&format!(
278 "no document is registered under the id `{}`",
279 id.0
280 ))),
281 Target::AmbiguousAlias(name) => Err(unresolved(&format!(
282 "several documents are titled `{name}`, so the anchor names no one of them"
283 ))),
284 Target::External => Err(unresolved(
285 "an anchor must name a document in this workspace, and this is a URL",
286 )),
287 Target::SameDocument => Err(unresolved(
288 "an anchor must name a document, and this names only a place inside one",
289 )),
290 Target::Foreign { workspace, .. } => Err(unresolved(&format!(
291 "the anchor names a document in the workspace `{workspace}`, which prov cannot see from here"
292 ))),
293 }
294}
295
296/// Why a walk did not arrive at a readable document, or `None` when it did.
297///
298/// The remaining [`NodeKind`]s cannot occur at the root of a walk — a cycle
299/// needs a trail behind it, and the id/alias/foreign kinds are how a *link*
300/// failed, which [`resolve_anchor`] has already had its say about — but they
301/// are spelled out rather than swept into a wildcard, so a new node kind
302/// arrives here as a compile error instead of as a silently empty view.
303fn unreached(kind: &NodeKind) -> Option<String> {
304 match kind {
305 NodeKind::Doc => None,
306 NodeKind::Missing => Some("no document exists there".to_string()),
307 NodeKind::Unreadable(why) => Some(format!("that document could not be read: {why}")),
308 NodeKind::Cycle => Some("that document contains itself".to_string()),
309 NodeKind::UnresolvedId(id) => Some(format!("no document is registered under `{}`", id.0)),
310 NodeKind::AmbiguousAlias(name) => Some(format!("several documents are titled `{name}`")),
311 NodeKind::Foreign { workspace, .. } => Some(format!(
312 "it names a document in the workspace `{workspace}`, which prov cannot see from here"
313 )),
314 }
315}
316
317/// Flatten the readable documents of a spanning tree into `out`.
318///
319/// `skip_root` drops the anchor itself: an index is what a scoped view's
320/// records hang *under*, not one of them. An unscoped view keeps its start,
321/// because there the start is the workspace root and there is nothing it would
322/// be an index *of*.
323///
324/// Every other [`NodeKind`] is skipped — a cycle marker, an unreadable file, an
325/// unresolved id and a foreign leaf are all things `check` reports on and a
326/// view has no row for.
327fn collect(node: &prov_graph::graph::Node, skip_root: bool, out: &mut Vec<PathBuf>) {
328 if !skip_root && matches!(node.kind, NodeKind::Doc) {
329 out.push(node.path.clone());
330 }
331 for child in &node.children {
332 collect(child, false, out);
333 }
334}
335
336// These tests use YAML frontmatter fixtures, so they run under the `yaml`
337// feature.
338#[cfg(all(test, feature = "yaml"))]
339mod tests {
340 use super::*;
341 use crate::filter::Condition;
342 use crate::spec::Grouping;
343 use prov_graph::exec::block_on;
344 use prov_graph::fs::StdFs;
345 use prov_graph::graph::ReadSettings;
346 use prov_graph::index::NoIndex;
347
348 use prov_testkit::write;
349 fn tempdir(tag: &str) -> PathBuf {
350 prov_testkit::scratch("select", tag)
351 }
352
353 /// A journal: a `Daily/` index with entries under it, plus a README beside
354 /// them that carries a `created` stamp and is *not* a daily entry. The
355 /// README is the reason a view needs scope at all.
356 fn journal(tag: &str) -> PathBuf {
357 let dir = tempdir(tag);
358 write(
359 &dir,
360 "index.md",
361 "---\ntitle: Home\ncontents:\n- daily.md\n- readme.md\n---\n",
362 );
363 write(
364 &dir,
365 "readme.md",
366 "---\ntitle: Readme\npart_of: index.md\ncreated: 2026-01-02\n---\n",
367 );
368 write(
369 &dir,
370 "daily.md",
371 "---\ntitle: Daily\npart_of: index.md\ncontents:\n- daily/2026.md\n---\n",
372 );
373 write(
374 &dir,
375 "daily/2026.md",
376 "---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
377 );
378 write(
379 &dir,
380 "daily/07-24.md",
381 "---\ntitle: July 24\npart_of: 2026.md\ndate_of_document: 2026-07-24\ndraft: true\n---\n",
382 );
383 write(
384 &dir,
385 "daily/08-01.md",
386 "---\ntitle: August 1\npart_of: 2026.md\ncreated: 2026-08-01T09:00:00Z\n---\n",
387 );
388 dir
389 }
390
391 fn graph(dir: &Path) -> Graph<StdFs, NoIndex> {
392 Graph::new(StdFs, dir, NoIndex, ReadSettings::default())
393 }
394
395 fn spec(under: Option<&str>, filter: Option<Condition>) -> ViewSpec {
396 ViewSpec {
397 name: "daily".into(),
398 label: None,
399 icon: None,
400 group: Grouping {
401 keys: vec!["date_of_document".into(), "created".into()],
402 by: None,
403 },
404 under: under.map(str::to_string),
405 filter,
406 nest: None,
407 }
408 }
409
410 fn paths(selection: &Selection) -> Vec<String> {
411 selection
412 .rows
413 .iter()
414 .map(|r| r.path.display().to_string())
415 .collect()
416 }
417
418 /// An anchor by title resolves to the one index so titled, wherever it
419 /// sits — in either link notation — and the walk from there is the same
420 /// walk a path anchor gives. Two indexes with the title are a refusal with
421 /// the reason in it, and a title nothing carries reads as missing, like a
422 /// dead path.
423 #[test]
424 fn an_anchor_may_name_its_index_by_title() {
425 let dir = journal("title-anchor");
426 for under in ["[[Daily]]", "[Daily](Daily)"] {
427 let selection = block_on(select(&graph(&dir), &spec(Some(under), None), "index.md"))
428 .unwrap_or_else(|e| panic!("{under}: {e}"));
429 assert_eq!(
430 paths(&selection),
431 ["daily/07-24.md", "daily/08-01.md", "daily/2026.md"],
432 "{under}"
433 );
434 }
435 // The file stem is a name too, as it is for any nominal link.
436 let selection = block_on(select(
437 &graph(&dir),
438 &spec(Some("[[2026]]"), None),
439 "index.md",
440 ))
441 .unwrap();
442 assert_eq!(paths(&selection), ["daily/07-24.md", "daily/08-01.md"]);
443
444 let err = block_on(select(
445 &graph(&dir),
446 &spec(Some("[[Nowhere]]"), None),
447 "index.md",
448 ))
449 .unwrap_err()
450 .to_string();
451 assert!(err.contains("no document exists there"), "{err}");
452
453 write(
454 &dir,
455 "trips.md",
456 "---\ntitle: Daily\npart_of: index.md\n---\n",
457 );
458 let err = block_on(select(
459 &graph(&dir),
460 &spec(Some("[[Daily]]"), None),
461 "index.md",
462 ))
463 .unwrap_err()
464 .to_string();
465 assert!(
466 err.contains("several documents are titled `Daily`"),
467 "{err}"
468 );
469 }
470
471 /// The whole point of `under:`: the README carries a `created` date and is
472 /// still not selected, because it is not under `Daily`. And the anchor
473 /// itself is what the records hang under, not one of them.
474 #[test]
475 fn an_anchor_scopes_the_selection_to_its_subtree_and_excludes_itself() {
476 let dir = journal("scope");
477 let selection = block_on(select(
478 &graph(&dir),
479 &spec(Some("daily.md"), None),
480 "index.md",
481 ))
482 .expect("a selection");
483 assert_eq!(
484 paths(&selection),
485 ["daily/07-24.md", "daily/08-01.md", "daily/2026.md"]
486 );
487 }
488
489 /// Without an anchor the view is the whole workspace — the difference the
490 /// previous test isolated, in the other direction.
491 ///
492 /// The order is `Path`'s, which compares **component-wise**, not by bytes:
493 /// the component `daily` sorts before `daily.md`, so the directory's
494 /// contents precede the file beside it. Spelled out because it reads like a
495 /// bug otherwise.
496 #[test]
497 fn an_unscoped_view_covers_the_whole_workspace() {
498 let dir = journal("unscoped");
499 let selection =
500 block_on(select(&graph(&dir), &spec(None, None), "index.md")).expect("a selection");
501 assert_eq!(
502 paths(&selection),
503 [
504 "daily/07-24.md",
505 "daily/08-01.md",
506 "daily/2026.md",
507 "daily.md",
508 "index.md",
509 "readme.md",
510 ]
511 );
512 }
513
514 /// Scope follows the spanning links, so moving the whole subtree to a new
515 /// directory changes nothing. A `path starts-with "Daily/"` filter would
516 /// have returned an empty selection here.
517 #[test]
518 fn scope_survives_moving_the_subtree() {
519 let dir = journal("moved");
520 std::fs::rename(dir.join("daily"), dir.join("archive")).unwrap();
521 write(
522 &dir,
523 "daily.md",
524 "---\ntitle: Daily\npart_of: index.md\ncontents:\n- archive/2026.md\n---\n",
525 );
526 write(
527 &dir,
528 "archive/2026.md",
529 "---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
530 );
531
532 let selection = block_on(select(
533 &graph(&dir),
534 &spec(Some("daily.md"), None),
535 "index.md",
536 ))
537 .expect("a selection");
538 assert_eq!(
539 paths(&selection),
540 ["archive/07-24.md", "archive/08-01.md", "archive/2026.md"]
541 );
542 }
543
544 /// `where:` narrows what scope reached — and, unlike a broken anchor,
545 /// matching nothing is an ordinary answer rather than an error.
546 #[test]
547 fn a_where_condition_narrows_the_selection() {
548 let dir = journal("filter");
549 let no_drafts = Condition::Not(Box::new(Condition::Has("draft".into())));
550 let selection = block_on(select(
551 &graph(&dir),
552 &spec(Some("daily.md"), Some(no_drafts)),
553 "index.md",
554 ))
555 .expect("a selection");
556 assert_eq!(paths(&selection), ["daily/08-01.md", "daily/2026.md"]);
557
558 let matches_nothing = Condition::Has("nonexistent".into());
559 let empty = block_on(select(
560 &graph(&dir),
561 &spec(Some("daily.md"), Some(matches_nothing)),
562 "index.md",
563 ))
564 .expect("an empty selection is not an error");
565 assert!(empty.is_empty());
566 }
567
568 /// Rows carry their metadata, which is what lets grouping be a pure
569 /// function rather than a second pass over the disk.
570 #[test]
571 fn rows_carry_metadata_so_grouping_needs_no_second_read() {
572 let dir = journal("meta");
573 let spec = spec(Some("daily.md"), None);
574 let selection = block_on(select(&graph(&dir), &spec, "index.md")).expect("a selection");
575
576 let entry = selection
577 .rows
578 .iter()
579 .find(|r| r.path.ends_with("07-24.md"))
580 .expect("the entry");
581 assert_eq!(entry.title(), Some("July 24"));
582
583 // No graph, no filesystem, no async.
584 let rows = crate::group(&selection, &spec.group);
585 assert_eq!(rows.len(), 3, "documents, not placements");
586 assert_eq!(rows.groups.len(), 2);
587 }
588
589 /// An anchor that names nothing is an error, not an empty result. The two
590 /// look identical to a reader and mean opposite things.
591 #[test]
592 fn an_unresolvable_anchor_is_an_error_not_an_empty_selection() {
593 let dir = journal("dead-anchor");
594 // A path anchor always *resolves* — a path is a path — so this one is
595 // only caught by the walk failing to arrive.
596 let by_path = spec(Some("[Gone](nowhere.md)"), None);
597 let err = block_on(select(&graph(&dir), &by_path, "index.md")).unwrap_err();
598 let Error::AnchorUnresolved { under, why, .. } = &err else {
599 panic!("got {err:?}");
600 };
601 assert_eq!(under, "[Gone](nowhere.md)");
602 assert_eq!(why, "no document exists there");
603
604 let by_id = spec(Some("[Gone](id:abcd123)"), None);
605 let err = block_on(select(&graph(&dir), &by_id, "index.md")).unwrap_err();
606 let Error::AnchorUnresolved { view, under, .. } = &err else {
607 panic!("got {err:?}");
608 };
609 assert_eq!(view, "daily");
610 assert_eq!(under, "[Gone](id:abcd123)");
611 assert!(err.to_string().contains("is registered under the id"));
612 }
613
614 /// Selecting twice over an unchanged workspace produces the identical set —
615 /// the property that lets a consumer diff two runs.
616 #[test]
617 fn selection_is_deterministic() {
618 let dir = journal("stable");
619 let spec = spec(Some("daily.md"), None);
620 let g = graph(&dir);
621 let first = block_on(select(&g, &spec, "index.md")).unwrap();
622 let second = block_on(select(&g, &spec, "index.md")).unwrap();
623 assert_eq!(first, second);
624 }
625}