1use blockworx_doc::{commit::Commit, rev::Rev};
13
14use super::manifest::{self, RowKind};
15use super::record::WallTime;
16use super::tags::Tags;
17
18pub 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#[derive(Clone, Copy)]
35pub enum Journal<'a> {
36 Recorded(&'a [manifest::Row]),
37 Session(&'a [Commit]),
39}
40
41#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub struct Row {
45 pub rev: Rev,
46 pub label: String,
47 pub touched: usize,
51 pub written: Option<Written>,
54 pub tags: Vec<String>,
57}
58
59#[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 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#[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#[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 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 pub fn kind_of(&self) -> Kind {
148 match self.kind_written() {
149 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 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 pub fn undone(&self) -> Option<Rev> {
171 match self.kind_written() {
172 Some(RowKind::Undo { of }) => Some(of),
173 _ => None,
174 }
175 }
176
177 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 pub fn scope_names(&self) -> &[String] {
192 self.written
193 .as_ref()
194 .map_or(&[], |written| written.scope_names.as_slice())
195 }
196
197 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 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 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 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 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 pub fn when(&self) -> String {
266 self.when_in(&jiff::tz::TimeZone::system())
267 }
268
269 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
281enum Field {
282 Anywhere,
284 Tag,
285 Author,
286 Scope,
287 Rev,
289}
290
291impl Field {
292 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#[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 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#[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 pub fn admits(&self, row: &Row) -> bool {
368 self.terms.iter().all(|term| term.admits(row))
369 }
370
371 pub fn narrows(&self) -> bool {
374 !self.terms.is_empty()
375 }
376}
377
378#[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#[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
404pub fn humanize(elapsed: std::time::Duration) -> String {
408 timeago::Formatter::new().convert(elapsed)
409}
410
411pub 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
440pub fn lines(rows: &[Row]) -> Vec<String> {
444 lines_in(rows, &jiff::tz::TimeZone::system())
445}
446
447fn 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 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 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
501fn stamp(when: WallTime, zone: &jiff::tz::TimeZone) -> String {
505 written_as(when, zone, "%Y-%m-%d %H:%M:%S")
506}
507
508pub fn date(when: WallTime) -> String {
512 date_in(when, &jiff::tz::TimeZone::system())
513}
514
515pub 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
527fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}