Skip to main content

blockworx/store/
history.rs

1//! The audit trail as text: one row per rev, for `blockworx log` and the
2//! editor's history panel.
3//! Rationale: `docs/single-author-playbook.md`, F7 and Phase 4.
4//!
5//! Both surfaces read the same rows, so the console dump and the panel
6//! cannot disagree about what a row says. A row here is a *rendering* of
7//! a [`manifest::Row`] — one derivation, whether the row was written this
8//! session or read off a disk — except for a session with no files, whose
9//! history is its commits and nothing else: there is no clock and no
10//! author to report, and the panel says so by showing neither.
11
12use blockworx_doc::{commit::Commit, rev::Rev};
13
14use super::manifest::{self, RowKind};
15use super::record::WallTime;
16use super::tags::Tags;
17
18/// The wall clock, in the one spelling every surface reads it in: the
19/// rows' own. The browser has no container and so no row carrying a time
20/// to compare against, which is why the epoch is an honest answer there
21/// rather than a stub.
22pub fn now() -> WallTime {
23    #[cfg(not(target_arch = "wasm32"))]
24    {
25        WallTime::from_unix_millis(
26            std::time::SystemTime::UNIX_EPOCH
27                .elapsed()
28                .unwrap_or_default()
29                .as_millis() as u64,
30        )
31    }
32    #[cfg(target_arch = "wasm32")]
33    {
34        WallTime::EPOCH
35    }
36}
37
38/// Where the rows a reader is shown come from. Not two functions: a
39/// container's history is its manifest and a scratch session's is its own
40/// commits, and naming the two arms is what keeps one renderer over both.
41#[derive(Clone, Copy)]
42pub enum Journal<'a> {
43    Recorded(&'a [manifest::Row]),
44    /// A session with no files, whose commits are all there is.
45    Session(&'a [Commit]),
46}
47
48/// One rev as a reader sees it. Borrowed rather than owned: the panel
49/// rebuilds these every frame from the history it is already holding.
50pub struct Row<'a> {
51    pub rev: Rev,
52    pub label: &'a str,
53    /// How many entities the act named — `blockworx log`'s count column.
54    /// The panel shows the entities' *scope* instead (§8.1), so this is
55    /// the dump's alone.
56    pub touched: usize,
57    /// The manifest row behind this one. `None` for a scratch session.
58    pub written: Option<&'a manifest::Row>,
59    /// What this rev is called, where tag rows named it (D18) —
60    /// alphabetical, and empty for an untagged rev.
61    pub tags: &'a [String],
62}
63
64/// What a rev did to its neighbours, without naming which one — the
65/// history panel's kind facet, and the coarse half of [`Row::kind`].
66#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
67pub enum Kind {
68    Edit,
69    Undo,
70    Redo,
71}
72
73impl Kind {
74    pub fn label(self) -> &'static str {
75        match self {
76            Kind::Edit => "edit",
77            Kind::Undo => "undo",
78            Kind::Redo => "redo",
79        }
80    }
81}
82
83/// The calendar day a rev was written, in this machine's own zone: the
84/// history panel's day-group key and the value its date facet filters on.
85/// One key for both, so a chip and a group heading cannot disagree about
86/// which rows belong together.
87#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
88pub struct Day(jiff::civil::Date);
89
90impl Day {
91    fn of(when: WallTime, zone: &jiff::tz::TimeZone) -> Option<Self> {
92        jiff::Timestamp::from_millisecond(when.unix_millis() as i64)
93            .ok()
94            .map(|instant| Day(instant.to_zoned(zone.clone()).date()))
95    }
96
97    /// How a heading or a chip prints this day: the words a reader would
98    /// use for the last two, the ISO date beyond them.
99    pub fn label(self, now: WallTime) -> String {
100        self.label_in(now, &jiff::tz::TimeZone::system())
101    }
102
103    fn label_in(self, now: WallTime, zone: &jiff::tz::TimeZone) -> String {
104        match Day::of(now, zone) {
105            Some(today) if today == self => "Today".to_owned(),
106            Some(today) if today.0.yesterday().ok() == Some(self.0) => "Yesterday".to_owned(),
107            _ => self.to_string(),
108        }
109    }
110}
111
112impl std::fmt::Display for Day {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        write!(f, "{}", self.0.strftime("%Y-%m-%d"))
115    }
116}
117
118impl<'a> Row<'a> {
119    /// What this rev did to its neighbours, without the rev it did it to.
120    /// A session keeping no rows has only its commits, and a commit is an
121    /// edit.
122    pub fn kind_of(&self) -> Kind {
123        match self.written.map(|row| row.kind) {
124            // Tag rows never reach `rows`: `manifest::history` folds them
125            // into the vocabulary instead of keeping them as positions.
126            None | Some(RowKind::Edit | RowKind::Tag | RowKind::Untag) => Kind::Edit,
127            Some(RowKind::Undo { .. }) => Kind::Undo,
128            Some(RowKind::Redo { .. }) => Kind::Redo,
129        }
130    }
131
132    /// Whether this rev is an inverse rather than a design decision —
133    /// rendered muted and italic in the panel so a reader scanning for
134    /// when something changed can read past it (spec §4.4). A session with
135    /// no rows has only the label it wrote, which still says so.
136    pub fn is_inverse(&self) -> bool {
137        match self.written {
138            Some(_) => self.kind_of() == Kind::Undo,
139            None => self.label.starts_with("Undo "),
140        }
141    }
142
143    /// Which rev this one took back, where the row says so — what the
144    /// panel's `Undo — <original>` is titled from (§7.2).
145    pub fn undone(&self) -> Option<Rev> {
146        match self.written.map(|row| row.kind) {
147            Some(RowKind::Undo { of }) => Some(of),
148            _ => None,
149        }
150    }
151
152    /// The row's kind column: [`Self::kind_of`] and the rev it names.
153    pub fn kind(&self) -> String {
154        match self.written.map(|row| row.kind) {
155            None | Some(RowKind::Edit | RowKind::Tag | RowKind::Untag) => {
156                Kind::Edit.label().to_owned()
157            }
158            Some(RowKind::Undo { of }) => format!("{} of r{}", Kind::Undo.label(), of.get()),
159            Some(RowKind::Redo { of }) => format!("{} of r{}", Kind::Redo.label(), of.get()),
160        }
161    }
162
163    /// §8.1's scope line: the blocks the act happened inside, outermost
164    /// first, as its author saw them spelled. Empty at the document root
165    /// and for a session that recorded no path.
166    pub fn scope_names(&self) -> &'a [String] {
167        self.written.map_or(&[], |row| row.scope_names.as_slice())
168    }
169
170    /// The author's initials, for §8.1's avatar. Two at most, and a name
171    /// with nothing in it shows nothing rather than a placeholder letter.
172    pub fn initials(&self) -> String {
173        self.author()
174            .split_whitespace()
175            .filter_map(|word| word.chars().next())
176            .take(2)
177            .flat_map(char::to_uppercase)
178            .collect()
179    }
180
181    /// The clock time this rev was written, without its date: §8.1 puts
182    /// the day in the group heading, so the row says only the time.
183    /// Empty for a session that carried no clock.
184    pub fn time(&self) -> String {
185        self.time_in(&jiff::tz::TimeZone::system())
186    }
187
188    fn time_in(&self, zone: &jiff::tz::TimeZone) -> String {
189        self.written.map_or_else(String::new, |row| {
190            written_as(row.wall_time, zone, "%-I:%M %p")
191        })
192    }
193
194    /// How long ago this was written, as a person would say it — "3 hours
195    /// ago". The viewing pill's half of the clock (§6.2); §8.1's row says
196    /// the time instead. Empty for a session that carried no clock, and
197    /// for a row whose stamp is in `now`'s future (a clock that went
198    /// backwards, or a container written on another machine).
199    pub fn since(&self, now: WallTime) -> String {
200        self.written
201            .and_then(|row| {
202                now.unix_millis()
203                    .checked_sub(row.wall_time.unix_millis())
204                    .map(|elapsed| humanize(std::time::Duration::from_millis(elapsed)))
205            })
206            .unwrap_or_default()
207    }
208
209    /// The whole instant as a reader would say it — what the time hovers
210    /// to and what the picked rev's card shows in full.
211    pub fn full_when(&self) -> String {
212        self.full_when_in(&jiff::tz::TimeZone::system())
213    }
214
215    fn full_when_in(&self, zone: &jiff::tz::TimeZone) -> String {
216        self.written.map_or_else(String::new, |row| {
217            written_as(row.wall_time, zone, "%a %-d %b %Y, %-I:%M %p")
218        })
219    }
220
221    /// The day this rev was written. `None` for a session that carried no
222    /// clock — there is no day to group it under.
223    pub fn day(&self) -> Option<Day> {
224        self.day_in(&jiff::tz::TimeZone::system())
225    }
226
227    fn day_in(&self, zone: &jiff::tz::TimeZone) -> Option<Day> {
228        self.written.and_then(|row| Day::of(row.wall_time, zone))
229    }
230
231    pub fn author(&self) -> &str {
232        self.written.map_or("", |row| row.author.name.as_str())
233    }
234
235    /// The wall time as a person reads it, in this machine's own zone.
236    /// Empty for a session that never carried one.
237    pub fn when(&self) -> String {
238        self.when_in(&jiff::tz::TimeZone::system())
239    }
240
241    /// [`Self::when`] against a named zone — the seam a test pins so its
242    /// expected text does not depend on where the machine is.
243    fn when_in(&self, zone: &jiff::tz::TimeZone) -> String {
244        self.written
245            .map_or_else(String::new, |row| stamp(row.wall_time, zone))
246    }
247}
248
249/// Which of a row's columns one search term is asked of. §8.1's
250/// prefixes, so a reader who remembers *where* a change was made can say
251/// so instead of hoping the word is rare.
252#[derive(Clone, Copy, PartialEq, Eq, Debug)]
253enum Field {
254    /// No prefix: the term is asked of every column at once.
255    Anywhere,
256    Tag,
257    Author,
258    Scope,
259    /// `#` — matched as a prefix of the number, so `#14` reaches r148.
260    Rev,
261}
262
263impl Field {
264    /// The prefixes, spelled once. Longest first is not needed: no
265    /// prefix here is a prefix of another.
266    const PREFIXES: [(&'static str, Field); 4] = [
267        ("tag:", Field::Tag),
268        ("by:", Field::Author),
269        ("in:", Field::Scope),
270        ("#", Field::Rev),
271    ];
272}
273
274/// One word of a search, already split from its prefix.
275#[derive(Clone, PartialEq, Eq, Debug)]
276struct Term {
277    field: Field,
278    text: String,
279}
280
281impl Term {
282    fn of(word: &str) -> Self {
283        let lowered = word.to_lowercase();
284        for (prefix, field) in Field::PREFIXES {
285            if let Some(rest) = lowered.strip_prefix(prefix) {
286                return Term {
287                    field,
288                    text: rest.to_owned(),
289                };
290            }
291        }
292        Term {
293            field: Field::Anywhere,
294            text: lowered,
295        }
296    }
297
298    fn admits(&self, row: &Row<'_>) -> bool {
299        // A prefix with nothing after it is still being typed; it narrows
300        // to the column without yet narrowing within it.
301        if self.text.is_empty() {
302            return true;
303        }
304        let has = |text: &str| text.to_lowercase().contains(&self.text);
305        let tagged = || row.tags.iter().any(|tag| has(tag));
306        let in_scope = || row.scope_names().iter().any(|name| has(name));
307        let is_rev = || row.rev.get().to_string().starts_with(&self.text);
308        match self.field {
309            Field::Tag => tagged(),
310            Field::Author => has(row.author()),
311            Field::Scope => in_scope(),
312            Field::Rev => is_rev(),
313            Field::Anywhere => {
314                has(row.label) || in_scope() || tagged() || has(row.author()) || is_rev()
315            }
316        }
317    }
318}
319
320/// §8.1's search box, parsed: every word must match, and a word carrying
321/// a prefix is asked only of that column.
322///
323/// Parsed once per frame rather than per row, so a long history does not
324/// re-split the same string for every line of it.
325#[derive(Clone, Default, PartialEq, Eq, Debug)]
326pub struct Query {
327    terms: Vec<Term>,
328}
329
330impl Query {
331    pub fn parse(text: &str) -> Self {
332        Query {
333            terms: text.split_whitespace().map(Term::of).collect(),
334        }
335    }
336
337    /// Whether a row survives. One predicate, so the box and the list
338    /// cannot disagree about what "matches" means.
339    pub fn admits(&self, row: &Row<'_>) -> bool {
340        self.terms.iter().all(|term| term.admits(row))
341    }
342
343    /// Whether anything at all is narrowing the list — what an empty
344    /// result has to blame.
345    pub fn narrows(&self) -> bool {
346        !self.terms.is_empty()
347    }
348}
349
350/// The search text that narrows the list to one tag — what clicking a
351/// chip sets the box to (§8.1).
352pub fn tag_query(name: &str) -> String {
353    format!("tag:{name}")
354}
355
356/// `elapsed` in conversation rather than in units. `timeago` is the whole
357/// of this rule — thresholds, pluralization, "just now" — and reproducing
358/// it is exactly the kind of drift the house rule about crates is for.
359pub fn humanize(elapsed: std::time::Duration) -> String {
360    timeago::Formatter::new().convert(elapsed)
361}
362
363/// `journal`'s revs with the names their tags give them, oldest first —
364/// the order the file holds and the history took them in.
365pub fn rows<'a>(journal: Journal<'a>, tags: &'a Tags) -> Vec<Row<'a>> {
366    let at = |ndx: usize| Rev::ZERO.forward(ndx as u64 + 1);
367    match journal {
368        Journal::Recorded(written) => written
369            .iter()
370            .map(|row| Row {
371                rev: row.rev,
372                label: row.label.as_str(),
373                touched: row.touched.len(),
374                written: Some(row),
375                tags: tags.of(row.rev),
376            })
377            .collect(),
378        Journal::Session(commits) => commits
379            .iter()
380            .enumerate()
381            .map(|(ndx, commit)| Row {
382                rev: at(ndx),
383                label: commit.label(),
384                touched: commit.ops().len(),
385                written: None,
386                tags: tags.of(at(ndx)),
387            })
388            .collect(),
389    }
390}
391
392/// The rendered dump: one line per row, columns aligned to the widest
393/// value in each. A function over the rows rather than a printer, so what
394/// the console shows is what a test can read.
395pub fn lines(rows: &[Row<'_>]) -> Vec<String> {
396    lines_in(rows, &jiff::tz::TimeZone::system())
397}
398
399/// [`lines`] with the clock's zone named, for a test that must know what
400/// the time column will say.
401fn lines_in(rows: &[Row<'_>], zone: &jiff::tz::TimeZone) -> Vec<String> {
402    let kinds: Vec<String> = rows.iter().map(Row::kind).collect();
403    let width = |values: &mut dyn Iterator<Item = usize>| values.max().unwrap_or(0);
404    let rev_w = width(&mut rows.iter().map(|row| digits(row.rev)));
405    let author_w = width(&mut rows.iter().map(|row| row.author().chars().count()));
406    let kind_w = width(&mut kinds.iter().map(|kind| kind.chars().count()));
407    let ops_w = width(&mut rows.iter().map(|row| digits_of(row.touched)));
408    // A log with no tags in it grows no tag column at all.
409    let tag_w = width(&mut rows.iter().map(|row| tag_cell(row.tags).chars().count()));
410
411    rows.iter()
412        .zip(&kinds)
413        .map(|(row, kind)| {
414            let rev = format!("r{:<rev_w$}", row.rev.get());
415            let ops = format!("{:>ops_w$} {}", row.touched, plural(row.touched));
416            let tag = match tag_w {
417                0 => String::new(),
418                _ => format!("{:<tag_w$}  ", tag_cell(row.tags)),
419            };
420            // A scratch session has no clock and no author, so those two
421            // columns collapse to nothing rather than to blank padding.
422            let attribution = match row.written {
423                None => String::new(),
424                Some(_) => format!("{}  {:<author_w$}  ", row.when_in(zone), row.author()),
425            };
426            format!(
427                "{rev}  {attribution}{kind:<kind_w$}  {ops}  {tag}{label}",
428                label = row.label
429            )
430        })
431        .collect()
432}
433
434fn tag_cell(tags: &[String]) -> String {
435    if tags.is_empty() {
436        return String::new();
437    }
438    format!("[{}]", tags.join(" "))
439}
440
441fn plural(touched: usize) -> &'static str {
442    if touched == 1 { "name " } else { "names" }
443}
444
445fn digits(rev: Rev) -> usize {
446    digits_of(rev.get() as usize)
447}
448
449fn digits_of(n: usize) -> usize {
450    n.checked_ilog10().unwrap_or(0) as usize + 1
451}
452
453/// `when` in `zone`, to the second. A zone the platform cannot name falls
454/// back to UTC, which is jiff's own answer and the only honest one — an
455/// audit line must still say *when*.
456fn stamp(when: WallTime, zone: &jiff::tz::TimeZone) -> String {
457    written_as(when, zone, "%Y-%m-%d %H:%M:%S")
458}
459
460/// The date a rev was written, in this machine's own zone — what a title
461/// block's Date row says. The log's clock rather than the wall clock, so an
462/// export of a given rev says the same thing every time it is taken.
463pub fn date(when: WallTime) -> String {
464    date_in(when, &jiff::tz::TimeZone::system())
465}
466
467/// The date *and time* a rev was written — the status line's title block,
468/// which has one line for what a sheet gives a whole row. To the minute
469/// where [`Row::when`] goes to the second: a corner of the frame the reader
470/// is not looking at is no place for a ticking field.
471pub fn written_at(when: WallTime) -> String {
472    written_at_in(when, &jiff::tz::TimeZone::system())
473}
474
475fn written_at_in(when: WallTime, zone: &jiff::tz::TimeZone) -> String {
476    written_as(when, zone, "%Y-%m-%d %H:%M")
477}
478
479/// [`date`] against a named zone — the seam a test pins so its expected text
480/// does not depend on where the machine is.
481fn date_in(when: WallTime, zone: &jiff::tz::TimeZone) -> String {
482    written_as(when, zone, "%Y-%m-%d")
483}
484
485fn written_as(when: WallTime, zone: &jiff::tz::TimeZone, format: &str) -> String {
486    let Ok(instant) = jiff::Timestamp::from_millisecond(when.unix_millis() as i64) else {
487        return String::new();
488    };
489    instant.to_zoned(zone.clone()).strftime(format).to_string()
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::store::manifest::RowKind;
496    use crate::store::record::{Camera, Digest, Identity, ScopePath};
497    use crate::store::tests::fixture;
498    use blockworx_doc::fixtures::rev;
499
500    fn written(n: u64, kind: RowKind, name: &str) -> manifest::Row {
501        manifest::Row {
502            rev: rev(n),
503            kind,
504            wall_time: WallTime::from_unix_millis(1_756_000_000_000 + n * 1_000),
505            author: Identity::new(name),
506            label: "Added a block".to_owned(),
507            scope: ScopePath::default(),
508            scope_names: Vec::new(),
509            camera: Camera::UNSEEN,
510            touched: vec![blockworx_doc::id::EntityRef::Block(
511                blockworx_doc::fixtures::block_id(1),
512            )],
513            truncated: false,
514            hash: Digest::of(b"a rev file"),
515            parent: Digest::genesis(),
516        }
517    }
518
519    /// A scratch session's history has commits and nothing else, so the
520    /// rows carry the labels and leave the audit columns empty rather than
521    /// inventing an author or an epoch timestamp.
522    #[test]
523    fn a_session_with_no_rows_still_lists_its_commits() {
524        let commits = fixture::edits(2);
525        let none = Tags::default();
526        let rows = rows(Journal::Session(&commits), &none);
527        assert_eq!(rows.len(), 2);
528        assert_eq!(rows[1].rev, rev(2));
529        assert_eq!(rows[1].label, "Added a block");
530        assert_eq!(rows[1].touched, 1);
531        assert_eq!(rows[0].author(), "");
532        assert_eq!(rows[0].when(), "");
533        assert_eq!(rows[0].kind(), "edit", "a commit with no row is an edit");
534    }
535
536    #[test]
537    fn a_row_kind_names_the_rev_it_took_back() {
538        let recorded = [
539            written(1, RowKind::Edit, "ada"),
540            written(2, RowKind::Undo { of: rev(1) }, "ada"),
541        ];
542        let none = Tags::default();
543        let rows = rows(Journal::Recorded(&recorded), &none);
544        assert_eq!(rows[0].kind(), "edit");
545        assert_eq!(rows[1].kind(), "undo of r1");
546        assert_eq!(rows[1].undone(), Some(rev(1)));
547        assert_eq!(rows[1].author(), "ada");
548    }
549
550    /// The elapsed column is the one a reader reads; the absolute stamp
551    /// stays reachable beside it. A row from the future — a clock that
552    /// went backwards — says nothing rather than a negative age.
553    #[test]
554    fn a_row_reports_its_age_in_words_and_declines_to_report_a_future_one() {
555        let recorded = [written(1, RowKind::Edit, "ada")];
556        let none = Tags::default();
557        let rows = rows(Journal::Recorded(&recorded), &none);
558        let at = recorded[0].wall_time.unix_millis();
559
560        let three_hours_on = WallTime::from_unix_millis(at + 3 * 60 * 60 * 1_000);
561        assert_eq!(rows[0].since(three_hours_on), "3 hours ago");
562        assert!(
563            !rows[0].when().is_empty(),
564            "the absolute stamp is still there for the tooltip",
565        );
566        assert_eq!(
567            rows[0].since(WallTime::from_unix_millis(at - 1)),
568            "",
569            "a row stamped after `now` has no age to report",
570        );
571    }
572
573    #[test]
574    fn a_scratch_row_has_no_age_because_it_carries_no_clock() {
575        let commits = fixture::edits(1);
576        let none = Tags::default();
577        assert_eq!(rows(Journal::Session(&commits), &none)[0].since(now()), "");
578    }
579
580    /// A tag rides its rev's row, and `blockworx log` grows a column for
581    /// it only when there is one to show.
582    #[test]
583    fn a_tagged_rev_carries_its_name_into_the_dump() {
584        let commits = fixture::edits(2);
585        let mut tags = Tags::default();
586        tags.add(rev(2), "Initial Draft");
587        tags.add(rev(2), "vendor");
588        let rows = rows(Journal::Session(&commits), &tags);
589        assert_eq!(rows[1].tags, ["Initial Draft", "vendor"]);
590        assert!(rows[0].tags.is_empty());
591
592        let tagged = lines(&rows);
593        assert!(
594            tagged[1].contains("[Initial Draft vendor]"),
595            "a rev's whole set does not reach the dump: {}",
596            tagged[1],
597        );
598        let none = Tags::default();
599        let untagged = lines(&super::rows(Journal::Session(&commits), &none));
600        assert!(
601            !untagged[0].contains('['),
602            "a history with no tags grows no tag column: {}",
603            untagged[0],
604        );
605    }
606
607    /// What `blockworx log` prints, over a real container with a pinned
608    /// clock: the whole dump, spelled out. It is the audit trail's user
609    /// interface, so the columns are pinned the way the row format is.
610    #[cfg(not(target_arch = "wasm32"))]
611    #[test]
612    fn a_container_dumps_its_trail_line_by_line() {
613        use crate::store::handle::{Clock, Store};
614
615        let dir = fixture::dir("log-dump");
616        let root = dir.join("doc.bwx");
617        let author = Identity::new("ada");
618        let mut store = Store::create(
619            &root,
620            Clock::Pinned {
621                at: WallTime::from_unix_millis(1_756_000_000_000),
622                step: std::time::Duration::from_mins(1),
623            },
624        )
625        .expect("the container");
626        let edit = store
627            .submit_edit(
628                fixture::commit(
629                    "Added a block",
630                    vec![
631                        fixture::block_create(1, "Adder"),
632                        fixture::block_create(2, "Summer"),
633                    ],
634                ),
635                &author,
636            )
637            .expect("the edit lands");
638        store
639            .undo(edit, &author)
640            .expect("and is taken back, into the trail");
641
642        let dumped = lines_in(
643            &rows(Journal::Recorded(store.rows()), store.tags()),
644            &jiff::tz::TimeZone::UTC,
645        );
646        assert_eq!(
647            dumped,
648            [
649                "r1  2025-08-24 01:47:40  ada  edit        2 names  Added a block",
650                "r2  2025-08-24 01:48:40  ada  undo of r1  2 names  Undo Added a block",
651            ],
652            "an undo frames what it moved: the same names its edit did",
653        );
654    }
655
656    /// The title block says *when* a rev was written as a plain date — the
657    /// row's own clock, so the same rev exports to the same date forever,
658    /// and no wall clock creeps into a byte-identical export.
659    #[test]
660    fn a_revs_date_is_the_day_its_row_was_written() {
661        let at = WallTime::from_unix_millis(1_756_000_060_000);
662        assert_eq!(date_in(at, &jiff::tz::TimeZone::UTC), "2025-08-24");
663        assert_eq!(
664            date_in(at, &jiff::tz::TimeZone::UTC),
665            stamp(at, &jiff::tz::TimeZone::UTC)
666                .split(' ')
667                .next()
668                .expect("the audit stamp leads with its date"),
669            "the two spellings of the same instant disagree about the day",
670        );
671    }
672
673    /// The day a row groups under is the day it was written, and the last
674    /// two are named rather than dated — one key for the heading and the
675    /// date facet both.
676    #[test]
677    fn a_row_groups_under_the_day_it_was_written() {
678        let utc = jiff::tz::TimeZone::UTC;
679        let recorded = [
680            written(1, RowKind::Edit, "ada"),
681            manifest::Row {
682                wall_time: WallTime::from_unix_millis(1_756_000_000_000 - 2 * 86_400_000),
683                ..written(2, RowKind::Edit, "ada")
684            },
685        ];
686        let none = Tags::default();
687        let rows = rows(Journal::Recorded(&recorded), &none);
688        let (recent, older) = (
689            rows[0].day_in(&utc).expect("a row carries a day"),
690            rows[1].day_in(&utc).expect("a row carries a day"),
691        );
692        assert_ne!(recent, older, "two days apart grouped together");
693        assert_eq!(recent.to_string(), "2025-08-24");
694        assert_eq!(older.to_string(), "2025-08-22");
695
696        let now = WallTime::from_unix_millis(1_756_000_000_000);
697        assert_eq!(recent.label_in(now, &utc), "Today");
698        assert_eq!(older.label_in(now, &utc), "2025-08-22");
699        let tomorrow = WallTime::from_unix_millis(1_756_000_000_000 + 86_400_000);
700        assert_eq!(recent.label_in(tomorrow, &utc), "Yesterday");
701
702        let commits = fixture::edits(1);
703        assert_eq!(
704            super::rows(Journal::Session(&commits), &none)[0].day_in(&utc),
705            None,
706            "a session with no clock has no day to group under",
707        );
708    }
709
710    /// The kind facet is coarse — every undo answers `undo`, whichever rev
711    /// it took back — while the row's own column still names that rev.
712    #[test]
713    fn the_kind_facet_is_coarse_and_marks_inverse_revs() {
714        let recorded = [
715            written(1, RowKind::Edit, "ada"),
716            written(2, RowKind::Undo { of: rev(1) }, "ada"),
717        ];
718        let none = Tags::default();
719        let rows = rows(Journal::Recorded(&recorded), &none);
720        assert_eq!(rows[0].kind_of(), Kind::Edit);
721        assert_eq!(rows[1].kind_of(), Kind::Undo);
722        assert_eq!(rows[1].kind(), "undo of r1", "the column still names it");
723        assert!(!rows[0].is_inverse());
724        assert!(rows[1].is_inverse());
725
726        let commits = fixture::edits(2);
727        let unrecorded = super::rows(Journal::Session(&commits), &none);
728        assert_eq!(
729            unrecorded[1].label, "Added a block",
730            "precondition: the fixture's commits are plain edits",
731        );
732        assert!(
733            !unrecorded[1].is_inverse(),
734            "a session with no rows reads its own labels",
735        );
736    }
737
738    /// §8.1's prefixes each narrow to one column, so a word that would
739    /// match somewhere else does not.
740    #[test]
741    fn a_prefix_asks_one_column_and_a_bare_word_asks_them_all() {
742        let mut recorded = [written(1, RowKind::Edit, "ada")];
743        recorded[0].label = "Base plate 78 to 84 mm".to_owned();
744        recorded[0].scope_names = vec!["engine".to_owned(), "base plate".to_owned()];
745        let mut tags = Tags::default();
746        tags.add(rev(1), "vendor");
747        let rows = rows(Journal::Recorded(&recorded), &tags);
748        let admits = |query: &str| Query::parse(query).admits(&rows[0]);
749
750        assert!(admits("plate"), "a bare word reaches the description");
751        assert!(admits("ada"), "a bare word reaches the author");
752        assert!(admits("vendor"), "a bare word reaches the tags");
753        assert!(
754            admits("78"),
755            "§6.1's deltas are what makes numbers searchable"
756        );
757
758        assert!(admits("by:ada") && !admits("by:grace"));
759        assert!(admits("tag:vendor") && !admits("tag:plate"));
760        assert!(
761            admits("in:engine") && !admits("in:vendor"),
762            "`in:` reached outside the scope",
763        );
764        assert!(
765            admits("#1") && !admits("#2"),
766            "`#` matches a rev number as a prefix of it",
767        );
768        assert!(
769            !admits("tag:plate"),
770            "a prefixed term matched a column it did not name",
771        );
772    }
773
774    /// Every word must match, which is what makes a second word narrow
775    /// rather than widen.
776    #[test]
777    fn every_word_must_match_and_a_bare_prefix_narrows_no_further() {
778        let mut recorded = [written(1, RowKind::Edit, "ada")];
779        recorded[0].label = "Base plate".to_owned();
780        let none = Tags::default();
781        let rows = rows(Journal::Recorded(&recorded), &none);
782        let admits = |query: &str| Query::parse(query).admits(&rows[0]);
783
784        assert!(admits("base plate"));
785        assert!(!admits("base rib"), "a second word widened the search");
786        assert!(
787            admits("by:"),
788            "a prefix still being typed narrows to the column, not to nothing",
789        );
790        assert!(!Query::parse("   ").narrows(), "blank text narrows nothing");
791    }
792
793    /// Column widths come from the widest value, so a two-digit rev or a
794    /// long author name does not push its neighbours out of line.
795    #[test]
796    fn columns_line_up_across_rows() {
797        let recorded: Vec<manifest::Row> = (1..=10)
798            .map(|n| {
799                written(
800                    n,
801                    RowKind::Edit,
802                    if n == 3 { "ada lovelace" } else { "bob" },
803                )
804            })
805            .collect();
806        let none = Tags::default();
807        let lines = lines(&rows(Journal::Recorded(&recorded), &none));
808        assert_eq!(lines.len(), 10);
809        assert!(lines[9].starts_with("r10 "), "{}", lines[9]);
810        let label_at = |line: &String| line.find("Added").expect("every line names its label");
811        let first = label_at(&lines[0]);
812        for line in &lines {
813            assert_eq!(label_at(line), first, "a column drifted: {line}");
814        }
815    }
816}