1use std::collections::BTreeMap;
19use std::fs::{self, File};
20use std::io::{Read, Seek, SeekFrom};
21use std::path::{Path, PathBuf};
22
23use chrono::{DateTime, Datelike, FixedOffset, NaiveDateTime, TimeZone};
24
25use crate::store::Store;
26
27const TS_FORMAT: &str = "%Y-%m-%d %H:%M";
31
32const LOG_FRONTMATTER: &str = "---\ntype: log\n---\n\n# Curator log\n";
34
35const REVERSE_BLOCK: usize = 8 * 1024;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum LogKind {
44 Ingest,
46 Create,
48 Update,
50 Delete,
52 Rename,
54 Link,
56 Validate,
58 IndexRebuild,
60 Contradiction,
62 Custom(String),
64}
65
66impl LogKind {
67 pub fn as_str(&self) -> &str {
70 match self {
71 LogKind::Ingest => "ingest",
72 LogKind::Create => "create",
73 LogKind::Update => "update",
74 LogKind::Delete => "delete",
75 LogKind::Rename => "rename",
76 LogKind::Link => "link",
77 LogKind::Validate => "validate",
78 LogKind::IndexRebuild => "index-rebuild",
79 LogKind::Contradiction => "contradiction",
80 LogKind::Custom(s) => s,
81 }
82 }
83
84 pub fn parse(token: &str) -> LogKind {
87 match token {
88 "ingest" => LogKind::Ingest,
89 "create" => LogKind::Create,
90 "update" => LogKind::Update,
91 "delete" => LogKind::Delete,
92 "rename" => LogKind::Rename,
93 "link" => LogKind::Link,
94 "validate" => LogKind::Validate,
95 "index-rebuild" => LogKind::IndexRebuild,
96 "contradiction" => LogKind::Contradiction,
97 other => LogKind::Custom(other.to_string()),
98 }
99 }
100
101 pub fn is_recognized(&self) -> bool {
104 !matches!(self, LogKind::Custom(_))
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct LogEntry {
112 pub timestamp: DateTime<FixedOffset>,
114 pub kind: LogKind,
116 pub object: Option<String>,
119 pub note: String,
121}
122
123impl LogEntry {
124 fn render(&self) -> String {
128 let ts = self.timestamp.format(TS_FORMAT);
129 let mut out = String::new();
130 match &self.object {
131 Some(obj) => {
132 out.push_str(&format!("## [{}] {} | {}\n", ts, self.kind.as_str(), obj));
133 }
134 None => {
135 out.push_str(&format!("## [{}] {}\n", ts, self.kind.as_str()));
136 }
137 }
138 let note = self.note.trim_end_matches(['\n', '\r', ' ', '\t']);
139 if !note.is_empty() {
140 out.push_str(note);
141 out.push('\n');
142 }
143 out.push('\n');
144 out
145 }
146
147 fn year_month(&self) -> (i32, u32) {
150 (self.timestamp.year(), self.timestamp.month())
151 }
152}
153
154#[derive(Debug, Clone)]
158pub struct Log;
159
160impl Log {
161 pub fn append(store: &Store, entry: &LogEntry) -> crate::Result<()> {
166 let active = active_log_path(store);
167
168 let current_ym = entry.year_month();
172
173 if active.exists() {
174 let content = fs::read_to_string(&active)?;
175 let (header, entries) = parse_active(&content);
176
177 let mut by_month: BTreeMap<(i32, u32), Vec<LogEntry>> = BTreeMap::new();
180 let mut keep: Vec<LogEntry> = Vec::new();
181 for e in entries {
182 if e.year_month() < current_ym {
183 by_month.entry(e.year_month()).or_default().push(e);
184 } else {
185 keep.push(e);
186 }
187 }
188
189 if !by_month.is_empty() {
190 let dir = archive_dir(store);
193 fs::create_dir_all(&dir)?;
194 for ((y, m), month_entries) in &by_month {
195 let path = archive_path(store, *y, *m);
196 append_to_archive(&path, month_entries)?;
197 }
198
199 let mut body = String::new();
202 for e in &keep {
203 body.push_str(&e.render());
204 }
205 body.push_str(&entry.render());
206 let full = compose_active(&header, &body);
207 crate::fsx::write_atomic(&active, full.as_bytes())?;
208 return Ok(());
209 }
210
211 let mut full = content;
213 if !full.ends_with('\n') {
214 full.push('\n');
215 }
216 full.push_str(&entry.render());
217 crate::fsx::write_atomic(&active, full.as_bytes())?;
218 Ok(())
219 } else {
220 if let Some(parent) = active.parent() {
222 fs::create_dir_all(parent)?;
223 }
224 let body = entry.render();
225 let full = compose_active(LOG_FRONTMATTER, &body);
226 crate::fsx::write_atomic(&active, full.as_bytes())?;
227 Ok(())
228 }
229 }
230
231 pub fn tail(store: &Store, n: usize) -> crate::Result<Vec<LogEntry>> {
255 if n == 0 {
256 return Ok(Vec::new());
257 }
258
259 let mut window = NewestWindow::new(n);
263
264 let active = active_log_path(store);
266 if active.exists() {
267 reverse_collect(&active, |e| {
268 window.consider(e);
269 false
270 })?;
271 }
272
273 for archive in list_archives_desc(store)? {
278 if let (true, Some(cutoff_ym), Some(arch_ym)) = (
279 window.is_full(),
280 window.min_year_month(),
281 archive_year_month(&archive),
282 ) {
283 if arch_ym < cutoff_ym {
284 break;
285 }
286 }
287 reverse_collect(&archive, |e| {
288 window.consider(e);
289 false
290 })?;
291 }
292
293 Ok(window.into_sorted())
294 }
295
296 pub fn since(store: &Store, time: DateTime<FixedOffset>) -> crate::Result<Vec<LogEntry>> {
319 let mut collected: Vec<LogEntry> = Vec::new();
320
321 let active = active_log_path(store);
323 if active.exists() {
324 reverse_collect(&active, |e| {
325 if e.timestamp > time {
326 collected.push(e);
327 }
328 false
329 })?;
330 }
331
332 let cutoff_ym = (time.year(), time.month());
335
336 for archive in list_archives_desc(store)? {
337 if let Some(arch_ym) = archive_year_month(&archive) {
340 if arch_ym < cutoff_ym {
341 break;
342 }
343 }
344 reverse_collect(&archive, |e| {
347 if e.timestamp > time {
348 collected.push(e);
349 }
350 false
351 })?;
352 }
353
354 collected.reverse();
355 Ok(collected)
356 }
357
358 pub fn last_validate_at(store: &Store) -> crate::Result<Option<DateTime<FixedOffset>>> {
361 let mut found: Option<DateTime<FixedOffset>> = None;
362
363 let active = active_log_path(store);
364 if active.exists() {
365 reverse_collect(&active, |e| {
366 if e.kind == LogKind::Validate {
367 found = Some(e.timestamp);
368 true
369 } else {
370 false
371 }
372 })?;
373 }
374
375 if found.is_none() {
376 for archive in list_archives_desc(store)? {
377 reverse_collect(&archive, |e| {
378 if e.kind == LogKind::Validate {
379 found = Some(e.timestamp);
380 true
381 } else {
382 false
383 }
384 })?;
385 if found.is_some() {
386 break;
387 }
388 }
389 }
390
391 Ok(found)
392 }
393
394 pub fn parse_header(line: &str) -> Option<(DateTime<FixedOffset>, LogKind, Option<String>)> {
398 let line = line.trim_end_matches(['\n', '\r']);
399 let rest = line.strip_prefix("## [")?;
400 let close = rest.find(']')?;
401 let ts_str = &rest[..close];
402 let timestamp = parse_timestamp(ts_str)?;
403
404 let after = rest[close + 1..].trim();
407 if after.is_empty() {
408 return None;
409 }
410
411 let (kind_str, object) = match after.split_once('|') {
412 Some((k, o)) => {
413 let obj = o.trim();
414 let obj = if obj.is_empty() {
415 None
416 } else {
417 Some(obj.to_string())
418 };
419 (k.trim(), obj)
420 }
421 None => (after, None),
422 };
423
424 if kind_str.is_empty() {
425 return None;
426 }
427
428 Some((timestamp, LogKind::parse(kind_str), object))
429 }
430}
431
432struct NewestWindow {
451 cap: usize,
452 heap: std::collections::BinaryHeap<WindowItem>,
455 next_arrival: u64,
458}
459
460impl NewestWindow {
461 fn new(cap: usize) -> Self {
462 NewestWindow {
463 cap,
464 heap: std::collections::BinaryHeap::with_capacity(cap),
465 next_arrival: 0,
466 }
467 }
468
469 fn consider(&mut self, entry: LogEntry) {
474 let arrival = self.next_arrival;
475 self.next_arrival += 1;
476
477 if self.heap.len() < self.cap {
478 self.heap.push(WindowItem { entry, arrival });
479 return;
480 }
481
482 let root = self.heap.peek().expect("full window has a root");
485 if entry.timestamp > root.entry.timestamp {
486 self.heap.pop();
488 self.heap.push(WindowItem { entry, arrival });
489 }
490 }
496
497 fn is_full(&self) -> bool {
499 self.heap.len() >= self.cap
500 }
501
502 fn min_year_month(&self) -> Option<(i32, u32)> {
506 self.heap
507 .peek()
508 .map(|item| (item.entry.timestamp.year(), item.entry.timestamp.month()))
509 }
510
511 fn into_sorted(self) -> Vec<LogEntry> {
514 let mut items: Vec<WindowItem> = self.heap.into_vec();
515 items.sort_by(|a, b| {
518 a.entry
519 .timestamp
520 .cmp(&b.entry.timestamp)
521 .then(b.arrival.cmp(&a.arrival))
522 });
523 items.into_iter().map(|i| i.entry).collect()
524 }
525}
526
527struct WindowItem {
532 entry: LogEntry,
533 arrival: u64,
534}
535
536impl PartialEq for WindowItem {
537 fn eq(&self, other: &Self) -> bool {
538 self.entry.timestamp == other.entry.timestamp && self.arrival == other.arrival
539 }
540}
541impl Eq for WindowItem {}
542
543impl Ord for WindowItem {
544 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
545 other
549 .entry
550 .timestamp
551 .cmp(&self.entry.timestamp)
552 .then(self.arrival.cmp(&other.arrival))
553 }
554}
555impl PartialOrd for WindowItem {
556 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
557 Some(self.cmp(other))
558 }
559}
560
561fn active_log_path(store: &Store) -> PathBuf {
563 store.root.join("log.md")
564}
565
566fn archive_dir(store: &Store) -> PathBuf {
568 store.root.join("log")
569}
570
571fn archive_path(store: &Store, year: i32, month: u32) -> PathBuf {
573 archive_dir(store).join(format!("{:04}-{:02}.md", year, month))
574}
575
576fn parse_timestamp(s: &str) -> Option<DateTime<FixedOffset>> {
579 let naive = NaiveDateTime::parse_from_str(s.trim(), TS_FORMAT).ok()?;
580 let utc = FixedOffset::east_opt(0)?;
581 utc.from_local_datetime(&naive).single()
582}
583
584fn parse_active(content: &str) -> (String, Vec<LogEntry>) {
589 match find_first_header(content) {
590 Some(idx) => {
591 let header = content[..idx].to_string();
592 let entries = parse_entries(&content[idx..]);
593 (header, entries)
594 }
595 None => (content.to_string(), Vec::new()),
596 }
597}
598
599fn find_first_header(content: &str) -> Option<usize> {
602 if content.starts_with("## [") {
603 return Some(0);
604 }
605 content.match_indices("\n## [").next().map(|(i, _)| i + 1)
606}
607
608fn parse_entries(text: &str) -> Vec<LogEntry> {
613 let mut entries: Vec<LogEntry> = Vec::new();
614 let mut cur_header: Option<(DateTime<FixedOffset>, LogKind, Option<String>)> = None;
615 let mut cur_note: Vec<&str> = Vec::new();
616
617 let flush = |entries: &mut Vec<LogEntry>,
618 header: &mut Option<(DateTime<FixedOffset>, LogKind, Option<String>)>,
619 note: &mut Vec<&str>| {
620 if let Some((timestamp, kind, object)) = header.take() {
621 let joined = note.join("\n");
622 let note_str = joined.trim_matches(['\n', '\r']).to_string();
623 entries.push(LogEntry {
624 timestamp,
625 kind,
626 object,
627 note: note_str,
628 });
629 }
630 note.clear();
631 };
632
633 for line in text.lines() {
634 if line.starts_with("## [") {
635 if let Some(parsed) = Log::parse_header(line) {
636 flush(&mut entries, &mut cur_header, &mut cur_note);
638 cur_header = Some(parsed);
639 continue;
640 }
641 }
643 if cur_header.is_some() {
644 cur_note.push(line);
645 }
646 }
647 flush(&mut entries, &mut cur_header, &mut cur_note);
648 entries
649}
650
651fn compose_active(header: &str, body: &str) -> String {
653 let mut out = String::new();
654 out.push_str(header);
655 if !header.is_empty() && !header.ends_with('\n') {
656 out.push('\n');
657 }
658 if !header.is_empty() && !out.ends_with("\n\n") {
660 out.push('\n');
661 }
662 out.push_str(body);
663 out
664}
665
666fn append_to_archive(path: &Path, entries: &[LogEntry]) -> crate::Result<()> {
670 let mut body = String::new();
671 for e in entries {
672 body.push_str(&e.render());
673 }
674
675 if path.exists() {
676 let existing = fs::read_to_string(path)?;
677 let mut full = existing;
678 if !full.ends_with('\n') {
679 full.push('\n');
680 }
681 full.push_str(&body);
682 crate::fsx::write_atomic(path, full.as_bytes())?;
683 } else {
684 if let Some(parent) = path.parent() {
685 fs::create_dir_all(parent)?;
686 }
687 let full = compose_active(LOG_FRONTMATTER, &body);
688 crate::fsx::write_atomic(path, full.as_bytes())?;
689 }
690 Ok(())
691}
692
693fn list_archives_desc(store: &Store) -> crate::Result<Vec<PathBuf>> {
695 let dir = archive_dir(store);
696 if !dir.is_dir() {
697 return Ok(Vec::new());
698 }
699 let mut months: Vec<(String, PathBuf)> = Vec::new();
700 for entry in fs::read_dir(&dir)? {
701 let entry = entry?;
702 let path = entry.path();
703 if !path.is_file() {
704 continue;
705 }
706 let name = match path.file_name().and_then(|s| s.to_str()) {
707 Some(n) => n,
708 None => continue,
709 };
710 if let Some(stem) = name.strip_suffix(".md") {
712 if is_year_month(stem) {
713 months.push((stem.to_string(), path.clone()));
714 }
715 }
716 }
717 months.sort_by(|a, b| b.0.cmp(&a.0));
720 Ok(months.into_iter().map(|(_, p)| p).collect())
721}
722
723fn archive_year_month(path: &Path) -> Option<(i32, u32)> {
727 let stem = path
728 .file_name()
729 .and_then(|s| s.to_str())
730 .and_then(|n| n.strip_suffix(".md"))?;
731 if !is_year_month(stem) {
732 return None;
733 }
734 let year: i32 = stem[..4].parse().ok()?;
735 let month: u32 = stem[5..7].parse().ok()?;
736 Some((year, month))
737}
738
739fn is_year_month(s: &str) -> bool {
741 let bytes = s.as_bytes();
742 if bytes.len() != 7 {
743 return false;
744 }
745 bytes[..4].iter().all(u8::is_ascii_digit)
746 && bytes[4] == b'-'
747 && bytes[5].is_ascii_digit()
748 && bytes[6].is_ascii_digit()
749}
750
751fn reverse_collect<F>(path: &Path, mut take: F) -> crate::Result<()>
756where
757 F: FnMut(LogEntry) -> bool,
758{
759 let mut file = File::open(path)?;
760 let len = file.metadata()?.len();
761 if len == 0 {
762 return Ok(());
763 }
764
765 let mut buf: Vec<u8> = Vec::new();
780 let mut start = len;
781 let mut emitted_abs: std::collections::HashSet<u64> = std::collections::HashSet::new();
784 let mut stop = false;
785
786 while start > 0 && !stop {
787 let block = std::cmp::min(REVERSE_BLOCK as u64, start);
788 let new_start = start - block;
789 file.seek(SeekFrom::Start(new_start))?;
790 let mut chunk = vec![0u8; block as usize];
791 file.read_exact(&mut chunk)?;
792 chunk.extend_from_slice(&buf);
793 buf = chunk;
794 start = new_start;
795
796 let headers = header_offsets(&buf, start);
799
800 for i in (0..headers.len()).rev() {
808 let abs = headers[i];
809 if emitted_abs.contains(&abs) {
810 continue;
811 }
812 let is_oldest_in_buf = i == 0;
813 if is_oldest_in_buf && start > 0 {
814 continue;
815 }
816
817 let entry_text = entry_text_at(&buf, start, abs, &headers, i);
818 if let Some(entry) = parse_single_entry(&entry_text) {
819 emitted_abs.insert(abs);
820 if take(entry) {
821 stop = true;
822 break;
823 }
824 } else {
825 emitted_abs.insert(abs);
826 }
827 }
828 }
829
830 if !stop && start == 0 {
834 let headers = header_offsets(&buf, start);
835 for i in (0..headers.len()).rev() {
836 let abs = headers[i];
837 if emitted_abs.contains(&abs) {
838 continue;
839 }
840 let entry_text = entry_text_at(&buf, start, abs, &headers, i);
841 if let Some(entry) = parse_single_entry(&entry_text) {
842 emitted_abs.insert(abs);
843 if take(entry) {
844 break;
845 }
846 } else {
847 emitted_abs.insert(abs);
848 }
849 }
850 }
851
852 Ok(())
853}
854
855fn header_offsets(buf: &[u8], base: u64) -> Vec<u64> {
858 const PAT: &[u8] = b"## [";
859 let mut out = Vec::new();
860 let n = buf.len();
861 let mut i = 0;
862 while i + PAT.len() <= n {
863 if &buf[i..i + PAT.len()] == PAT {
864 let at_line_start = i == 0 || buf[i - 1] == b'\n';
865 if at_line_start {
866 out.push(base + i as u64);
867 i += PAT.len();
869 continue;
870 }
871 }
872 i += 1;
873 }
874 out
875}
876
877fn entry_text_at(buf: &[u8], base: u64, header_abs: u64, headers: &[u64], idx: usize) -> String {
881 let rel_start = (header_abs - base) as usize;
882 let rel_end = if idx + 1 < headers.len() {
883 (headers[idx + 1] - base) as usize
884 } else {
885 buf.len()
886 };
887 String::from_utf8_lossy(&buf[rel_start..rel_end]).into_owned()
888}
889
890fn parse_single_entry(text: &str) -> Option<LogEntry> {
892 parse_entries(text).into_iter().next()
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898 use crate::parser::Config;
899 use std::fs;
900 use tempfile::TempDir;
901
902 fn temp_store() -> (TempDir, Store) {
906 let dir = tempfile::tempdir().expect("tempdir");
907 fs::write(dir.path().join("DB.md"), "---\ntype: db-md\n---\n").expect("write DB.md");
908 let store = Store {
909 root: dir.path().to_path_buf(),
910 config: Config::default(),
911 };
912 (dir, store)
913 }
914
915 fn ts(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<FixedOffset> {
917 let naive = chrono::NaiveDate::from_ymd_opt(y, mo, d)
918 .unwrap()
919 .and_hms_opt(h, mi, 0)
920 .unwrap();
921 FixedOffset::east_opt(0)
922 .unwrap()
923 .from_local_datetime(&naive)
924 .single()
925 .unwrap()
926 }
927
928 #[allow(clippy::too_many_arguments)] fn entry(
930 y: i32,
931 mo: u32,
932 d: u32,
933 h: u32,
934 mi: u32,
935 kind: LogKind,
936 object: Option<&str>,
937 note: &str,
938 ) -> LogEntry {
939 LogEntry {
940 timestamp: ts(y, mo, d, h, mi),
941 kind,
942 object: object.map(|s| s.to_string()),
943 note: note.to_string(),
944 }
945 }
946
947 #[test]
950 fn parse_header_with_object() {
951 let (t, k, o) =
952 Log::parse_header("## [2026-05-27 10:00] ingest | sources/emails/x.eml").unwrap();
953 assert_eq!(t, ts(2026, 5, 27, 10, 0));
954 assert_eq!(k, LogKind::Ingest);
955 assert_eq!(o.as_deref(), Some("sources/emails/x.eml"));
956 }
957
958 #[test]
959 fn parse_header_without_object_is_none_object() {
960 let (t, k, o) = Log::parse_header("## [2026-05-27 10:20] validate").unwrap();
961 assert_eq!(t, ts(2026, 5, 27, 10, 20));
962 assert_eq!(k, LogKind::Validate);
963 assert_eq!(o, None);
964 }
965
966 #[test]
967 fn parse_header_custom_kind_roundtrips_token() {
968 let (_, k, o) = Log::parse_header("## [2026-05-27 10:00] proposal | records/x").unwrap();
969 assert_eq!(k, LogKind::Custom("proposal".to_string()));
970 assert!(!k.is_recognized());
971 assert_eq!(o.as_deref(), Some("records/x"));
972 }
973
974 #[test]
975 fn parse_header_index_rebuild_hyphenated_kind() {
976 let (_, k, _) = Log::parse_header("## [2026-05-27 10:00] index-rebuild").unwrap();
977 assert_eq!(k, LogKind::IndexRebuild);
978 assert_eq!(k.as_str(), "index-rebuild");
979 }
980
981 #[test]
982 fn parse_header_rejects_non_headers() {
983 assert!(Log::parse_header("Not a header").is_none());
984 assert!(Log::parse_header("# Curator log").is_none());
985 assert!(Log::parse_header("## [garbage] ingest | x").is_none());
986 assert!(Log::parse_header("## [2026-05-27 10:00]").is_none()); assert!(Log::parse_header("## [2026-13-40 99:99] ingest | x").is_none());
989 }
990
991 #[test]
994 fn kind_as_str_parse_roundtrip_for_all_recognized() {
995 for k in [
996 LogKind::Ingest,
997 LogKind::Create,
998 LogKind::Update,
999 LogKind::Delete,
1000 LogKind::Rename,
1001 LogKind::Link,
1002 LogKind::Validate,
1003 LogKind::IndexRebuild,
1004 LogKind::Contradiction,
1005 ] {
1006 assert_eq!(LogKind::parse(k.as_str()), k);
1007 assert!(k.is_recognized());
1008 }
1009 }
1010
1011 #[test]
1014 fn append_creates_log_with_frontmatter_and_entry() {
1015 let (_d, store) = temp_store();
1016 let e = entry(
1017 2026,
1018 5,
1019 27,
1020 10,
1021 0,
1022 LogKind::Ingest,
1023 Some("sources/emails/x.eml"),
1024 "Email received.",
1025 );
1026 Log::append(&store, &e).unwrap();
1027
1028 let content = fs::read_to_string(store.root.join("log.md")).unwrap();
1029 assert!(
1031 content.starts_with("---\ntype: log\n---\n"),
1032 "missing log frontmatter; got:\n{content}"
1033 );
1034 assert!(content.contains("## [2026-05-27 10:00] ingest | sources/emails/x.eml"));
1036 assert!(content.contains("Email received."));
1037 assert!(!store.root.join("log").exists());
1039 }
1040
1041 #[test]
1044 fn append_tail_since_roundtrip() {
1045 let (_d, store) = temp_store();
1046 let e1 = entry(2026, 5, 27, 10, 0, LogKind::Ingest, Some("a"), "first");
1047 let e2 = entry(2026, 5, 27, 10, 5, LogKind::Create, Some("b"), "second");
1048 let e3 = entry(2026, 5, 27, 10, 10, LogKind::Update, Some("c"), "third");
1049 Log::append(&store, &e1).unwrap();
1050 Log::append(&store, &e2).unwrap();
1051 Log::append(&store, &e3).unwrap();
1052
1053 let tail = Log::tail(&store, 2).unwrap();
1055 assert_eq!(tail.len(), 2);
1056 assert_eq!(tail[0], e2);
1057 assert_eq!(tail[1], e3);
1058
1059 let all = Log::tail(&store, 99).unwrap();
1061 assert_eq!(all, vec![e1.clone(), e2.clone(), e3.clone()]);
1062
1063 let since = Log::since(&store, ts(2026, 5, 27, 10, 5)).unwrap();
1065 assert_eq!(since, vec![e3.clone()]);
1066
1067 let since_all = Log::since(&store, ts(2026, 5, 27, 9, 0)).unwrap();
1069 assert_eq!(since_all, vec![e1, e2, e3]);
1070 }
1071
1072 #[test]
1073 fn tail_zero_is_empty() {
1074 let (_d, store) = temp_store();
1075 Log::append(
1076 &store,
1077 &entry(2026, 5, 27, 10, 0, LogKind::Ingest, Some("a"), "x"),
1078 )
1079 .unwrap();
1080 assert!(Log::tail(&store, 0).unwrap().is_empty());
1081 }
1082
1083 #[test]
1084 fn tail_and_since_on_missing_log_are_empty() {
1085 let (_d, store) = temp_store();
1086 assert!(Log::tail(&store, 5).unwrap().is_empty());
1087 assert!(Log::since(&store, ts(2000, 1, 1, 0, 0)).unwrap().is_empty());
1088 assert!(Log::last_validate_at(&store).unwrap().is_none());
1089 }
1090
1091 #[test]
1092 fn since_exact_timestamp_is_exclusive() {
1093 let (_d, store) = temp_store();
1094 let e = entry(2026, 5, 27, 10, 0, LogKind::Validate, None, "PASS");
1095 Log::append(&store, &e).unwrap();
1096 assert!(Log::since(&store, ts(2026, 5, 27, 10, 0))
1098 .unwrap()
1099 .is_empty());
1100 }
1101
1102 fn write_raw_log(store: &Store, entries: &[LogEntry]) {
1111 let mut content = String::from(LOG_FRONTMATTER);
1112 content.push('\n');
1113 for e in entries {
1114 content.push_str(&e.render());
1115 }
1116 fs::write(store.root.join("log.md"), content).expect("write raw log.md");
1117 }
1118
1119 #[test]
1120 fn since_returns_newer_entries_even_when_disk_order_is_non_monotonic() {
1121 let (_d, store) = temp_store();
1127 let e_1010 = entry(2026, 5, 27, 10, 10, LogKind::Update, Some("c"), "newest");
1128 let e_1005 = entry(2026, 5, 27, 10, 5, LogKind::Create, Some("b"), "middle");
1129 let e_1000 = entry(
1130 2026,
1131 5,
1132 27,
1133 10,
1134 0,
1135 LogKind::Update,
1136 Some("a"),
1137 "backdated fix",
1138 );
1139 write_raw_log(&store, &[e_1010, e_1005, e_1000]);
1141
1142 let got = Log::since(&store, ts(2026, 5, 27, 10, 2)).unwrap();
1147 let stamps: std::collections::BTreeSet<_> = got.iter().map(|e| e.timestamp).collect();
1148 assert_eq!(
1149 stamps,
1150 [ts(2026, 5, 27, 10, 5), ts(2026, 5, 27, 10, 10)]
1151 .into_iter()
1152 .collect(),
1153 "since(10:02) must include both 10:05 and 10:10 despite the backdated \
1154 10:00 entry sitting physically last, and exclude 10:00; got {got:?}"
1155 );
1156
1157 let all = Log::since(&store, ts(2026, 5, 27, 9, 0)).unwrap();
1160 let all_stamps: std::collections::BTreeSet<_> = all.iter().map(|e| e.timestamp).collect();
1161 assert_eq!(
1162 all_stamps,
1163 [
1164 ts(2026, 5, 27, 10, 0),
1165 ts(2026, 5, 27, 10, 5),
1166 ts(2026, 5, 27, 10, 10),
1167 ]
1168 .into_iter()
1169 .collect()
1170 );
1171 }
1172
1173 #[test]
1174 fn since_crosses_archive_when_newer_entry_is_out_of_order_inside_it() {
1175 let (_d, store) = temp_store();
1181
1182 let may = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may1");
1184 write_raw_log(&store, &[may]);
1185
1186 let apr_late = entry(
1188 2026,
1189 4,
1190 20,
1191 9,
1192 0,
1193 LogKind::Create,
1194 Some("apr-b"),
1195 "apr-late",
1196 );
1197 let apr_early = entry(
1198 2026,
1199 4,
1200 5,
1201 9,
1202 0,
1203 LogKind::Ingest,
1204 Some("apr-a"),
1205 "apr-early",
1206 );
1207 let dir = store.root.join("log");
1208 fs::create_dir_all(&dir).unwrap();
1209 let mut arch = String::from(LOG_FRONTMATTER);
1210 arch.push('\n');
1211 arch.push_str(&apr_late.render());
1212 arch.push_str(&apr_early.render());
1213 fs::write(dir.join("2026-04.md"), arch).unwrap();
1214
1215 let got = Log::since(&store, ts(2026, 4, 15, 0, 0)).unwrap();
1218 let stamps: std::collections::BTreeSet<_> = got.iter().map(|e| e.timestamp).collect();
1219 assert_eq!(
1220 stamps,
1221 [ts(2026, 4, 20, 9, 0), ts(2026, 5, 2, 8, 0)]
1222 .into_iter()
1223 .collect(),
1224 "since(mid-April) must include the out-of-order later April entry \
1225 and the May entry, and exclude the earlier April entry; got {got:?}"
1226 );
1227 }
1228
1229 #[test]
1232 fn multiline_note_is_preserved() {
1233 let (_d, store) = temp_store();
1234 let e = entry(
1235 2026,
1236 5,
1237 27,
1238 10,
1239 0,
1240 LogKind::Create,
1241 Some("records/x"),
1242 "Line one.\nLine two.\nLine three.",
1243 );
1244 Log::append(&store, &e).unwrap();
1245 let got = Log::tail(&store, 1).unwrap();
1246 assert_eq!(got[0].note, "Line one.\nLine two.\nLine three.");
1247 }
1248
1249 #[test]
1250 fn empty_note_roundtrips_as_empty() {
1251 let (_d, store) = temp_store();
1252 let e = entry(2026, 5, 27, 10, 0, LogKind::Validate, None, "");
1253 Log::append(&store, &e).unwrap();
1254 let got = Log::tail(&store, 1).unwrap();
1255 assert_eq!(got[0], e);
1256 assert_eq!(got[0].note, "");
1257 }
1258
1259 #[test]
1262 fn last_validate_at_finds_most_recent_validate() {
1263 let (_d, store) = temp_store();
1264 Log::append(
1265 &store,
1266 &entry(2026, 5, 27, 10, 0, LogKind::Validate, None, "first pass"),
1267 )
1268 .unwrap();
1269 Log::append(
1270 &store,
1271 &entry(2026, 5, 27, 10, 5, LogKind::Create, Some("a"), "made a"),
1272 )
1273 .unwrap();
1274 Log::append(
1275 &store,
1276 &entry(2026, 5, 27, 10, 10, LogKind::Validate, None, "second pass"),
1277 )
1278 .unwrap();
1279 Log::append(
1280 &store,
1281 &entry(2026, 5, 27, 10, 15, LogKind::Update, Some("a"), "edit a"),
1282 )
1283 .unwrap();
1284
1285 let last = Log::last_validate_at(&store).unwrap();
1286 assert_eq!(last, Some(ts(2026, 5, 27, 10, 10)));
1287 }
1288
1289 #[test]
1290 fn last_validate_at_none_when_no_validate() {
1291 let (_d, store) = temp_store();
1292 Log::append(
1293 &store,
1294 &entry(2026, 5, 27, 10, 0, LogKind::Create, Some("a"), "x"),
1295 )
1296 .unwrap();
1297 assert_eq!(Log::last_validate_at(&store).unwrap(), None);
1298 }
1299
1300 #[test]
1303 fn rotation_rolls_prior_months_into_archives() {
1304 let (_d, store) = temp_store();
1305 let a1 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("apr-a"), "apr one");
1308 let a2 = entry(2026, 4, 20, 9, 0, LogKind::Create, Some("apr-b"), "apr two");
1309 Log::append(&store, &a1).unwrap();
1310 Log::append(&store, &a2).unwrap();
1311
1312 assert!(!store.root.join("log").exists());
1314
1315 let m1 = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may one");
1317 Log::append(&store, &m1).unwrap();
1318
1319 let arch_path = store.root.join("log").join("2026-04.md");
1321 assert!(arch_path.exists(), "expected April archive to be created");
1322 let arch = fs::read_to_string(&arch_path).unwrap();
1323 assert!(arch.starts_with("---\ntype: log\n---\n"));
1324 assert!(arch.contains("## [2026-04-10 09:00] ingest | apr-a"));
1325 assert!(arch.contains("## [2026-04-20 09:00] create | apr-b"));
1326 assert!(arch.contains("apr one"));
1327 assert!(arch.contains("apr two"));
1328
1329 let active = fs::read_to_string(store.root.join("log.md")).unwrap();
1331 assert!(active.contains("## [2026-05-02 08:00] update | may-a"));
1332 assert!(
1333 !active.contains("apr-a") && !active.contains("apr-b"),
1334 "April entries must be gone from the active file; got:\n{active}"
1335 );
1336
1337 let all = Log::tail(&store, 99).unwrap();
1339 assert_eq!(all, vec![a1, a2, m1]);
1340 }
1341
1342 #[test]
1343 fn rotation_groups_distinct_prior_months_into_separate_archives() {
1344 let (_d, store) = temp_store();
1345 let mar = entry(2026, 3, 5, 9, 0, LogKind::Ingest, Some("mar"), "march");
1348 let apr = entry(2026, 4, 5, 9, 0, LogKind::Create, Some("apr"), "april");
1349 Log::append(&store, &mar).unwrap();
1350 Log::append(&store, &apr).unwrap();
1351 assert!(store.root.join("log").join("2026-03.md").exists());
1353
1354 let may = entry(2026, 5, 5, 9, 0, LogKind::Update, Some("may"), "may");
1355 Log::append(&store, &may).unwrap();
1356
1357 assert!(store.root.join("log").join("2026-03.md").exists());
1358 assert!(store.root.join("log").join("2026-04.md").exists());
1359
1360 let mar_arch = fs::read_to_string(store.root.join("log").join("2026-03.md")).unwrap();
1362 let apr_arch = fs::read_to_string(store.root.join("log").join("2026-04.md")).unwrap();
1363 assert!(mar_arch.contains("mar") && !mar_arch.contains("apr"));
1364 assert!(apr_arch.contains("apr") && !apr_arch.contains("mar"));
1365
1366 let active = fs::read_to_string(store.root.join("log.md")).unwrap();
1368 assert!(active.contains("may") && !active.contains("mar") && !active.contains("apr"));
1369
1370 let all = Log::tail(&store, 99).unwrap();
1372 assert_eq!(all, vec![mar, apr, may]);
1373 }
1374
1375 #[test]
1376 fn tail_crosses_into_archive_when_n_spans_month_boundary() {
1377 let (_d, store) = temp_store();
1378 let a1 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("apr-a"), "apr1");
1379 let a2 = entry(2026, 4, 20, 9, 0, LogKind::Create, Some("apr-b"), "apr2");
1380 let m1 = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may1");
1381 let m2 = entry(2026, 5, 3, 8, 0, LogKind::Update, Some("may-b"), "may2");
1382 for e in [&a1, &a2, &m1, &m2] {
1383 Log::append(&store, e).unwrap();
1384 }
1385 let tail3 = Log::tail(&store, 3).unwrap();
1388 assert_eq!(tail3, vec![a2.clone(), m1.clone(), m2.clone()]);
1389
1390 let tail2 = Log::tail(&store, 2).unwrap();
1393 assert_eq!(tail2, vec![m1, m2]);
1394 }
1395
1396 #[test]
1397 fn since_crosses_into_archive_and_early_stops() {
1398 let (_d, store) = temp_store();
1399 let a1 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("apr-a"), "apr1");
1400 let a2 = entry(2026, 4, 20, 9, 0, LogKind::Create, Some("apr-b"), "apr2");
1401 let m1 = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may1");
1402 for e in [&a1, &a2, &m1] {
1403 Log::append(&store, e).unwrap();
1404 }
1405 let got = Log::since(&store, ts(2026, 4, 15, 0, 0)).unwrap();
1408 assert_eq!(got, vec![a2, m1]);
1409 }
1410
1411 #[test]
1412 fn last_validate_at_crosses_into_archive() {
1413 let (_d, store) = temp_store();
1414 Log::append(
1416 &store,
1417 &entry(2026, 4, 10, 9, 0, LogKind::Validate, None, "apr validate"),
1418 )
1419 .unwrap();
1420 Log::append(
1421 &store,
1422 &entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may work"),
1423 )
1424 .unwrap();
1425 let last = Log::last_validate_at(&store).unwrap();
1428 assert_eq!(last, Some(ts(2026, 4, 10, 9, 0)));
1429 }
1430
1431 #[test]
1434 fn reverse_read_correct_on_large_single_month_log() {
1435 let (_d, store) = temp_store();
1436 let n = 400usize;
1442 let mut expected: Vec<LogEntry> = Vec::new();
1443 for i in 0..n {
1444 let total_min = (i as u32) * 3;
1445 let day = 1 + total_min / (24 * 60);
1446 let hour = (total_min / 60) % 24;
1447 let min = total_min % 60;
1448 let note = format!(
1450 "entry number {i}\nbody line A for {i}\nbody line B for {i} with padding {}",
1451 "x".repeat(40)
1452 );
1453 let e = entry(
1454 2026,
1455 6,
1456 day,
1457 hour,
1458 min,
1459 LogKind::Update,
1460 Some(&format!("records/item-{i:04}")),
1461 ¬e,
1462 );
1463 Log::append(&store, &e).unwrap();
1464 expected.push(e);
1465 }
1466
1467 let size = fs::metadata(store.root.join("log.md")).unwrap().len();
1469 assert!(
1470 size > (REVERSE_BLOCK as u64) * 2,
1471 "test log not large enough ({size} bytes) to exercise multi-block reverse-read"
1472 );
1473
1474 let tail5 = Log::tail(&store, 5).unwrap();
1476 assert_eq!(tail5, expected[n - 5..].to_vec());
1477
1478 let tail50 = Log::tail(&store, 50).unwrap();
1480 assert_eq!(tail50, expected[n - 50..].to_vec());
1481
1482 let all = Log::tail(&store, n + 10).unwrap();
1484 assert_eq!(all.len(), n);
1485 assert_eq!(all, expected);
1486 }
1487
1488 fn write_log_physical(store: &Store, entries: &[LogEntry]) {
1501 let mut body = String::new();
1502 for e in entries {
1503 body.push_str(&e.render());
1504 }
1505 let full = compose_active(LOG_FRONTMATTER, &body);
1506 fs::write(store.root.join("log.md"), full).expect("write log.md");
1507 }
1508
1509 #[test]
1510 fn tail_returns_newest_by_timestamp_on_demonstrated_out_of_order_log() {
1511 let (_d, store) = temp_store();
1516 let e_1010 = entry(2026, 5, 27, 10, 10, LogKind::Update, Some("c"), "ten-ten");
1517 let e_1005 = entry(
1518 2026,
1519 5,
1520 27,
1521 10,
1522 5,
1523 LogKind::Create,
1524 Some("b"),
1525 "ten-oh-five",
1526 );
1527 let e_1000 = entry(2026, 5, 27, 10, 0, LogKind::Ingest, Some("a"), "ten-oh-oh");
1528 write_log_physical(&store, &[e_1010.clone(), e_1005.clone(), e_1000.clone()]);
1530
1531 let tail2 = Log::tail(&store, 2).unwrap();
1532 assert_eq!(
1533 tail2,
1534 vec![e_1005.clone(), e_1010.clone()],
1535 "tail(2) must be the two NEWEST by timestamp (chronological), \
1536 not the last two physical entries"
1537 );
1538 assert!(tail2.contains(&e_1010), "newest (10:10) must be included");
1540 assert!(!tail2.contains(&e_1000), "oldest (10:00) must be excluded");
1541
1542 assert_eq!(Log::tail(&store, 1).unwrap(), vec![e_1010.clone()]);
1544 assert_eq!(Log::tail(&store, 99).unwrap(), vec![e_1000, e_1005, e_1010]);
1546 }
1547
1548 #[test]
1549 fn tail_no_early_stop_when_newer_entry_sits_before_an_older_one() {
1550 let (_d, store) = temp_store();
1558 let e55 = entry(2026, 5, 27, 10, 55, LogKind::Update, Some("x55"), "55");
1559 let e10 = entry(2026, 5, 27, 10, 10, LogKind::Update, Some("x10"), "10");
1560 let e50 = entry(2026, 5, 27, 10, 50, LogKind::Update, Some("x50"), "50");
1561 let e00 = entry(2026, 5, 27, 10, 0, LogKind::Update, Some("x00"), "00");
1562 write_log_physical(
1563 &store,
1564 &[e55.clone(), e10.clone(), e50.clone(), e00.clone()],
1565 );
1566
1567 let tail2 = Log::tail(&store, 2).unwrap();
1570 assert_eq!(tail2, vec![e50.clone(), e55.clone()]);
1571
1572 let tail3 = Log::tail(&store, 3).unwrap();
1573 assert_eq!(tail3, vec![e10.clone(), e50.clone(), e55.clone()]);
1574 }
1575
1576 #[test]
1577 fn tail_orders_equal_timestamps_by_physical_recency() {
1578 let (_d, store) = temp_store();
1582 let early = entry(2026, 5, 27, 9, 59, LogKind::Create, Some("early"), "before");
1583 let tie_a = entry(
1584 2026,
1585 5,
1586 27,
1587 10,
1588 0,
1589 LogKind::Update,
1590 Some("tie-a"),
1591 "first 10:00",
1592 );
1593 let tie_b = entry(
1594 2026,
1595 5,
1596 27,
1597 10,
1598 0,
1599 LogKind::Update,
1600 Some("tie-b"),
1601 "second 10:00",
1602 );
1603 write_log_physical(&store, &[early.clone(), tie_a.clone(), tie_b.clone()]);
1605
1606 let tail2 = Log::tail(&store, 2).unwrap();
1607 assert_eq!(
1608 tail2,
1609 vec![tie_a.clone(), tie_b.clone()],
1610 "both 10:00 entries kept, physically-later one (tie_b) last; 09:59 dropped"
1611 );
1612 assert_eq!(Log::tail(&store, 1).unwrap(), vec![tie_b]);
1614 }
1615
1616 #[test]
1617 fn tail_finds_newest_across_a_backdated_entry_spanning_the_month_boundary() {
1618 let (_d, store) = temp_store();
1625 let may1 = entry(2026, 5, 10, 9, 0, LogKind::Ingest, Some("may-1"), "may one");
1626 let may2 = entry(2026, 5, 20, 9, 0, LogKind::Create, Some("may-2"), "may two");
1627 let jun1 = entry(2026, 6, 2, 8, 0, LogKind::Update, Some("jun-1"), "jun one");
1628 Log::append(&store, &may1).unwrap();
1629 Log::append(&store, &may2).unwrap();
1630 Log::append(&store, &jun1).unwrap(); assert!(store.root.join("log").join("2026-05.md").exists());
1632
1633 let may_corr = entry(
1637 2026,
1638 5,
1639 25,
1640 9,
1641 0,
1642 LogKind::Update,
1643 Some("may-2"),
1644 "may correction",
1645 );
1646 Log::append(&store, &may_corr).unwrap();
1647 let active = fs::read_to_string(store.root.join("log.md")).unwrap();
1648 assert!(
1649 active.contains("jun-1") && active.contains("may correction"),
1650 "backdated May entry should be in the active file alongside June; got:\n{active}"
1651 );
1652
1653 assert_eq!(Log::tail(&store, 1).unwrap(), vec![jun1.clone()]);
1656
1657 let tail2 = Log::tail(&store, 2).unwrap();
1659 assert_eq!(tail2, vec![may_corr.clone(), jun1.clone()]);
1660
1661 let tail3 = Log::tail(&store, 3).unwrap();
1664 assert_eq!(tail3, vec![may2.clone(), may_corr.clone(), jun1.clone()]);
1665
1666 let all = Log::tail(&store, 99).unwrap();
1668 assert_eq!(all, vec![may1, may2, may_corr, jun1]);
1669 }
1670
1671 #[test]
1672 fn parse_entries_skips_unparseable_header_folding_into_body() {
1673 let text = "\
1677## [2026-05-27 10:00] create | records/x
1678Body mentions a literal: ## [not a real header here]
1679More body.
1680
1681## [2026-05-27 10:05] update | records/y
1682Second.
1683";
1684 let entries = parse_entries(text);
1685 assert_eq!(entries.len(), 2);
1686 assert_eq!(entries[0].kind, LogKind::Create);
1687 assert!(entries[0].note.contains("## [not a real header here]"));
1688 assert!(entries[0].note.contains("More body."));
1689 assert_eq!(entries[1].kind, LogKind::Update);
1690 assert_eq!(entries[1].note, "Second.");
1691 }
1692
1693 #[test]
1696 fn append_only_corrective_entry_goes_on_end_without_rewriting() {
1697 let (_d, store) = temp_store();
1698 let original = entry(
1699 2026,
1700 5,
1701 27,
1702 10,
1703 0,
1704 LogKind::Update,
1705 Some("records/northstar"),
1706 "Seat count 120 -> 175.",
1707 );
1708 Log::append(&store, &original).unwrap();
1709 let after_first = fs::read_to_string(store.root.join("log.md")).unwrap();
1710
1711 let correction = entry(
1714 2026,
1715 5,
1716 27,
1717 11,
1718 0,
1719 LogKind::Update,
1720 Some("records/northstar"),
1721 "Correction: seat count is 165, not 175.",
1722 );
1723 Log::append(&store, &correction).unwrap();
1724 let after_second = fs::read_to_string(store.root.join("log.md")).unwrap();
1725
1726 assert!(
1727 after_second.starts_with(&after_first),
1728 "appending must not rewrite earlier bytes"
1729 );
1730 assert!(after_second.contains("Correction: seat count is 165, not 175."));
1731
1732 let all = Log::tail(&store, 99).unwrap();
1734 assert_eq!(all, vec![original, correction]);
1735 }
1736
1737 #[test]
1740 fn concurrent_appends_are_atomic_and_total() {
1741 use std::sync::{Arc, Barrier};
1742 use std::thread;
1743
1744 let (_d, store) = temp_store();
1745 Log::append(
1747 &store,
1748 &entry(2026, 7, 1, 0, 0, LogKind::Create, Some("seed"), "seed"),
1749 )
1750 .unwrap();
1751
1752 let threads = 8usize;
1753 let per = 25usize;
1754 let barrier = Arc::new(Barrier::new(threads));
1755 let store = Arc::new(store);
1756
1757 let mut handles = Vec::new();
1758 for tnum in 0..threads {
1759 let b = Arc::clone(&barrier);
1760 let s = Arc::clone(&store);
1761 handles.push(thread::spawn(move || {
1762 b.wait();
1763 for i in 0..per {
1764 let e = entry(
1765 2026,
1766 7,
1767 1,
1768 (tnum % 24) as u32,
1769 (i % 60) as u32,
1770 LogKind::Update,
1771 Some(&format!("t{tnum}-i{i}")),
1772 &format!("thread {tnum} item {i}"),
1773 );
1774 Log::append(&s, &e).unwrap();
1775 }
1776 }));
1777 }
1778 for h in handles {
1779 h.join().unwrap();
1780 }
1781
1782 let content = fs::read_to_string(store.root.join("log.md")).unwrap();
1790 assert!(content.starts_with("---\ntype: log\n---\n"));
1791
1792 for line in content.lines() {
1794 if line.starts_with("## [") {
1795 assert!(
1796 Log::parse_header(line).is_some(),
1797 "corrupt/torn header line on disk: {line:?}"
1798 );
1799 }
1800 }
1801
1802 assert!(content.contains("## [2026-07-01 00:00] create | seed"));
1805
1806 let all = Log::tail(&store, 10_000).unwrap();
1808 assert!(!all.is_empty());
1809 for e in &all {
1814 let rendered = e.render();
1815 let reparsed = parse_single_entry(&rendered).unwrap();
1816 assert_eq!(&reparsed, e);
1817 }
1818 }
1819
1820 #[test]
1823 fn render_then_parse_is_identity() {
1824 let cases = vec![
1825 entry(
1826 2026,
1827 1,
1828 2,
1829 3,
1830 4,
1831 LogKind::Ingest,
1832 Some("sources/a.eml"),
1833 "n",
1834 ),
1835 entry(
1836 2026,
1837 12,
1838 31,
1839 23,
1840 59,
1841 LogKind::Validate,
1842 None,
1843 "PASS - 0 errors",
1844 ),
1845 entry(
1846 2026,
1847 6,
1848 15,
1849 12,
1850 30,
1851 LogKind::Custom("proposal".to_string()),
1852 Some("records/p"),
1853 "multi\nline\nnote",
1854 ),
1855 entry(2026, 6, 15, 12, 30, LogKind::Contradiction, Some("obj"), ""),
1856 ];
1857 for e in cases {
1858 let rendered = e.render();
1859 let parsed = parse_single_entry(&rendered).unwrap_or_else(|| {
1860 panic!("failed to reparse rendered entry:\n{rendered}");
1861 });
1862 assert_eq!(parsed, e, "round-trip mismatch for {e:?}");
1863 }
1864 }
1865}