prov-views 0.6.0

Declarative views over a prov workspace: the format, and the traversal that executes it
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
//! The view format: what a workspace declares under `views.<name>`.
//!
//! # Why a view is not a field declaration
//!
//! A declared field (`fields.<name>`) already makes a lens: the workspace says
//! it files things by `people`, so a frontend groups by `people`. That covers a
//! lens whose groups *are* one field's values, over the whole corpus.
//!
//! It cannot express the four things a real archive needs. **Scope**: a lens
//! over every file in the workspace buries the entries among the notes, drafts
//! and READMEs that happen to carry the same field. **Grain**: "by year" is a
//! rule about how a value becomes a group, and a field declaration has nowhere
//! to put it. **Fallback**: the value worth grouping on is often the first of
//! several fields that is filled in. **Conditions**: not everything in scope
//! belongs in every lens (see [`crate::filter`]).
//!
//! So a view is its own declaration:
//!
//! ```yaml
//! views:
//!   daily:
//!     label: Daily
//!     icon: calendar
//!     group: [date_of_document, created, updated]
//!     by: month
//!     under: '[Daily](/Daily/daily_index.md)'
//!     where:
//!       not: { has: draft }
//!     nest: month
//! ```
//!
//! # There is no `date` grouping
//!
//! An earlier form of this format spelled the above `group: date`, a token that
//! meant "the date chain" — and the chain itself (`date_of_document` →
//! `created` → `updated`) was hardcoded in whichever program was reading. Three
//! field names no workspace had agreed to, blessed by the tool.
//!
//! Here [`Grouping`] is one shape: an ordered list of field keys, first
//! non-empty wins, optionally [cut](Grain) at a grain. A date view is that
//! shape with date fields in it, and nothing in this crate knows the word
//! "date" — the chain above is a *declaration a workspace writes*, which is
//! what makes it reviewable, diffable, and different for a workspace that files
//! by `taken_on` or `received`.
//!
//! A [`Grain`] is not a calendar either — it is any coarsening (see
//! [`Grain::cut`]), and the date grains are one family beside
//! [`Initial`](Grain::Initial)'s A–Z index. It applies to a *value*, never to a
//! declared type, so it works on the `2026-07-24` that YAML hands back as a
//! string without this crate resolving the workspace's `fields.<name>.type`
//! declarations. A value the grain cannot cut does not group at all, rather
//! than grouping wrongly.
//!
//! # Classification is not aggregation
//!
//! The remaining shape is [MoReq2010]'s, not an invention. ISO 15489 calls
//! *classification* the identification of a record by the context that produced
//! it; MoReq2010 §1.4.5 separates that from *aggregation*, "the activity of
//! assembling related records together", which "may be based on any
//! organisational requirement or criteria, not business context alone". It
//! permits conjoining the two into one hierarchy and warns what happens when
//! you do: schemes hybridize, and naturally occurring aggregations get split
//! apart to fit the classification.
//!
//! That maps onto this struct exactly:
//!
//! - [`Grouping`] is classification — how records become groups.
//! - [`ViewSpec::under`] is aggregation — the index the records actually hang
//!   under, resolved through the spanning relation rather than by matching a
//!   path or a title, so it survives a rename, a move and a retitle.
//! - [`ViewSpec::nest`] is the *deliberate* seam between them. It is not
//!   derived from [`Grouping::by`], because a lens must never become a reason
//!   to move a file: changing how a view groups is a reading decision, and it
//!   would be a poor bargain if a picker that reads like a display setting
//!   silently changed where tomorrow's entry lands.
//!
//! # Inheritance and override
//!
//! `under:` is inherited: a view covers the whole subtree below its anchor, not
//! just the anchor's direct children. This is MoReq2010 §201.2.3 — a class
//! applied at a root aggregation "is inherited as the default classification
//! for all descendants". §201.2.4 then allows a class applied directly to a
//! child to break that chain, which is what keeps aggregations from having to
//! be homogeneous. That override is a document-level concern and is not part of
//! this struct; the scope walk in [`select`](fn@crate::select) is the inheritance half.
//!
//! [MoReq2010]: https://moreq.info/files/moreq2010_vol1_v1_1_en.pdf

use prov_graph::meta::{Mapping, Value};

use crate::filter::Condition;

/// The config block views are declared in — a top-level axis, so every prov
/// tool reads the same views rather than each app namespacing its own.
pub const VIEWS_KEY: &str = "views";

/// The keys valid inside one `views.<name>` entry.
pub const VIEW_KEYS: &[&str] = &["label", "icon", "group", "by", "under", "nest", "where"];

