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