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/// 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}