/// A **coarsening**: how finely a value is cut into groups.
///
/// Not a date vocabulary. A grain is any many-to-one function from a value to a
/// group key, and the calendar grains are one family of them — `year` is
/// "the first four characters, if they are a year", and [`Initial`](Self::Initial)
/// is "the first *n* characters" with no such condition. What makes something a
/// grain is the two properties below, not what it is about.
///
/// # Two properties, and what each one licenses
///
/// - [`cut`](Self::cut) — value → key. This is all [`by`](Grouping::by) needs,
///   because grouping is a *reading* operation with no invariant to keep.
/// - [`chain`](Self::chain) — the coarser grains this one refines, coarsest
///   first. This is what [`nest`](ViewSpec::nest) needs, and it is a strictly
///   stronger requirement: nesting builds a hierarchy of index documents, so
///   each level's key must be determined by the finer level's
///   (`2026-07-24` → `2026-07` → `2026`, `Ada` → `Ad` → `A`). A coarsening
///   with no such chain can group but cannot nest.
///
/// The second constraint is prov's, not taste. `nest` files a record into the
/// **spanning relation**, which is single-parent, so a nest chain must also be
/// *single-valued* per document — see [`ViewSpec::nest_route`], which returns
/// `None` rather than guessing which of a multi-valued field's values a
/// document should be filed under.
///
/// # Adding a grain
///
/// The rule is the one [`crate::filter`] uses for predicates: a **concrete lens
/// that cannot otherwise be said**, not a shape that seems likely to be wanted.
/// `initial` earns its place as the A–Z index every list of names and places
/// eventually wants. A numeric `bucket` (ratings by tens) is the obvious next
/// one and is deliberately *not* here: nobody has asked for it, and it would
/// arrive with a problem the calendar grains do not have — its keys sort
/// lexically as `0, 10, 100, 20`, so it needs group ordering to become
/// grain-aware, which is really the deferred `sort:` axis wearing a disguise.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Grain {
    /// `2026` — the default, and what a lifetime of entries wants.
    #[default]
    Year,
    /// `2026-07`.
    Month,
    /// `2026-07-25`.
    Day,
    /// The first *n* characters, upper-cased — the A–Z index.
    ///
    /// Upper-casing is a deliberate normalization rather than a faithful cut:
    /// an alphabetical index that files `ada` apart from `Ada` is not an index.
    /// It is the same kind of choice a date cut makes when it reports `2026`
    /// for a value that says `2026-07-24`; a group key describes a bucket, not
    /// a value that appears in the data.
    Initial(usize),
}

/// The grain spellings that are a bare word — what a near-miss diagnostic
/// offers. [`Grain::Initial`] also takes a parameterized form
/// (`{ initial: 2 }`) that is not a spelling to suggest.
pub const GRAINS: &[&str] = &["year", "month", "day", "initial"];

