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