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::Foreign { workspace, .. } => Err(unresolved(&format!(
193            "the anchor names a document in the workspace `{workspace}`, which prov cannot see from here"
194        ))),
195    }
196}
197
198/// Why a walk did not arrive at a readable document, or `None` when it did.
199///
200/// The remaining [`NodeKind`]s cannot occur at the root of a walk — a cycle
201/// needs a trail behind it, and the id/alias/foreign kinds are how a *link*
202/// failed, which [`resolve_anchor`] has already had its say about — but they
203/// are spelled out rather than swept into a wildcard, so a new node kind
204/// arrives here as a compile error instead of as a silently empty view.
205fn unreached(kind: &NodeKind) -> Option<String> {
206    match kind {
207        NodeKind::Doc => None,
208        NodeKind::Missing => Some("no document exists there".to_string()),
209        NodeKind::Unreadable(why) => Some(format!("that document could not be read: {why}")),
210        NodeKind::Cycle => Some("that document contains itself".to_string()),
211        NodeKind::UnresolvedId(id) => Some(format!("no document is registered under `{}`", id.0)),
212        NodeKind::AmbiguousAlias(name) => Some(format!("several documents are titled `{name}`")),
213        NodeKind::Foreign { workspace, .. } => Some(format!(
214            "it names a document in the workspace `{workspace}`, which prov cannot see from here"
215        )),
216    }
217}
218
219/// Flatten the readable documents of a spanning tree into `out`.
220///
221/// `skip_root` drops the anchor itself: an index is what a scoped view's
222/// records hang *under*, not one of them. An unscoped view keeps its start,
223/// because there the start is the workspace root and there is nothing it would
224/// be an index *of*.
225///
226/// Every other [`NodeKind`] is skipped — a cycle marker, an unreadable file, an
227/// unresolved id and a foreign leaf are all things `check` reports on and a
228/// view has no row for.
229fn collect(node: &prov_graph::graph::Node, skip_root: bool, out: &mut Vec<PathBuf>) {
230    if !skip_root && matches!(node.kind, NodeKind::Doc) {
231        out.push(node.path.clone());
232    }
233    for child in &node.children {
234        collect(child, false, out);
235    }
236}
237
238// These tests use YAML frontmatter fixtures, so they run under the `yaml`
239// feature.
240#[cfg(all(test, feature = "yaml"))]
241mod tests {
242    use super::*;
243    use crate::filter::Condition;
244    use crate::spec::Grouping;
245    use prov_graph::exec::block_on;
246    use prov_graph::fs::StdFs;
247    use prov_graph::graph::ReadSettings;
248    use prov_graph::index::NoIndex;
249
250    fn write(dir: &Path, rel: &str, text: &str) {
251        let p = dir.join(rel);
252        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
253        std::fs::write(p, text).unwrap();
254    }
255
256    fn tempdir(tag: &str) -> PathBuf {
257        let dir = std::env::temp_dir().join(format!("prov-select-{tag}-{}", std::process::id()));
258        let _ = std::fs::remove_dir_all(&dir);
259        std::fs::create_dir_all(&dir).unwrap();
260        dir
261    }
262
263    /// A journal: a `Daily/` index with entries under it, plus a README beside
264    /// them that carries a `created` stamp and is *not* a daily entry. The
265    /// README is the reason a view needs scope at all.
266    fn journal(tag: &str) -> PathBuf {
267        let dir = tempdir(tag);
268        write(
269            &dir,
270            "index.md",
271            "---\ntitle: Home\ncontents:\n- daily.md\n- readme.md\n---\n",
272        );
273        write(
274            &dir,
275            "readme.md",
276            "---\ntitle: Readme\npart_of: index.md\ncreated: 2026-01-02\n---\n",
277        );
278        write(
279            &dir,
280            "daily.md",
281            "---\ntitle: Daily\npart_of: index.md\ncontents:\n- daily/2026.md\n---\n",
282        );
283        write(
284            &dir,
285            "daily/2026.md",
286            "---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
287        );
288        write(
289            &dir,
290            "daily/07-24.md",
291            "---\ntitle: July 24\npart_of: 2026.md\ndate_of_document: 2026-07-24\ndraft: true\n---\n",
292        );
293        write(
294            &dir,
295            "daily/08-01.md",
296            "---\ntitle: August 1\npart_of: 2026.md\ncreated: 2026-08-01T09:00:00Z\n---\n",
297        );
298        dir
299    }
300
301    fn graph(dir: &Path) -> Graph<StdFs, NoIndex> {
302        Graph::new(StdFs, dir, NoIndex, ReadSettings::default())
303    }
304
305    fn spec(under: Option<&str>, filter: Option<Condition>) -> ViewSpec {
306        ViewSpec {
307            name: "daily".into(),
308            label: None,
309            icon: None,
310            group: Grouping {
311                keys: vec!["date_of_document".into(), "created".into()],
312                by: None,
313            },
314            under: under.map(str::to_string),
315            filter,
316            nest: None,
317        }
318    }
319
320    fn paths(selection: &Selection) -> Vec<String> {
321        selection
322            .rows
323            .iter()
324            .map(|r| r.path.display().to_string())
325            .collect()
326    }
327
328    /// The whole point of `under:`: the README carries a `created` date and is
329    /// still not selected, because it is not under `Daily`. And the anchor
330    /// itself is what the records hang under, not one of them.
331    #[test]
332    fn an_anchor_scopes_the_selection_to_its_subtree_and_excludes_itself() {
333        let dir = journal("scope");
334        let selection = block_on(select(
335            &graph(&dir),
336            &spec(Some("daily.md"), None),
337            "index.md",
338        ))
339        .expect("a selection");
340        assert_eq!(
341            paths(&selection),
342            ["daily/07-24.md", "daily/08-01.md", "daily/2026.md"]
343        );
344    }
345
346    /// Without an anchor the view is the whole workspace — the difference the
347    /// previous test isolated, in the other direction.
348    ///
349    /// The order is `Path`'s, which compares **component-wise**, not by bytes:
350    /// the component `daily` sorts before `daily.md`, so the directory's
351    /// contents precede the file beside it. Spelled out because it reads like a
352    /// bug otherwise.
353    #[test]
354    fn an_unscoped_view_covers_the_whole_workspace() {
355        let dir = journal("unscoped");
356        let selection =
357            block_on(select(&graph(&dir), &spec(None, None), "index.md")).expect("a selection");
358        assert_eq!(
359            paths(&selection),
360            [
361                "daily/07-24.md",
362                "daily/08-01.md",
363                "daily/2026.md",
364                "daily.md",
365                "index.md",
366                "readme.md",
367            ]
368        );
369    }
370
371    /// Scope follows the spanning links, so moving the whole subtree to a new
372    /// directory changes nothing. A `path starts-with "Daily/"` filter would
373    /// have returned an empty selection here.
374    #[test]
375    fn scope_survives_moving_the_subtree() {
376        let dir = journal("moved");
377        std::fs::rename(dir.join("daily"), dir.join("archive")).unwrap();
378        write(
379            &dir,
380            "daily.md",
381            "---\ntitle: Daily\npart_of: index.md\ncontents:\n- archive/2026.md\n---\n",
382        );
383        write(
384            &dir,
385            "archive/2026.md",
386            "---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
387        );
388
389        let selection = block_on(select(
390            &graph(&dir),
391            &spec(Some("daily.md"), None),
392            "index.md",
393        ))
394        .expect("a selection");
395        assert_eq!(
396            paths(&selection),
397            ["archive/07-24.md", "archive/08-01.md", "archive/2026.md"]
398        );
399    }
400
401    /// `where:` narrows what scope reached — and, unlike a broken anchor,
402    /// matching nothing is an ordinary answer rather than an error.
403    #[test]
404    fn a_where_condition_narrows_the_selection() {
405        let dir = journal("filter");
406        let no_drafts = Condition::Not(Box::new(Condition::Has("draft".into())));
407        let selection = block_on(select(
408            &graph(&dir),
409            &spec(Some("daily.md"), Some(no_drafts)),
410            "index.md",
411        ))
412        .expect("a selection");
413        assert_eq!(paths(&selection), ["daily/08-01.md", "daily/2026.md"]);
414
415        let matches_nothing = Condition::Has("nonexistent".into());
416        let empty = block_on(select(
417            &graph(&dir),
418            &spec(Some("daily.md"), Some(matches_nothing)),
419            "index.md",
420        ))
421        .expect("an empty selection is not an error");
422        assert!(empty.is_empty());
423    }
424
425    /// Rows carry their metadata, which is what lets grouping be a pure
426    /// function rather than a second pass over the disk.
427    #[test]
428    fn rows_carry_metadata_so_grouping_needs_no_second_read() {
429        let dir = journal("meta");
430        let spec = spec(Some("daily.md"), None);
431        let selection = block_on(select(&graph(&dir), &spec, "index.md")).expect("a selection");
432
433        let entry = selection
434            .rows
435            .iter()
436            .find(|r| r.path.ends_with("07-24.md"))
437            .expect("the entry");
438        assert_eq!(entry.title(), Some("July 24"));
439
440        // No graph, no filesystem, no async.
441        let rows = crate::group(&selection, &spec.group);
442        assert_eq!(rows.len(), 3, "documents, not placements");
443        assert_eq!(rows.groups.len(), 2);
444    }
445
446    /// An anchor that names nothing is an error, not an empty result. The two
447    /// look identical to a reader and mean opposite things.
448    #[test]
449    fn an_unresolvable_anchor_is_an_error_not_an_empty_selection() {
450        let dir = journal("dead-anchor");
451        // A path anchor always *resolves* — a path is a path — so this one is
452        // only caught by the walk failing to arrive.
453        let by_path = spec(Some("[Gone](nowhere.md)"), None);
454        let err = block_on(select(&graph(&dir), &by_path, "index.md")).unwrap_err();
455        let Error::AnchorUnresolved { under, why, .. } = &err else {
456            panic!("got {err:?}");
457        };
458        assert_eq!(under, "[Gone](nowhere.md)");
459        assert_eq!(why, "no document exists there");
460
461        let by_id = spec(Some("[Gone](id:abcd123)"), None);
462        let err = block_on(select(&graph(&dir), &by_id, "index.md")).unwrap_err();
463        let Error::AnchorUnresolved { view, under, .. } = &err else {
464            panic!("got {err:?}");
465        };
466        assert_eq!(view, "daily");
467        assert_eq!(under, "[Gone](id:abcd123)");
468        assert!(err.to_string().contains("is registered under the id"));
469    }
470
471    /// Selecting twice over an unchanged workspace produces the identical set —
472    /// the property that lets a consumer diff two runs.
473    #[test]
474    fn selection_is_deterministic() {
475        let dir = journal("stable");
476        let spec = spec(Some("daily.md"), None);
477        let g = graph(&dir);
478        let first = block_on(select(&g, &spec, "index.md")).unwrap();
479        let second = block_on(select(&g, &spec, "index.md")).unwrap();
480        assert_eq!(first, second);
481    }
482}