impl Grain {
    /// The config spelling, when this grain has a bare-word one.
    ///
    /// `None` for a parameterized grain that is not at its default — write
    /// [`to_value`](Self::to_value) instead, which always round-trips.
    pub fn as_config_str(self) -> Option<&'static str> {
        Some(match self {
            Grain::Year => "year",
            Grain::Month => "month",
            Grain::Day => "day",
            Grain::Initial(1) => "initial",
            Grain::Initial(_) => return None,
        })
    }

    /// Parse a bare-word config spelling. Unknown text is **not** silently
    /// defaulted — a `by: yearr` that quietly grouped by year would look
    /// applied and be wrong, which is the failure a config linter exists to
    /// prevent.
    pub fn from_config_str(text: &str) -> Option<Self> {
        match text.trim() {
            "year" => Some(Grain::Year),
            "month" => Some(Grain::Month),
            "day" => Some(Grain::Day),
            // The bare word is the useful case; `{ initial: n }` says the rest.
            "initial" => Some(Grain::Initial(1)),
            _ => None,
        }
    }

    /// Read a `by:`/`nest:` value: a bare word, or a one-key mapping naming a
    /// parameterized grain (`{ initial: 2 }`).
    ///
    /// A parameter of zero is rejected rather than clamped: `{ initial: 0 }`
    /// would put every document in one group called "", which is a view that
    /// has stopped being one.
    pub fn parse(value: &Value) -> Option<Self> {
        match value {
            Value::String(text) => Grain::from_config_str(text),
            Value::Mapping(map) => match map.iter().next() {
                Some((key, arg)) if map.len() == 1 && key == "initial" => {
                    let n = match arg {
                        Value::Int(n) => *n,
                        Value::String(s) => s.trim().parse().ok()?,
                        _ => return None,
                    };
                    (n > 0).then_some(Grain::Initial(n as usize))
                }
                _ => None,
            },
            _ => None,
        }
    }

    /// The value this grain writes back as — a bare word where it has one, a
    /// one-key mapping otherwise.
    pub fn to_value(self) -> Value {
        match self.as_config_str() {
            Some(word) => Value::String(word.into()),
            None => {
                let Grain::Initial(n) = self else {
                    unreachable!("every non-parameterized grain has a bare spelling")
                };
                let mut map = Mapping::new();
                map.insert("initial".into(), Value::Int(n as i64));
                Value::Mapping(map)
            }
        }
    }

    /// How this grain reads in a listing (`month`, `initial 2`).
    pub fn display(self) -> String {
        match self {
            Grain::Initial(n) if n > 1 => format!("initial {n}"),
            other => other.as_config_str().unwrap_or("initial").to_string(),
        }
    }

    /// The grains to nest through to reach `self`, coarsest first.
    ///
    /// Filing at month grain means a year index and then a month index inside
    /// it: a month index that is not inside its year is not where anyone looks
    /// for it. The alphabetical case is the same shape — filing at `initial 2`
    /// means an `A` index holding an `Ad` index.
    ///
    /// Each step must be *determined* by the one after it, which is what makes
    /// the hierarchy well defined. That is why this is a property of the grain
    /// rather than something a caller can assemble: an arbitrary sequence of
    /// coarsenings is not a nest.
    pub fn chain(self) -> Vec<Grain> {
        match self {
            Grain::Year => vec![Grain::Year],
            Grain::Month => vec![Grain::Year, Grain::Month],
            Grain::Day => vec![Grain::Year, Grain::Month, Grain::Day],
            Grain::Initial(n) => (1..=n).map(Grain::Initial).collect(),
        }
    }

    /// How many characters of an ISO-8601 date a calendar grain keeps:
    /// `2026-07-25` cut to 4, 7 or 10.
    ///
    /// The group key is a *prefix* because an ISO date sorts lexically, so the
    /// group order falls out of the string with no calendar arithmetic and no
    /// time zone to get wrong.
    fn prefix_len(self) -> usize {
        match self {
            Grain::Year => 4,
            Grain::Month => 7,
            Grain::Day => 10,
            Grain::Initial(n) => n,
        }
    }

    /// Cut `value` to this grain, or `None` if the value does not reach it.
    ///
    /// The calendar grains *validate* rather than taking a blind prefix, which
    /// is what keeps `by:` usable on a view whose field is only usually a date:
    /// `banana` cut to a year would otherwise group under `bana`, a group key
    /// that looks like data. A value this rejects falls to the ungrouped
    /// bucket, where it is visible as something that did not sort.
    ///
    /// Anything after the cut is ignored, so an RFC 3339 instant
    /// (`2026-07-24T07:32:00Z` — what a machine-maintained `updated` field
    /// carries) cuts exactly like the plain date it starts with.
    pub fn cut(self, value: &str) -> Option<String> {
        let text = value.trim();
        if let Grain::Initial(n) = self {
            // By *character*, not byte: a name may begin with any of them, and
            // slicing `Ålesund` at byte 1 is a panic. A value shorter than the
            // cut is taken whole rather than rejected — `Bo` under a two-letter
            // index belongs at `BO`, and there is no coarser truth to wait for.
            let cut: String = text.chars().take(n).flat_map(char::to_uppercase).collect();
            return (!cut.is_empty()).then_some(cut);
        }
        let bytes = text.as_bytes();
        if bytes.len() < self.prefix_len() {
            return None;
        }
        // `YYYY`, then `-MM` and `-DD` as the grain demands. Checked by byte
        // because every character an ISO date is allowed to use is ASCII, so
        // the prefix is a character boundary by construction.
        let shape_ok = bytes[..4].iter().all(u8::is_ascii_digit)
            && match self {
                Grain::Month => bytes[4] == b'-' && bytes[5..7].iter().all(u8::is_ascii_digit),
                Grain::Day => {
                    bytes[4] == b'-'
                        && bytes[5..7].iter().all(u8::is_ascii_digit)
                        && bytes[7] == b'-'
                        && bytes[8..10].iter().all(u8::is_ascii_digit)
                }
                _ => true,
            };
        // A year cut must not swallow the head of a longer number: `20264` is
        // not the year 2026. Every other grain is already delimited by its `-`.
        let bounded = match bytes.get(self.prefix_len()) {
            Some(b) if self == Grain::Year => !b.is_ascii_digit(),
            _ => true,
        };
        (shape_ok && bounded).then(|| text[..self.prefix_len()].to_string())
    }
}

/// What a view sorts records by — MoReq2010's *classification*.
///
/// One shape, not a set of blessed kinds: an ordered chain of field keys, and
/// an optional grain to cut the chosen value at. See the module docs for why
/// there is no `date` variant.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Grouping {
    /// The field keys to read, in order — the first that carries a value wins,
    /// and supplies *all* of that view's group keys for the document.
    /// Guaranteed non-empty by [`ViewSpec::parse`].
    pub keys: Vec<String>,
    /// The grain the chosen value is cut at, or `None` to group on the value
    /// itself.
    pub by: Option<Grain>,
}

impl Grouping {
    /// A view grouped on one field's raw values.
    pub fn field(key: impl Into<String>) -> Self {
        Grouping {
            keys: vec![key.into()],
            by: None,
        }
    }

