Skip to main content

prov_views/
spec.rs

1//! The view format: what a workspace declares under `views.<name>`.
2//!
3//! # Why a view is not a field declaration
4//!
5//! A declared field (`fields.<name>`) already makes a lens: the workspace says
6//! it files things by `people`, so a frontend groups by `people`. That covers a
7//! lens whose groups *are* one field's values, over the whole corpus.
8//!
9//! It cannot express the four things a real archive needs. **Scope**: a lens
10//! over every file in the workspace buries the entries among the notes, drafts
11//! and READMEs that happen to carry the same field. **Grain**: "by year" is a
12//! rule about how a value becomes a group, and a field declaration has nowhere
13//! to put it. **Fallback**: the value worth grouping on is often the first of
14//! several fields that is filled in. **Conditions**: not everything in scope
15//! belongs in every lens (see [`crate::filter`]).
16//!
17//! So a view is its own declaration:
18//!
19//! ```yaml
20//! views:
21//!   daily:
22//!     label: Daily
23//!     icon: calendar
24//!     group: [date_of_document, created, updated]
25//!     by: month
26//!     under: '[Daily](/Daily/daily_index.md)'
27//!     where:
28//!       not: { has: draft }
29//!     nest: month
30//! ```
31//!
32//! # There is no `date` grouping
33//!
34//! An earlier form of this format spelled the above `group: date`, a token that
35//! meant "the date chain" — and the chain itself (`date_of_document` →
36//! `created` → `updated`) was hardcoded in whichever program was reading. Three
37//! field names no workspace had agreed to, blessed by the tool.
38//!
39//! Here [`Grouping`] is one shape: an ordered list of field keys, first
40//! non-empty wins, optionally [cut](Grain) at a grain. A date view is that
41//! shape with date fields in it, and nothing in this crate knows the word
42//! "date" — the chain above is a *declaration a workspace writes*, which is
43//! what makes it reviewable, diffable, and different for a workspace that files
44//! by `taken_on` or `received`.
45//!
46//! A [`Grain`] is not a calendar either — it is any coarsening (see
47//! [`Grain::cut`]), and the date grains are one family beside
48//! [`Initial`](Grain::Initial)'s A–Z index. It applies to a *value*, never to a
49//! declared type, so it works on the `2026-07-24` that YAML hands back as a
50//! string without this crate resolving the workspace's `fields.<name>.type`
51//! declarations. A value the grain cannot cut does not group at all, rather
52//! than grouping wrongly.
53//!
54//! # Classification is not aggregation
55//!
56//! The remaining shape is [MoReq2010]'s, not an invention. ISO 15489 calls
57//! *classification* the identification of a record by the context that produced
58//! it; MoReq2010 §1.4.5 separates that from *aggregation*, "the activity of
59//! assembling related records together", which "may be based on any
60//! organisational requirement or criteria, not business context alone". It
61//! permits conjoining the two into one hierarchy and warns what happens when
62//! you do: schemes hybridize, and naturally occurring aggregations get split
63//! apart to fit the classification.
64//!
65//! That maps onto this struct exactly:
66//!
67//! - [`Grouping`] is classification — how records become groups.
68//! - [`ViewSpec::under`] is aggregation — the index the records actually hang
69//!   under, resolved through the spanning relation rather than by matching a
70//!   path or a title, so it survives a rename, a move and a retitle.
71//! - [`ViewSpec::nest`] is the *deliberate* seam between them. It is not
72//!   derived from [`Grouping::by`], because a lens must never become a reason
73//!   to move a file: changing how a view groups is a reading decision, and it
74//!   would be a poor bargain if a picker that reads like a display setting
75//!   silently changed where tomorrow's entry lands.
76//!
77//! # Inheritance and override
78//!
79//! `under:` is inherited: a view covers the whole subtree below its anchor, not
80//! just the anchor's direct children. This is MoReq2010 §201.2.3 — a class
81//! applied at a root aggregation "is inherited as the default classification
82//! for all descendants". §201.2.4 then allows a class applied directly to a
83//! child to break that chain, which is what keeps aggregations from having to
84//! be homogeneous. That override is a document-level concern and is not part of
85//! this struct; the scope walk in [`select`](fn@crate::select) is the inheritance half.
86//!
87//! [MoReq2010]: https://moreq.info/files/moreq2010_vol1_v1_1_en.pdf
88
89use prov_graph::meta::{Mapping, Value};
90
91use crate::filter::Condition;
92
93/// The config block views are declared in — a top-level axis, so every prov
94/// tool reads the same views rather than each app namespacing its own.
95pub const VIEWS_KEY: &str = "views";
96
97/// The keys valid inside one `views.<name>` entry.
98pub const VIEW_KEYS: &[&str] = &["label", "icon", "group", "by", "under", "nest", "where"];
99
100/// A **coarsening**: how finely a value is cut into groups.
101///
102/// Not a date vocabulary. A grain is any many-to-one function from a value to a
103/// group key, and the calendar grains are one family of them — `year` is
104/// "the first four characters, if they are a year", and [`Initial`](Self::Initial)
105/// is "the first *n* characters" with no such condition. What makes something a
106/// grain is the two properties below, not what it is about.
107///
108/// # Two properties, and what each one licenses
109///
110/// - [`cut`](Self::cut) — value → key. This is all [`by`](Grouping::by) needs,
111///   because grouping is a *reading* operation with no invariant to keep.
112/// - [`chain`](Self::chain) — the coarser grains this one refines, coarsest
113///   first. This is what [`nest`](ViewSpec::nest) needs, and it is a strictly
114///   stronger requirement: nesting builds a hierarchy of index documents, so
115///   each level's key must be determined by the finer level's
116///   (`2026-07-24` → `2026-07` → `2026`, `Ada` → `Ad` → `A`). A coarsening
117///   with no such chain can group but cannot nest.
118///
119/// The second constraint is prov's, not taste. `nest` files a record into the
120/// **spanning relation**, which is single-parent, so a nest chain must also be
121/// *single-valued* per document — see [`ViewSpec::nest_route`], which returns
122/// `None` rather than guessing which of a multi-valued field's values a
123/// document should be filed under.
124///
125/// # Adding a grain
126///
127/// The rule is the one [`crate::filter`] uses for predicates: a **concrete lens
128/// that cannot otherwise be said**, not a shape that seems likely to be wanted.
129/// `initial` earns its place as the A–Z index every list of names and places
130/// eventually wants. A numeric `bucket` (ratings by tens) is the obvious next
131/// one and is deliberately *not* here: nobody has asked for it, and it would
132/// arrive with a problem the calendar grains do not have — its keys sort
133/// lexically as `0, 10, 100, 20`, so it needs group ordering to become
134/// grain-aware, which is really the deferred `sort:` axis wearing a disguise.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
136pub enum Grain {
137    /// `2026` — the default, and what a lifetime of entries wants.
138    #[default]
139    Year,
140    /// `2026-07`.
141    Month,
142    /// `2026-07-25`.
143    Day,
144    /// The first *n* characters, upper-cased — the A–Z index.
145    ///
146    /// Upper-casing is a deliberate normalization rather than a faithful cut:
147    /// an alphabetical index that files `ada` apart from `Ada` is not an index.
148    /// It is the same kind of choice a date cut makes when it reports `2026`
149    /// for a value that says `2026-07-24`; a group key describes a bucket, not
150    /// a value that appears in the data.
151    Initial(usize),
152}
153
154/// The grain spellings that are a bare word — what a near-miss diagnostic
155/// offers. [`Grain::Initial`] also takes a parameterized form
156/// (`{ initial: 2 }`) that is not a spelling to suggest.
157pub const GRAINS: &[&str] = &["year", "month", "day", "initial"];
158
159impl Grain {
160    /// The config spelling, when this grain has a bare-word one.
161    ///
162    /// `None` for a parameterized grain that is not at its default — write
163    /// [`to_value`](Self::to_value) instead, which always round-trips.
164    pub fn as_config_str(self) -> Option<&'static str> {
165        Some(match self {
166            Grain::Year => "year",
167            Grain::Month => "month",
168            Grain::Day => "day",
169            Grain::Initial(1) => "initial",
170            Grain::Initial(_) => return None,
171        })
172    }
173
174    /// Parse a bare-word config spelling. Unknown text is **not** silently
175    /// defaulted — a `by: yearr` that quietly grouped by year would look
176    /// applied and be wrong, which is the failure a config linter exists to
177    /// prevent.
178    pub fn from_config_str(text: &str) -> Option<Self> {
179        match text.trim() {
180            "year" => Some(Grain::Year),
181            "month" => Some(Grain::Month),
182            "day" => Some(Grain::Day),
183            // The bare word is the useful case; `{ initial: n }` says the rest.
184            "initial" => Some(Grain::Initial(1)),
185            _ => None,
186        }
187    }
188
189    /// Read a `by:`/`nest:` value: a bare word, or a one-key mapping naming a
190    /// parameterized grain (`{ initial: 2 }`).
191    ///
192    /// A parameter of zero is rejected rather than clamped: `{ initial: 0 }`
193    /// would put every document in one group called "", which is a view that
194    /// has stopped being one.
195    pub fn parse(value: &Value) -> Option<Self> {
196        match value {
197            Value::String(text) => Grain::from_config_str(text),
198            Value::Mapping(map) => match map.iter().next() {
199                Some((key, arg)) if map.len() == 1 && key == "initial" => {
200                    let n = match arg {
201                        Value::Int(n) => *n,
202                        Value::String(s) => s.trim().parse().ok()?,
203                        _ => return None,
204                    };
205                    (n > 0).then_some(Grain::Initial(n as usize))
206                }
207                _ => None,
208            },
209            _ => None,
210        }
211    }
212
213    /// The value this grain writes back as — a bare word where it has one, a
214    /// one-key mapping otherwise.
215    pub fn to_value(self) -> Value {
216        match self.as_config_str() {
217            Some(word) => Value::String(word.into()),
218            None => {
219                let Grain::Initial(n) = self else {
220                    unreachable!("every non-parameterized grain has a bare spelling")
221                };
222                let mut map = Mapping::new();
223                map.insert("initial".into(), Value::Int(n as i64));
224                Value::Mapping(map)
225            }
226        }
227    }
228
229    /// How this grain reads in a listing (`month`, `initial 2`).
230    pub fn display(self) -> String {
231        match self {
232            Grain::Initial(n) if n > 1 => format!("initial {n}"),
233            other => other.as_config_str().unwrap_or("initial").to_string(),
234        }
235    }
236
237    /// The grains to nest through to reach `self`, coarsest first.
238    ///
239    /// Filing at month grain means a year index and then a month index inside
240    /// it: a month index that is not inside its year is not where anyone looks
241    /// for it. The alphabetical case is the same shape — filing at `initial 2`
242    /// means an `A` index holding an `Ad` index.
243    ///
244    /// Each step must be *determined* by the one after it, which is what makes
245    /// the hierarchy well defined. That is why this is a property of the grain
246    /// rather than something a caller can assemble: an arbitrary sequence of
247    /// coarsenings is not a nest.
248    pub fn chain(self) -> Vec<Grain> {
249        match self {
250            Grain::Year => vec![Grain::Year],
251            Grain::Month => vec![Grain::Year, Grain::Month],
252            Grain::Day => vec![Grain::Year, Grain::Month, Grain::Day],
253            Grain::Initial(n) => (1..=n).map(Grain::Initial).collect(),
254        }
255    }
256
257    /// How many characters of an ISO-8601 date a calendar grain keeps:
258    /// `2026-07-25` cut to 4, 7 or 10.
259    ///
260    /// The group key is a *prefix* because an ISO date sorts lexically, so the
261    /// group order falls out of the string with no calendar arithmetic and no
262    /// time zone to get wrong.
263    fn prefix_len(self) -> usize {
264        match self {
265            Grain::Year => 4,
266            Grain::Month => 7,
267            Grain::Day => 10,
268            Grain::Initial(n) => n,
269        }
270    }
271
272    /// Cut `value` to this grain, or `None` if the value does not reach it.
273    ///
274    /// The calendar grains *validate* rather than taking a blind prefix, which
275    /// is what keeps `by:` usable on a view whose field is only usually a date:
276    /// `banana` cut to a year would otherwise group under `bana`, a group key
277    /// that looks like data. A value this rejects falls to the ungrouped
278    /// bucket, where it is visible as something that did not sort.
279    ///
280    /// Anything after the cut is ignored, so an RFC 3339 instant
281    /// (`2026-07-24T07:32:00Z` — what a machine-maintained `updated` field
282    /// carries) cuts exactly like the plain date it starts with.
283    pub fn cut(self, value: &str) -> Option<String> {
284        let text = value.trim();
285        if let Grain::Initial(n) = self {
286            // By *character*, not byte: a name may begin with any of them, and
287            // slicing `Ålesund` at byte 1 is a panic. A value shorter than the
288            // cut is taken whole rather than rejected — `Bo` under a two-letter
289            // index belongs at `BO`, and there is no coarser truth to wait for.
290            let cut: String = text.chars().take(n).flat_map(char::to_uppercase).collect();
291            return (!cut.is_empty()).then_some(cut);
292        }
293        let bytes = text.as_bytes();
294        if bytes.len() < self.prefix_len() {
295            return None;
296        }
297        // `YYYY`, then `-MM` and `-DD` as the grain demands. Checked by byte
298        // because every character an ISO date is allowed to use is ASCII, so
299        // the prefix is a character boundary by construction.
300        let shape_ok = bytes[..4].iter().all(u8::is_ascii_digit)
301            && match self {
302                Grain::Month => bytes[4] == b'-' && bytes[5..7].iter().all(u8::is_ascii_digit),
303                Grain::Day => {
304                    bytes[4] == b'-'
305                        && bytes[5..7].iter().all(u8::is_ascii_digit)
306                        && bytes[7] == b'-'
307                        && bytes[8..10].iter().all(u8::is_ascii_digit)
308                }
309                _ => true,
310            };
311        // A year cut must not swallow the head of a longer number: `20264` is
312        // not the year 2026. Every other grain is already delimited by its `-`.
313        let bounded = match bytes.get(self.prefix_len()) {
314            Some(b) if self == Grain::Year => !b.is_ascii_digit(),
315            _ => true,
316        };
317        (shape_ok && bounded).then(|| text[..self.prefix_len()].to_string())
318    }
319}
320
321/// What a view sorts records by — MoReq2010's *classification*.
322///
323/// One shape, not a set of blessed kinds: an ordered chain of field keys, and
324/// an optional grain to cut the chosen value at. See the module docs for why
325/// there is no `date` variant.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct Grouping {
328    /// The field keys to read, in order — the first that carries a value wins,
329    /// and supplies *all* of that view's group keys for the document.
330    /// Guaranteed non-empty by [`ViewSpec::parse`].
331    pub keys: Vec<String>,
332    /// The grain the chosen value is cut at, or `None` to group on the value
333    /// itself.
334    pub by: Option<Grain>,
335}
336
337impl Grouping {
338    /// A view grouped on one field's raw values.
339    pub fn field(key: impl Into<String>) -> Self {
340        Grouping {
341            keys: vec![key.into()],
342            by: None,
343        }
344    }
345
346    /// The group keys `meta` falls under — empty when no field in the chain
347    /// carries a usable value, which is the ungrouped bucket.
348    ///
349    /// A sequence-valued field yields one key per element, so a letter about
350    /// two people appears under both. That is the whole point of a view: the
351    /// same document reached several ways, with retrieval decoupled from the
352    /// single containment spine.
353    ///
354    /// The chain stops at the first key that is *present and non-empty*, and
355    /// its values are used even if the grain rejects all of them. Falling
356    /// through to `created` because `date_of_document` held something
357    /// unparseable would silently file the document under a date it does not
358    /// claim; leaving it ungrouped shows the bad value instead.
359    pub fn keys_of(&self, meta: &Value) -> Vec<String> {
360        for key in &self.keys {
361            let Some(value) = meta.get(key) else { continue };
362            let raw = scalar_texts(value);
363            if raw.is_empty() {
364                continue;
365            }
366            return match self.by {
367                Some(grain) => raw.iter().filter_map(|t| grain.cut(t)).collect(),
368                None => raw,
369            };
370        }
371        Vec::new()
372    }
373
374    /// The `group:` value this writes back as: a bare string for a single key,
375    /// a list for a chain, so a one-field view reads as the small thing it is.
376    fn to_value(&self) -> Value {
377        match self.keys.as_slice() {
378            [only] => Value::String(only.clone()),
379            many => Value::Sequence(many.iter().cloned().map(Value::String).collect()),
380        }
381    }
382}
383
384/// The trimmed, non-empty text of a scalar, or of every scalar in a sequence.
385///
386/// A view groups on what a value *says*, so the numeric and boolean cases are
387/// rendered rather than skipped — a `rating: 5` groups under `5`. A mapping has
388/// no single text and is not groupable; a nested sequence is not flattened,
389/// because a list of lists is a shape no frontmatter field means to declare.
390pub(crate) fn scalar_texts(value: &Value) -> Vec<String> {
391    match value {
392        Value::Sequence(items) => items.iter().filter_map(scalar_text).collect(),
393        other => scalar_text(other).into_iter().collect(),
394    }
395}
396
397/// One scalar's trimmed text, or `None` for a null, an empty string, or a
398/// composite.
399fn scalar_text(value: &Value) -> Option<String> {
400    let text = match value {
401        Value::String(s) => s.trim().to_string(),
402        Value::Int(i) => i.to_string(),
403        Value::Float(f) => f.to_string(),
404        Value::Bool(b) => b.to_string(),
405        Value::Null | Value::Sequence(_) | Value::Mapping(_) => return None,
406    };
407    (!text.is_empty()).then_some(text)
408}
409
410/// One view a workspace declares for itself.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct ViewSpec {
413    /// The key under `views` — also the token that names this view to a
414    /// frontend, and the id it is addressed by.
415    pub name: String,
416    /// What a person calls it. Absent falls back to the name, humanized.
417    pub label: Option<String>,
418    /// A glyph hint for a frontend's lens picker. Uninterpreted here: what a
419    /// `calendar` looks like is the frontend's business.
420    pub icon: Option<String>,
421    /// Classification — how records become groups.
422    pub group: Grouping,
423    /// Aggregation — the index this view's records hang under, as a link
424    /// (`'[Daily](id:abc1234)'`). `None` scopes the view to the whole
425    /// workspace.
426    pub under: Option<String>,
427    /// The `where:` conditions a document in scope must also meet. `None`
428    /// takes everything scope reaches.
429    ///
430    /// Named `filter` because `where` is a Rust keyword; the config spelling is
431    /// `where`, which is what a reader of the format sees.
432    ///
433    /// Separate from [`under`](Self::under) because the two fail differently:
434    /// an anchor that names nothing is a broken view, while a condition that
435    /// matches nothing is an ordinary empty answer.
436    pub filter: Option<Condition>,
437    /// Materialization: when set, filing a new record through this view nests
438    /// it under an index at this grain below [`under`](Self::under), creating
439    /// the index if the calendar has turned. `None` files flat.
440    ///
441    /// Independent of [`Grouping::by`] on purpose — see the module docs.
442    pub nest: Option<Grain>,
443}
444
445impl ViewSpec {
446    /// Read one `views.<name>` entry.
447    ///
448    /// Returns `None` when the entry is not a mapping or names no groupable
449    /// field — an entry that does not say what it groups by is not a view, and
450    /// recording it as one would put a lens in the picker that groups nothing.
451    /// [`crate::diagnose_view`] is the half that says *why*, so a malformed
452    /// entry is reported rather than merely dropped.
453    pub fn parse(name: &str, value: &Value) -> Option<Self> {
454        let map = value.as_mapping()?;
455        let keys = group_keys(map.get("group"))?;
456        Some(ViewSpec {
457            name: name.to_string(),
458            label: non_empty(map.get("label")),
459            icon: non_empty(map.get("icon")),
460            group: Grouping {
461                keys,
462                by: map.get("by").and_then(Grain::parse),
463            },
464            under: non_empty(map.get("under")),
465            filter: map.get("where").and_then(Condition::parse),
466            nest: map.get("nest").and_then(Grain::parse),
467        })
468    }
469
470    /// The mapping this view writes back as. Absent options are omitted rather
471    /// than written empty, so a view declared from an app reads as the small
472    /// thing it is.
473    pub fn to_mapping(&self) -> Mapping {
474        let mut map = Mapping::new();
475        if let Some(label) = &self.label {
476            map.insert("label".into(), Value::String(label.clone()));
477        }
478        if let Some(icon) = &self.icon {
479            map.insert("icon".into(), Value::String(icon.clone()));
480        }
481        map.insert("group".into(), self.group.to_value());
482        if let Some(by) = self.group.by {
483            map.insert("by".into(), by.to_value());
484        }
485        if let Some(under) = &self.under {
486            map.insert("under".into(), Value::String(under.clone()));
487        }
488        if let Some(filter) = &self.filter {
489            map.insert("where".into(), filter.to_value());
490        }
491        if let Some(nest) = self.nest {
492            map.insert("nest".into(), nest.to_value());
493        }
494        map
495    }
496
497    /// The index titles a new record nests under, coarsest first — or `None`
498    /// when this view does not nest, or `meta` cannot be filed.
499    ///
500    /// For a date view at month grain this is `["2026", "2026-07"]`; for an
501    /// alphabetical one at `initial 2`, `["A", "AD"]`. Those are *titles*, which
502    /// is exactly what prov's route addressing takes (`prov new --under
503    /// "Daily/2026/2026-07" -p`), so a frontend that materializes a view hands
504    /// this straight to `plan_route` and never assembles a path itself.
505    ///
506    /// `None` in three cases, all of which mean *this record has no single home
507    /// under this view* rather than *nowhere*:
508    ///
509    /// - the view declares no [`nest`](Self::nest);
510    /// - no field in the grouping chain carries a usable value, so there is
511    ///   nothing to file by;
512    /// - the value is **multi-valued**. This is the constraint prov's spanning
513    ///   relation imposes: a document with two people cannot hang under two
514    ///   parents, and picking one would be inventing an answer the workspace
515    ///   did not give. Such a view groups perfectly well — it just cannot be
516    ///   materialized, which is why `nest` on a multi-valued field is a config
517    ///   finding rather than a runtime surprise.
518    pub fn nest_route(&self, meta: &Value) -> Option<Vec<String>> {
519        let nest = self.nest?;
520        // Read the chain *uncut*: `by:` is how this view reads, and reading must
521        // not decide where a file lands (the whole point of keeping the two
522        // keys apart). The value is then cut at each nesting grain instead.
523        let raw = Grouping {
524            keys: self.group.keys.clone(),
525            by: None,
526        };
527        let values = raw.keys_of(meta);
528        let [value] = values.as_slice() else {
529            return None;
530        };
531        let route: Vec<String> = nest
532            .chain()
533            .into_iter()
534            .filter_map(|grain| grain.cut(value))
535            .collect();
536        // A partial chain would file a July entry under `2026` and call it
537        // done, which is a different place from the one the view describes.
538        (route.len() == nest.chain().len()).then_some(route)
539    }
540
541    /// What a person calls this view: its label, else its name humanized
542    /// (`daily_entries` → `Daily entries`).
543    pub fn display_label(&self) -> String {
544        match &self.label {
545            Some(label) => label.clone(),
546            None => humanize(&self.name),
547        }
548    }
549}
550
551/// The field-key chain a `group:` value names — a bare string, or a list.
552///
553/// `None` when the value is absent, is neither of those shapes, or names no
554/// non-empty key. Empty entries are dropped rather than carried, so
555/// `group: [people, '']` is the one-key chain it plainly means.
556fn group_keys(value: Option<&Value>) -> Option<Vec<String>> {
557    let keys: Vec<String> = match value? {
558        Value::String(s) => s
559            .trim()
560            .is_empty()
561            .then(Vec::new)
562            .unwrap_or_else(|| vec![s.trim().to_string()]),
563        Value::Sequence(items) => items.iter().filter_map(|v| non_empty(Some(v))).collect(),
564        _ => return None,
565    };
566    (!keys.is_empty()).then_some(keys)
567}
568
569/// A trimmed non-empty string from a config value, or `None`.
570fn non_empty(value: Option<&Value>) -> Option<String> {
571    let text = value?.as_str()?.trim();
572    (!text.is_empty()).then(|| text.to_string())
573}
574
575/// `daily_entries` → `Daily entries`: a key is written for a file, a label for
576/// a person.
577pub fn humanize(key: &str) -> String {
578    let mut words = key.split(['_', '-']).filter(|w| !w.is_empty());
579    let Some(first) = words.next() else {
580        return key.to_string();
581    };
582    let mut out = first.to_string();
583    if let Some(c) = out.get_mut(0..1) {
584        c.make_ascii_uppercase();
585    }
586    for word in words {
587        out.push(' ');
588        out.push_str(&word.to_lowercase());
589    }
590    out
591}
592
593/// Read every `views.<name>` entry out of a config surface's `views:` block,
594/// in declaration order.
595pub fn views_from(config: &Mapping) -> Vec<ViewSpec> {
596    let Some(views) = config.get(VIEWS_KEY).and_then(Value::as_mapping) else {
597        return Vec::new();
598    };
599    views
600        .iter()
601        .filter_map(|(name, value)| ViewSpec::parse(name, value))
602        .collect()
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    fn mapping(pairs: &[(&str, Value)]) -> Value {
610        let mut map = Mapping::new();
611        for (k, v) in pairs {
612            map.insert((*k).into(), v.clone());
613        }
614        Value::Mapping(map)
615    }
616
617    fn text(pairs: &[(&str, &str)]) -> Value {
618        let owned: Vec<(&str, Value)> = pairs
619            .iter()
620            .map(|(k, v)| (*k, Value::String((*v).to_string())))
621            .collect();
622        mapping(&owned)
623    }
624
625    fn text_value(s: &str) -> Value {
626        Value::String(s.to_string())
627    }
628
629    fn seq(items: &[&str]) -> Value {
630        Value::Sequence(items.iter().map(|s| Value::String((*s).into())).collect())
631    }
632
633    /// The un-blessing, stated as a test: `date` is not a token. A view that
634    /// says `group: date` groups on a *field called `date`* like any other, so
635    /// nothing in this crate has to know the word.
636    #[test]
637    fn date_is_a_field_name_not_a_grouping_kind() {
638        let spec = ViewSpec::parse("daily", &text(&[("group", "date")])).expect("a view");
639        assert_eq!(spec.group, Grouping::field("date"));
640
641        let mut doc = Mapping::new();
642        doc.insert("date".into(), Value::String("2026-07-24".into()));
643        assert_eq!(spec.group.keys_of(&Value::Mapping(doc)), ["2026-07-24"]);
644    }
645
646    #[test]
647    fn a_chain_takes_the_first_field_that_carries_a_value() {
648        let spec = ViewSpec::parse(
649            "daily",
650            &mapping(&[
651                ("group", seq(&["date_of_document", "created", "updated"])),
652                ("by", Value::String("month".into())),
653            ]),
654        )
655        .expect("a view");
656
657        let mut doc = Mapping::new();
658        doc.insert("created".into(), Value::String("2026-07-24".into()));
659        doc.insert("updated".into(), Value::String("2020-01-01".into()));
660        assert_eq!(
661            spec.group.keys_of(&Value::Mapping(doc)),
662            ["2026-07"],
663            "created wins over updated; the grain cuts it"
664        );
665    }
666
667    /// A present-but-unparseable value does not fall through to the next field
668    /// in the chain. Filing the document under `created` because
669    /// `date_of_document` held junk would assert a date the document never
670    /// claimed.
671    #[test]
672    fn a_bad_value_does_not_fall_through_to_the_next_key() {
673        let spec = ViewSpec::parse(
674            "daily",
675            &mapping(&[
676                ("group", seq(&["date_of_document", "created"])),
677                ("by", Value::String("year".into())),
678            ]),
679        )
680        .expect("a view");
681
682        let mut doc = Mapping::new();
683        doc.insert("date_of_document".into(), Value::String("banana".into()));
684        doc.insert("created".into(), Value::String("2026-07-24".into()));
685        assert!(spec.group.keys_of(&Value::Mapping(doc)).is_empty());
686    }
687
688    /// One document, several groups — the property that makes a view different
689    /// from the spine.
690    #[test]
691    fn a_sequence_field_puts_one_document_in_several_groups() {
692        let spec = ViewSpec::parse("who", &text(&[("group", "people")])).expect("a view");
693        let mut doc = Mapping::new();
694        doc.insert("people".into(), seq(&["Ada", "Grace"]));
695        assert_eq!(spec.group.keys_of(&Value::Mapping(doc)), ["Ada", "Grace"]);
696    }
697
698    #[test]
699    fn a_document_with_nothing_in_the_chain_is_ungrouped() {
700        let spec = ViewSpec::parse("daily", &text(&[("group", "created")])).expect("a view");
701        assert!(
702            spec.group
703                .keys_of(&Value::Mapping(Mapping::new()))
704                .is_empty()
705        );
706        let mut blank = Mapping::new();
707        blank.insert("created".into(), Value::String("   ".into()));
708        assert!(spec.group.keys_of(&Value::Mapping(blank)).is_empty());
709    }
710
711    #[test]
712    fn a_grain_cuts_an_iso_date_and_an_rfc3339_instant_alike() {
713        assert_eq!(Grain::Year.cut("2026-07-24"), Some("2026".into()));
714        assert_eq!(Grain::Month.cut("2026-07-24"), Some("2026-07".into()));
715        assert_eq!(Grain::Day.cut("2026-07-24"), Some("2026-07-24".into()));
716        assert_eq!(
717            Grain::Month.cut("2026-07-24T07:32:00Z"),
718            Some("2026-07".into())
719        );
720        assert_eq!(Grain::Year.cut("  2026-07-24  "), Some("2026".into()));
721    }
722
723    /// The generalization, stated as a test: a grain is any coarsening, and the
724    /// A–Z index is one — same `by:` key, same `cut`, no calendar involved.
725    #[test]
726    fn an_initial_grain_cuts_the_alphabet_the_way_a_date_grain_cuts_a_year() {
727        assert_eq!(Grain::Initial(1).cut("Ada Lovelace"), Some("A".into()));
728        assert_eq!(Grain::Initial(2).cut("Ada Lovelace"), Some("AD".into()));
729        // Upper-cased on purpose: an index that files `ada` apart from `Ada` is
730        // not an index.
731        assert_eq!(Grain::Initial(1).cut("ada"), Some("A".into()));
732        // Shorter than the cut is taken whole — there is no coarser truth to
733        // wait for, unlike a half-written date.
734        assert_eq!(Grain::Initial(3).cut("Bo"), Some("BO".into()));
735        assert_eq!(Grain::Initial(1).cut("   "), None);
736    }
737
738    /// Cutting by character rather than byte: slicing a multi-byte name at
739    /// byte 1 would panic, and `Å` is one letter.
740    #[test]
741    fn an_initial_grain_cuts_characters_not_bytes() {
742        assert_eq!(Grain::Initial(1).cut("Ålesund"), Some("Å".into()));
743        assert_eq!(Grain::Initial(2).cut("Øland"), Some("ØL".into()));
744        assert_eq!(Grain::Initial(1).cut("東京"), Some("東".into()));
745    }
746
747    /// `chain` is what `nest` needs, and it generalizes with the grain: each
748    /// step must be determined by the one after it.
749    #[test]
750    fn every_grain_chains_coarsest_first() {
751        assert_eq!(Grain::Day.chain(), [Grain::Year, Grain::Month, Grain::Day]);
752        assert_eq!(Grain::Year.chain(), [Grain::Year]);
753        assert_eq!(
754            Grain::Initial(3).chain(),
755            [Grain::Initial(1), Grain::Initial(2), Grain::Initial(3)]
756        );
757    }
758
759    #[test]
760    fn a_parameterized_grain_parses_and_round_trips() {
761        let mut map = Mapping::new();
762        map.insert("initial".into(), Value::Int(2));
763        let parsed = Grain::parse(&Value::Mapping(map)).expect("a grain");
764        assert_eq!(parsed, Grain::Initial(2));
765        assert_eq!(Grain::parse(&parsed.to_value()), Some(parsed));
766
767        // The bare word is the one-character case, and writes back bare.
768        assert_eq!(
769            Grain::parse(&text_value("initial")),
770            Some(Grain::Initial(1))
771        );
772        assert_eq!(Grain::Initial(1).to_value(), text_value("initial"));
773        assert_eq!(Grain::parse(&text_value("month")), Some(Grain::Month));
774    }
775
776    /// A zero-width cut puts every document in one group called "", which is a
777    /// view that has stopped being one. Rejected rather than clamped, so the
778    /// linter reports it instead of it silently working.
779    #[test]
780    fn a_grain_with_a_useless_parameter_does_not_parse() {
781        let mut zero = Mapping::new();
782        zero.insert("initial".into(), Value::Int(0));
783        assert_eq!(Grain::parse(&Value::Mapping(zero)), None);
784
785        let mut unknown = Mapping::new();
786        unknown.insert("bucket".into(), Value::Int(10));
787        assert_eq!(Grain::parse(&Value::Mapping(unknown)), None);
788
789        let mut two = Mapping::new();
790        two.insert("initial".into(), Value::Int(1));
791        two.insert("month".into(), Value::Int(1));
792        assert_eq!(Grain::parse(&Value::Mapping(two)), None);
793    }
794
795    /// The reason the cut validates instead of slicing: `banana` must not
796    /// become the group `bana`, and `20264` must not become the year `2026`.
797    #[test]
798    fn a_grain_rejects_what_is_not_a_date_at_that_grain() {
799        assert_eq!(Grain::Year.cut("banana"), None);
800        assert_eq!(Grain::Year.cut("20264"), None);
801        assert_eq!(Grain::Day.cut("2026-07"), None);
802        assert_eq!(Grain::Month.cut("2026/07"), None);
803        assert_eq!(Grain::Month.cut(""), None);
804    }
805
806    /// The load-bearing separation: `by:` is classification, `nest:` is
807    /// aggregation, and reading one does not set the other. A view that grouped
808    /// by month would otherwise start filing next month's entry somewhere new.
809    #[test]
810    fn grain_does_not_imply_nesting() {
811        let spec = ViewSpec::parse("daily", &text(&[("group", "created"), ("by", "month")]))
812            .expect("a view");
813        assert_eq!(spec.group.by, Some(Grain::Month));
814        assert_eq!(spec.nest, None);
815
816        let materialized = ViewSpec::parse(
817            "daily",
818            &text(&[("group", "created"), ("by", "month"), ("nest", "year")]),
819        )
820        .expect("a view");
821        assert_eq!(
822            materialized.nest,
823            Some(Grain::Year),
824            "a view may group finer than it files"
825        );
826    }
827
828    #[test]
829    fn an_entry_without_a_grouping_is_not_a_view() {
830        assert!(ViewSpec::parse("x", &text(&[("label", "Nameless")])).is_none());
831        assert!(ViewSpec::parse("x", &text(&[("group", "  ")])).is_none());
832        assert!(ViewSpec::parse("x", &mapping(&[("group", seq(&[]))])).is_none());
833        assert!(ViewSpec::parse("x", &Value::String("created".into())).is_none());
834    }
835
836    /// A view that nests hands a frontend the index *titles* to file under —
837    /// which is exactly what prov's route addressing takes, so nothing
838    /// assembles a path.
839    #[test]
840    fn nest_route_gives_the_index_titles_to_file_under() {
841        let spec = ViewSpec::parse(
842            "daily",
843            &text(&[("group", "created"), ("by", "day"), ("nest", "month")]),
844        )
845        .expect("a view");
846
847        let mut doc = Mapping::new();
848        doc.insert("created".into(), Value::String("2026-07-24".into()));
849        assert_eq!(
850            spec.nest_route(&Value::Mapping(doc)),
851            Some(vec!["2026".to_string(), "2026-07".to_string()]),
852            "a month nest is a year index holding a month index"
853        );
854    }
855
856    /// The alphabetical case is the same machinery — the generalization, seen
857    /// from the filing side rather than the reading side.
858    #[test]
859    fn nest_route_generalizes_past_dates() {
860        let mut entry = Mapping::new();
861        entry.insert("group".into(), Value::String("surname".into()));
862        entry.insert("nest".into(), {
863            let mut g = Mapping::new();
864            g.insert("initial".into(), Value::Int(2));
865            Value::Mapping(g)
866        });
867        let spec = ViewSpec::parse("people", &Value::Mapping(entry)).expect("a view");
868
869        let mut doc = Mapping::new();
870        doc.insert("surname".into(), Value::String("Lovelace".into()));
871        assert_eq!(
872            spec.nest_route(&Value::Mapping(doc)),
873            Some(vec!["L".to_string(), "LO".to_string()])
874        );
875    }
876
877    /// The constraint prov's spine imposes: a document with two people cannot
878    /// hang under two parents, so it has no single home and this says so rather
879    /// than picking one.
880    #[test]
881    fn a_multi_valued_document_has_no_nest_route() {
882        let spec = ViewSpec::parse("who", &text(&[("group", "people"), ("nest", "initial")]))
883            .expect("a view");
884
885        let mut one = Mapping::new();
886        one.insert("people".into(), Value::String("Ada".into()));
887        assert_eq!(
888            spec.nest_route(&Value::Mapping(one)),
889            Some(vec!["A".to_string()]),
890            "one value files fine"
891        );
892
893        let mut two = Mapping::new();
894        two.insert("people".into(), seq(&["Ada", "Grace"]));
895        assert_eq!(
896            spec.nest_route(&Value::Mapping(two)),
897            None,
898            "two values are two homes, and prov's spine allows one"
899        );
900    }
901
902    /// Reading must not decide where a file lands: a view that groups by year
903    /// still nests by month if that is what it says, and the route is cut from
904    /// the *uncut* value.
905    #[test]
906    fn nest_route_ignores_how_the_view_reads() {
907        let spec = ViewSpec::parse(
908            "daily",
909            &text(&[("group", "created"), ("by", "year"), ("nest", "month")]),
910        )
911        .expect("a view");
912
913        let mut doc = Mapping::new();
914        doc.insert("created".into(), Value::String("2026-07-24".into()));
915        assert_eq!(
916            spec.nest_route(&Value::Mapping(doc)),
917            Some(vec!["2026".to_string(), "2026-07".to_string()]),
918            "grouped by year, filed by month — `by` never reaches the route"
919        );
920    }
921
922    #[test]
923    fn a_view_that_does_not_nest_or_cannot_file_has_no_route() {
924        let no_nest = ViewSpec::parse("daily", &text(&[("group", "created")])).expect("a view");
925        assert_eq!(no_nest.nest_route(&Value::Mapping(Mapping::new())), None);
926
927        let nests = ViewSpec::parse("daily", &text(&[("group", "created"), ("nest", "month")]))
928            .expect("a view");
929        assert_eq!(
930            nests.nest_route(&Value::Mapping(Mapping::new())),
931            None,
932            "nothing to file by"
933        );
934
935        // A value that reaches the year but not the month files nowhere rather
936        // than landing in `2026` and calling it done.
937        let mut partial = Mapping::new();
938        partial.insert("created".into(), Value::String("2026".into()));
939        assert_eq!(nests.nest_route(&Value::Mapping(partial)), None);
940    }
941
942    #[test]
943    fn a_view_round_trips_through_its_mapping() {
944        for group in [
945            Grouping {
946                keys: vec!["created".into()],
947                by: Some(Grain::Month),
948            },
949            Grouping {
950                keys: vec!["date_of_document".into(), "created".into()],
951                by: Some(Grain::Day),
952            },
953            Grouping::field("people"),
954        ] {
955            let spec = ViewSpec {
956                name: "daily".into(),
957                label: Some("Daily".into()),
958                icon: Some("calendar".into()),
959                group,
960                under: Some("[Daily](id:abc1234)".into()),
961                filter: Some(Condition::Not(Box::new(Condition::Has("draft".into())))),
962                nest: Some(Grain::Year),
963            };
964            let back =
965                ViewSpec::parse("daily", &Value::Mapping(spec.to_mapping())).expect("a view");
966            assert_eq!(back, spec);
967        }
968    }
969
970    /// A one-key chain writes back as a bare string, not a one-element list.
971    #[test]
972    fn a_single_key_group_serializes_unwrapped() {
973        let spec = ViewSpec {
974            name: "who".into(),
975            label: None,
976            icon: None,
977            group: Grouping::field("people"),
978            under: None,
979            filter: None,
980            nest: None,
981        };
982        assert_eq!(
983            spec.to_mapping().get("group"),
984            Some(&Value::String("people".into()))
985        );
986    }
987
988    #[test]
989    fn views_read_in_declaration_order() {
990        let mut views = Mapping::new();
991        views.insert("daily".into(), text(&[("group", "created")]));
992        views.insert("who".into(), text(&[("group", "people")]));
993        let mut config = Mapping::new();
994        config.insert(VIEWS_KEY.into(), Value::Mapping(views));
995
996        let specs = views_from(&config);
997        assert_eq!(
998            specs.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(),
999            ["daily", "who"]
1000        );
1001    }
1002
1003    #[test]
1004    fn a_label_falls_back_to_the_humanized_name() {
1005        let spec = ViewSpec::parse("daily_entries", &text(&[("group", "created")])).expect("view");
1006        assert_eq!(spec.display_label(), "Daily entries");
1007    }
1008
1009    #[test]
1010    fn a_non_string_scalar_groups_under_its_text() {
1011        let spec = ViewSpec::parse("stars", &text(&[("group", "rating")])).expect("a view");
1012        let mut doc = Mapping::new();
1013        doc.insert("rating".into(), Value::Int(5));
1014        assert_eq!(spec.group.keys_of(&Value::Mapping(doc)), ["5"]);
1015    }
1016}