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}