    /// The group keys `meta` falls under — empty when no field in the chain
    /// carries a usable value, which is the ungrouped bucket.
    ///
    /// A sequence-valued field yields one key per element, so a letter about
    /// two people appears under both. That is the whole point of a view: the
    /// same document reached several ways, with retrieval decoupled from the
    /// single containment spine.
    ///
    /// The chain stops at the first key that is *present and non-empty*, and
    /// its values are used even if the grain rejects all of them. Falling
    /// through to `created` because `date_of_document` held something
    /// unparseable would silently file the document under a date it does not
    /// claim; leaving it ungrouped shows the bad value instead.
    pub fn keys_of(&self, meta: &Value) -> Vec<String> {
        for key in &self.keys {
            let Some(value) = meta.get(key) else { continue };
            let raw = scalar_texts(value);
            if raw.is_empty() {
                continue;
            }
            return match self.by {
                Some(grain) => raw.iter().filter_map(|t| grain.cut(t)).collect(),
                None => raw,
            };
        }
        Vec::new()
    }

    /// The `group:` value this writes back as: a bare string for a single key,
    /// a list for a chain, so a one-field view reads as the small thing it is.
    fn to_value(&self) -> Value {
        match self.keys.as_slice() {
            [only] => Value::String(only.clone()),
            many => Value::Sequence(many.iter().cloned().map(Value::String).collect()),
        }
    }
}

/// The trimmed, non-empty text of a scalar, or of every scalar in a sequence.
///
/// A view groups on what a value *says*, so the numeric and boolean cases are
/// rendered rather than skipped — a `rating: 5` groups under `5`. A mapping has
/// no single text and is not groupable; a nested sequence is not flattened,
/// because a list of lists is a shape no frontmatter field means to declare.
pub(crate) fn scalar_texts(value: &Value) -> Vec<String> {
    match value {
        Value::Sequence(items) => items.iter().filter_map(scalar_text).collect(),
        other => scalar_text(other).into_iter().collect(),
    }
}

/// One scalar's trimmed text, or `None` for a null, an empty string, or a
/// composite.
fn scalar_text(value: &Value) -> Option<String> {
    let text = match value {
        Value::String(s) => s.trim().to_string(),
        Value::Int(i) => i.to_string(),
        Value::Float(f) => f.to_string(),
        Value::Bool(b) => b.to_string(),
        Value::Null | Value::Sequence(_) | Value::Mapping(_) => return None,
    };
    (!text.is_empty()).then_some(text)
}

/// One view a workspace declares for itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewSpec {
    /// The key under `views` — also the token that names this view to a
    /// frontend, and the id it is addressed by.
    pub name: String,
    /// What a person calls it. Absent falls back to the name, humanized.
    pub label: Option<String>,
    /// A glyph hint for a frontend's lens picker. Uninterpreted here: what a
    /// `calendar` looks like is the frontend's business.
    pub icon: Option<String>,
    /// Classification — how records become groups.
    pub group: Grouping,
    /// Aggregation — the index this view's records hang under, as a link
    /// (`'[Daily](id:abc1234)'`). `None` scopes the view to the whole
    /// workspace.
    pub under: Option<String>,
    /// The `where:` conditions a document in scope must also meet. `None`
    /// takes everything scope reaches.
    ///
    /// Named `filter` because `where` is a Rust keyword; the config spelling is
    /// `where`, which is what a reader of the format sees.
    ///
    /// Separate from [`under`](Self::under) because the two fail differently:
    /// an anchor that names nothing is a broken view, while a condition that
    /// matches nothing is an ordinary empty answer.
    pub filter: Option<Condition>,
    /// Materialization: when set, filing a new record through this view nests
    /// it under an index at this grain below [`under`](Self::under), creating
    /// the index if the calendar has turned. `None` files flat.
    ///
    /// Independent of [`Grouping::by`] on purpose — see the module docs.
    pub nest: Option<Grain>,
}

impl ViewSpec {
    /// Read one `views.<name>` entry.
    ///
    /// Returns `None` when the entry is not a mapping or names no groupable
    /// field — an entry that does not say what it groups by is not a view, and
    /// recording it as one would put a lens in the picker that groups nothing.
    /// [`crate::diagnose_view`] is the half that says *why*, so a malformed
    /// entry is reported rather than merely dropped.
    pub fn parse(name: &str, value: &Value) -> Option<Self> {
        let map = value.as_mapping()?;
        let keys = group_keys(map.get("group"))?;
        Some(ViewSpec {
            name: name.to_string(),
            label: non_empty(map.get("label")),
            icon: non_empty(map.get("icon")),
            group: Grouping {
                keys,
                by: map.get("by").and_then(Grain::parse),
            },
            under: non_empty(map.get("under")),
            filter: map.get("where").and_then(Condition::parse),
            nest: map.get("nest").and_then(Grain::parse),
        })
    }

    /// The mapping this view writes back as. Absent options are omitted rather
    /// than written empty, so a view declared from an app reads as the small
    /// thing it is.
    pub fn to_mapping(&self) -> Mapping {
        let mut map = Mapping::new();
        if let Some(label) = &self.label {
            map.insert("label".into(), Value::String(label.clone()));
        }
        if let Some(icon) = &self.icon {
            map.insert("icon".into(), Value::String(icon.clone()));
        }
        map.insert("group".into(), self.group.to_value());
        if let Some(by) = self.group.by {
            map.insert("by".into(), by.to_value());
        }
        if let Some(under) = &self.under {
            map.insert("under".into(), Value::String(under.clone()));
        }
        if let Some(filter) = &self.filter {
            map.insert("where".into(), filter.to_value());
        }
        if let Some(nest) = self.nest {
            map.insert("nest".into(), nest.to_value());
        }
        map
    }

