Skip to main content

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}