    /// The index titles a new record nests under, coarsest first — or `None`
    /// when this view does not nest, or `meta` cannot be filed.
    ///
    /// For a date view at month grain this is `["2026", "2026-07"]`; for an
    /// alphabetical one at `initial 2`, `["A", "AD"]`. Those are *titles*, which
    /// is exactly what prov's route addressing takes (`prov new --under
    /// "Daily/2026/2026-07" -p`), so a frontend that materializes a view hands
    /// this straight to `plan_route` and never assembles a path itself.
    ///
    /// `None` in three cases, all of which mean *this record has no single home
    /// under this view* rather than *nowhere*:
    ///
    /// - the view declares no [`nest`](Self::nest);
    /// - no field in the grouping chain carries a usable value, so there is
    ///   nothing to file by;
    /// - the value is **multi-valued**. This is the constraint prov's spanning
    ///   relation imposes: a document with two people cannot hang under two
    ///   parents, and picking one would be inventing an answer the workspace
    ///   did not give. Such a view groups perfectly well — it just cannot be
    ///   materialized, which is why `nest` on a multi-valued field is a config
    ///   finding rather than a runtime surprise.
    pub fn nest_route(&self, meta: &Value) -> Option<Vec<String>> {
        let nest = self.nest?;
        // Read the chain *uncut*: `by:` is how this view reads, and reading must
        // not decide where a file lands (the whole point of keeping the two
        // keys apart). The value is then cut at each nesting grain instead.
        let raw = Grouping {
            keys: self.group.keys.clone(),
            by: None,
        };
        let values = raw.keys_of(meta);
        let [value] = values.as_slice() else {
            return None;
        };
        let route: Vec<String> = nest
            .chain()
            .into_iter()
            .filter_map(|grain| grain.cut(value))
            .collect();
        // A partial chain would file a July entry under `2026` and call it
        // done, which is a different place from the one the view describes.
        (route.len() == nest.chain().len()).then_some(route)
    }

    /// What a person calls this view: its label, else its name humanized
    /// (`daily_entries` → `Daily entries`).
    pub fn display_label(&self) -> String {
        match &self.label {
            Some(label) => label.clone(),
            None => humanize(&self.name),
        }
    }
}

/// The field-key chain a `group:` value names — a bare string, or a list.
///
/// `None` when the value is absent, is neither of those shapes, or names no
/// non-empty key. Empty entries are dropped rather than carried, so
/// `group: [people, '']` is the one-key chain it plainly means.
fn group_keys(value: Option<&Value>) -> Option<Vec<String>> {
    let keys: Vec<String> = match value? {
        Value::String(s) => s
            .trim()
            .is_empty()
            .then(Vec::new)
            .unwrap_or_else(|| vec![s.trim().to_string()]),
        Value::Sequence(items) => items.iter().filter_map(|v| non_empty(Some(v))).collect(),
        _ => return None,
    };
    (!keys.is_empty()).then_some(keys)
}

/// A trimmed non-empty string from a config value, or `None`.
fn non_empty(value: Option<&Value>) -> Option<String> {
    let text = value?.as_str()?.trim();
    (!text.is_empty()).then(|| text.to_string())
}

/// `daily_entries` → `Daily entries`: a key is written for a file, a label for
/// a person.
pub fn humanize(key: &str) -> String {
    let mut words = key.split(['_', '-']).filter(|w| !w.is_empty());
    let Some(first) = words.next() else {
        return key.to_string();
    };
    let mut out = first.to_string();
    if let Some(c) = out.get_mut(0..1) {
        c.make_ascii_uppercase();
    }
    for word in words {
        out.push(' ');
        out.push_str(&word.to_lowercase());
    }
    out
}

/// Read every `views.<name>` entry out of a config surface's `views:` block,
/// in declaration order.
pub fn views_from(config: &Mapping) -> Vec<ViewSpec> {
    let Some(views) = config.get(VIEWS_KEY).and_then(Value::as_mapping) else {
        return Vec::new();
    };
    views
        .iter()
        .filter_map(|(name, value)| ViewSpec::parse(name, value))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn mapping(pairs: &[(&str, Value)]) -> Value {
        let mut map = Mapping::new();
        for (k, v) in pairs {
            map.insert((*k).into(), v.clone());
        }
        Value::Mapping(map)
    }

    fn text(pairs: &[(&str, &str)]) -> Value {
        let owned: Vec<(&str, Value)> = pairs
            .iter()
            .map(|(k, v)| (*k, Value::String((*v).to_string())))
            .collect();
        mapping(&owned)
    }

    fn text_value(s: &str) -> Value {
        Value::String(s.to_string())
    }

    fn seq(items: &[&str]) -> Value {
        Value::Sequence(items.iter().map(|s| Value::String((*s).into())).collect())
    }

    /// The un-blessing, stated as a test: `date` is not a token. A view that
    /// says `group: date` groups on a *field called `date`* like any other, so
    /// nothing in this crate has to know the word.
    #[test]
    fn date_is_a_field_name_not_a_grouping_kind() {
        let spec = ViewSpec::parse("daily", &text(&[("group", "date")])).expect("a view");
        assert_eq!(spec.group, Grouping::field("date"));

        let mut doc = Mapping::new();
        doc.insert("date".into(), Value::String("2026-07-24".into()));
        assert_eq!(spec.group.keys_of(&Value::Mapping(doc)), ["2026-07-24"]);
    }

    #[test]
    fn a_chain_takes_the_first_field_that_carries_a_value() {
        let spec = ViewSpec::parse(
            "daily",
            &mapping(&[
                ("group", seq(&["date_of_document", "created", "updated"])),
                ("by", Value::String("month".into())),
            ]),
        )
        .expect("a view");

        let mut doc = Mapping::new();
        doc.insert("created".into(), Value::String("2026-07-24".into()));
        doc.insert("updated".into(), Value::String("2020-01-01".into()));
        assert_eq!(
            spec.group.keys_of(&Value::Mapping(doc)),
            ["2026-07"],
            "created wins over updated; the grain cuts it"
        );
    }

    /// A present-but-unparseable value does not fall through to the next field
    /// in the chain. Filing the document under `created` because
    /// `date_of_document` held junk would assert a date the document never
    /// claimed.
    #[test]
    fn a_bad_value_does_not_fall_through_to_the_next_key() {
        let spec = ViewSpec::parse(
            "daily",
            &mapping(&[
                ("group", seq(&["date_of_document", "created"])),
                ("by", Value::String("year".into())),
            ]),
        )
        .expect("a view");

        let mut doc = Mapping::new();
        doc.insert("date_of_document".into(), Value::String("banana".into()));
        doc.insert("created".into(), Value::String("2026-07-24".into()));
        assert!(spec.group.keys_of(&Value::Mapping(doc)).is_empty());
    }

    /// One document, several groups — the property that makes a view different
    /// from the spine.
    #[test]
    fn a_sequence_field_puts_one_document_in_several_groups() {
        let spec = ViewSpec::parse("who", &text(&[("group", "people")])).expect("a view");
        let mut doc = Mapping::new();
        doc.insert("people".into(), seq(&["Ada", "Grace"]));
        assert_eq!(spec.group.keys_of(&Value::Mapping(doc)), ["Ada", "Grace"]);
    }

    #[test]
    fn a_document_with_nothing_in_the_chain_is_ungrouped() {
        let spec = ViewSpec::parse("daily", &text(&[("group", "created")])).expect("a view");
        assert!(
            spec.group
                .keys_of(&Value::Mapping(Mapping::new()))
                .is_empty()
        );
        let mut blank = Mapping::new();
        blank.insert("created".into(), Value::String("   ".into()));
        assert!(spec.group.keys_of(&Value::Mapping(blank)).is_empty());
    }

    #[test]
    fn a_grain_cuts_an_iso_date_and_an_rfc3339_instant_alike() {
        assert_eq!(Grain::Year.cut("2026-07-24"), Some("2026".into()));
        assert_eq!(Grain::Month.cut("2026-07-24"), Some("2026-07".into()));
        assert_eq!(Grain::Day.cut("2026-07-24"), Some("2026-07-24".into()));
        assert_eq!(
            Grain::Month.cut("2026-07-24T07:32:00Z"),
            Some("2026-07".into())
        );
        assert_eq!(Grain::Year.cut("  2026-07-24  "), Some("2026".into()));
    }

    /// The generalization, stated as a test: a grain is any coarsening, and the
    /// A–Z index is one — same `by:` key, same `cut`, no calendar involved.
    #[test]
    fn an_initial_grain_cuts_the_alphabet_the_way_a_date_grain_cuts_a_year() {
        assert_eq!(Grain::Initial(1).cut("Ada Lovelace"), Some("A".into()));
        assert_eq!(Grain::Initial(2).cut("Ada Lovelace"), Some("AD".into()));
        // Upper-cased on purpose: an index that files `ada` apart from `Ada` is
        // not an index.
        assert_eq!(Grain::Initial(1).cut("ada"), Some("A".into()));
        // Shorter than the cut is taken whole — there is no coarser truth to
        // wait for, unlike a half-written date.
        assert_eq!(Grain::Initial(3).cut("Bo"), Some("BO".into()));
        assert_eq!(Grain::Initial(1).cut("   "), None);
    }

    /// Cutting by character rather than byte: slicing a multi-byte name at
    /// byte 1 would panic, and `Å` is one letter.
    #[test]
    fn an_initial_grain_cuts_characters_not_bytes() {
        assert_eq!(Grain::Initial(1).cut("Ålesund"), Some("Å".into()));
        assert_eq!(Grain::Initial(2).cut("Øland"), Some("ØL".into()));
        assert_eq!(Grain::Initial(1).cut("東京"), Some("".into()));
    }

    /// `chain` is what `nest` needs, and it generalizes with the grain: each
    /// step must be determined by the one after it.
    #[test]
    fn every_grain_chains_coarsest_first() {
        assert_eq!(Grain::Day.chain(), [Grain::Year, Grain::Month, Grain::Day]);
        assert_eq!(Grain::Year.chain(), [Grain::Year]);
        assert_eq!(
            Grain::Initial(3).chain(),
            [Grain::Initial(1), Grain::Initial(2), Grain::Initial(3)]
        );
    }

    #[test]
    fn a_parameterized_grain_parses_and_round_trips() {
        let mut map = Mapping::new();
        map.insert("initial".into(), Value::Int(2));
        let parsed = Grain::parse(&Value::Mapping(map)).expect("a grain");
        assert_eq!(parsed, Grain::Initial(2));
        assert_eq!(Grain::parse(&parsed.to_value()), Some(parsed));

        // The bare word is the one-character case, and writes back bare.
        assert_eq!(
            Grain::parse(&text_value("initial")),
            Some(Grain::Initial(1))
        );
        assert_eq!(Grain::Initial(1).to_value(), text_value("initial"));
        assert_eq!(Grain::parse(&text_value("month")), Some(Grain::Month));
    }

    /// A zero-width cut puts every document in one group called "", which is a
    /// view that has stopped being one. Rejected rather than clamped, so the
    /// linter reports it instead of it silently working.
    #[test]
    fn a_grain_with_a_useless_parameter_does_not_parse() {
        let mut zero = Mapping::new();
        zero.insert("initial".into(), Value::Int(0));
        assert_eq!(Grain::parse(&Value::Mapping(zero)), None);

        let mut unknown = Mapping::new();
        unknown.insert("bucket".into(), Value::Int(10));
        assert_eq!(Grain::parse(&Value::Mapping(unknown)), None);

        let mut two = Mapping::new();
        two.insert("initial".into(), Value::Int(1));
        two.insert("month".into(), Value::Int(1));
        assert_eq!(Grain::parse(&Value::Mapping(two)), None);
    }

    /// The reason the cut validates instead of slicing: `banana` must not
    /// become the group `bana`, and `20264` must not become the year `2026`.
    #[test]
    fn a_grain_rejects_what_is_not_a_date_at_that_grain() {
        assert_eq!(Grain::Year.cut("banana"), None);
        assert_eq!(Grain::Year.cut("20264"), None);
        assert_eq!(Grain::Day.cut("2026-07"), None);
        assert_eq!(Grain::Month.cut("2026/07"), None);
        assert_eq!(Grain::Month.cut(""), None);
    }

    /// The load-bearing separation: `by:` is classification, `nest:` is
    /// aggregation, and reading one does not set the other. A view that grouped
    /// by month would otherwise start filing next month's entry somewhere new.
    #[test]
    fn grain_does_not_imply_nesting() {
        let spec = ViewSpec::parse("daily", &text(&[("group", "created"), ("by", "month")]))
            .expect("a view");
        assert_eq!(spec.group.by, Some(Grain::Month));
        assert_eq!(spec.nest, None);

        let materialized = ViewSpec::parse(
            "daily",
            &text(&[("group", "created"), ("by", "month"), ("nest", "year")]),
        )
        .expect("a view");
        assert_eq!(
            materialized.nest,
            Some(Grain::Year),
            "a view may group finer than it files"
        );
    }

    #[test]
    fn an_entry_without_a_grouping_is_not_a_view() {
        assert!(ViewSpec::parse("x", &text(&[("label", "Nameless")])).is_none());
        assert!(ViewSpec::parse("x", &text(&[("group", "  ")])).is_none());
        assert!(ViewSpec::parse("x", &mapping(&[("group", seq(&[]))])).is_none());
        assert!(ViewSpec::parse("x", &Value::String("created".into())).is_none());
    }

    /// A view that nests hands a frontend the index *titles* to file under —
    /// which is exactly what prov's route addressing takes, so nothing
    /// assembles a path.
    #[test]
    fn nest_route_gives_the_index_titles_to_file_under() {
        let spec = ViewSpec::parse(
            "daily",
            &text(&[("group", "created"), ("by", "day"), ("nest", "month")]),
        )
        .expect("a view");

        let mut doc = Mapping::new();
        doc.insert("created".into(), Value::String("2026-07-24".into()));
        assert_eq!(
            spec.nest_route(&Value::Mapping(doc)),
            Some(vec!["2026".to_string(), "2026-07".to_string()]),
            "a month nest is a year index holding a month index"
        );
    }

    /// The alphabetical case is the same machinery — the generalization, seen
    /// from the filing side rather than the reading side.
    #[test]
    fn nest_route_generalizes_past_dates() {
        let mut entry = Mapping::new();
        entry.insert("group".into(), Value::String("surname".into()));
        entry.insert("nest".into(), {
            let mut g = Mapping::new();
            g.insert("initial".into(), Value::Int(2));
            Value::Mapping(g)
        });
        let spec = ViewSpec::parse("people", &Value::Mapping(entry)).expect("a view");

        let mut doc = Mapping::new();
        doc.insert("surname".into(), Value::String("Lovelace".into()));
        assert_eq!(
            spec.nest_route(&Value::Mapping(doc)),
            Some(vec!["L".to_string(), "LO".to_string()])
        );
    }

    /// The constraint prov's spine imposes: a document with two people cannot
    /// hang under two parents, so it has no single home and this says so rather
    /// than picking one.
    #[test]
    fn a_multi_valued_document_has_no_nest_route() {
        let spec = ViewSpec::parse("who", &text(&[("group", "people"), ("nest", "initial")]))
            .expect("a view");

        let mut one = Mapping::new();
        one.insert("people".into(), Value::String("Ada".into()));
        assert_eq!(
            spec.nest_route(&Value::Mapping(one)),
            Some(vec!["A".to_string()]),
            "one value files fine"
        );

        let mut two = Mapping::new();
        two.insert("people".into(), seq(&["Ada", "Grace"]));
        assert_eq!(
            spec.nest_route(&Value::Mapping(two)),
            None,
            "two values are two homes, and prov's spine allows one"
        );
    }

    /// Reading must not decide where a file lands: a view that groups by year
    /// still nests by month if that is what it says, and the route is cut from
    /// the *uncut* value.
    #[test]
    fn nest_route_ignores_how_the_view_reads() {
        let spec = ViewSpec::parse(
            "daily",
            &text(&[("group", "created"), ("by", "year"), ("nest", "month")]),
        )
        .expect("a view");

        let mut doc = Mapping::new();
        doc.insert("created".into(), Value::String("2026-07-24".into()));
        assert_eq!(
            spec.nest_route(&Value::Mapping(doc)),
            Some(vec!["2026".to_string(), "2026-07".to_string()]),
            "grouped by year, filed by month — `by` never reaches the route"
        );
    }

    #[test]
    fn a_view_that_does_not_nest_or_cannot_file_has_no_route() {
        let no_nest = ViewSpec::parse("daily", &text(&[("group", "created")])).expect("a view");
        assert_eq!(no_nest.nest_route(&Value::Mapping(Mapping::new())), None);

        let nests = ViewSpec::parse("daily", &text(&[("group", "created"), ("nest", "month")]))
            .expect("a view");
        assert_eq!(
            nests.nest_route(&Value::Mapping(Mapping::new())),
            None,
            "nothing to file by"
        );

        // A value that reaches the year but not the month files nowhere rather
        // than landing in `2026` and calling it done.
        let mut partial = Mapping::new();
        partial.insert("created".into(), Value::String("2026".into()));
        assert_eq!(nests.nest_route(&Value::Mapping(partial)), None);
    }

    #[test]
    fn a_view_round_trips_through_its_mapping() {
        for group in [
            Grouping {
                keys: vec!["created".into()],
                by: Some(Grain::Month),
            },
            Grouping {
                keys: vec!["date_of_document".into(), "created".into()],
                by: Some(Grain::Day),
            },
            Grouping::field("people"),
        ] {
            let spec = ViewSpec {
                name: "daily".into(),
                label: Some("Daily".into()),
                icon: Some("calendar".into()),
                group,
                under: Some("[Daily](id:abc1234)".into()),
                filter: Some(Condition::Not(Box::new(Condition::Has("draft".into())))),
                nest: Some(Grain::Year),
            };
            let back =
                ViewSpec::parse("daily", &Value::Mapping(spec.to_mapping())).expect("a view");
            assert_eq!(back, spec);
        }
    }

    /// A one-key chain writes back as a bare string, not a one-element list.
    #[test]
    fn a_single_key_group_serializes_unwrapped() {
        let spec = ViewSpec {
            name: "who".into(),
            label: None,
            icon: None,
            group: Grouping::field("people"),
            under: None,
            filter: None,
            nest: None,
        };
        assert_eq!(
            spec.to_mapping().get("group"),
            Some(&Value::String("people".into()))
        );
    }

    #[test]
    fn views_read_in_declaration_order() {
        let mut views = Mapping::new();
        views.insert("daily".into(), text(&[("group", "created")]));
        views.insert("who".into(), text(&[("group", "people")]));
        let mut config = Mapping::new();
        config.insert(VIEWS_KEY.into(), Value::Mapping(views));

        let specs = views_from(&config);
        assert_eq!(
            specs.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(),
            ["daily", "who"]
        );
    }

    #[test]
    fn a_label_falls_back_to_the_humanized_name() {
        let spec = ViewSpec::parse("daily_entries", &text(&[("group", "created")])).expect("view");
        assert_eq!(spec.display_label(), "Daily entries");
    }

    #[test]
    fn a_non_string_scalar_groups_under_its_text() {
        let spec = ViewSpec::parse("stars", &text(&[("group", "rating")])).expect("a view");
        let mut doc = Mapping::new();
        doc.insert("rating".into(), Value::Int(5));
        assert_eq!(spec.group.keys_of(&Value::Mapping(doc)), ["5"]);
    }
}