1use std::collections::BTreeMap;
19use std::fs::{self, File};
20use std::io::{Read, Seek, SeekFrom, Write};
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 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 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 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 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 write_atomic(path, full.as_bytes())?;
689 }
690 Ok(())
691}
692
693fn write_atomic(dest: &Path, bytes: &[u8]) -> crate::Result<()> {
697 let dir = dest.parent().unwrap_or_else(|| Path::new("."));
698 fs::create_dir_all(dir)?;
699
700 static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
707 let pid = std::process::id();
708 let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
709 let file_name = dest
710 .file_name()
711 .and_then(|s| s.to_str())
712 .unwrap_or("log.md");
713 let tmp = dir.join(format!(".{}.{}.{}.tmp", file_name, pid, seq));
714
715 {
716 let mut f = File::create(&tmp)?;
717 f.write_all(bytes)?;
718 f.sync_all()?;
719 }
720 match fs::rename(&tmp, dest) {
722 Ok(()) => Ok(()),
723 Err(e) => {
724 let _ = fs::remove_file(&tmp);
725 Err(e.into())
726 }
727 }
728}
729
730fn list_archives_desc(store: &Store) -> crate::Result<Vec<PathBuf>> {
732 let dir = archive_dir(store);
733 if !dir.is_dir() {
734 return Ok(Vec::new());
735 }
736 let mut months: Vec<(String, PathBuf)> = Vec::new();
737 for entry in fs::read_dir(&dir)? {
738 let entry = entry?;
739 let path = entry.path();
740 if !path.is_file() {
741 continue;
742 }
743 let name = match path.file_name().and_then(|s| s.to_str()) {
744 Some(n) => n,
745 None => continue,
746 };
747 if let Some(stem) = name.strip_suffix(".md") {
749 if is_year_month(stem) {
750 months.push((stem.to_string(), path.clone()));
751 }
752 }
753 }
754 months.sort_by(|a, b| b.0.cmp(&a.0));
757 Ok(months.into_iter().map(|(_, p)| p).collect())
758}
759
760fn archive_year_month(path: &Path) -> Option<(i32, u32)> {
764 let stem = path
765 .file_name()
766 .and_then(|s| s.to_str())
767 .and_then(|n| n.strip_suffix(".md"))?;
768 if !is_year_month(stem) {
769 return None;
770 }
771 let year: i32 = stem[..4].parse().ok()?;
772 let month: u32 = stem[5..7].parse().ok()?;
773 Some((year, month))
774}
775
776fn is_year_month(s: &str) -> bool {
778 let bytes = s.as_bytes();
779 if bytes.len() != 7 {
780 return false;
781 }
782 bytes[..4].iter().all(u8::is_ascii_digit)
783 && bytes[4] == b'-'
784 && bytes[5].is_ascii_digit()
785 && bytes[6].is_ascii_digit()
786}
787
788fn reverse_collect<F>(path: &Path, mut take: F) -> crate::Result<()>
793where
794 F: FnMut(LogEntry) -> bool,
795{
796 let mut file = File::open(path)?;
797 let len = file.metadata()?.len();
798 if len == 0 {
799 return Ok(());
800 }
801
802 let mut buf: Vec<u8> = Vec::new();
817 let mut start = len;
818 let mut emitted_abs: Vec<u64> = Vec::new();
819 let mut stop = false;
820
821 while start > 0 && !stop {
822 let block = std::cmp::min(REVERSE_BLOCK as u64, start);
823 let new_start = start - block;
824 file.seek(SeekFrom::Start(new_start))?;
825 let mut chunk = vec![0u8; block as usize];
826 file.read_exact(&mut chunk)?;
827 chunk.extend_from_slice(&buf);
828 buf = chunk;
829 start = new_start;
830
831 let headers = header_offsets(&buf, start);
834
835 for i in (0..headers.len()).rev() {
843 let abs = headers[i];
844 if emitted_abs.contains(&abs) {
845 continue;
846 }
847 let is_oldest_in_buf = i == 0;
848 if is_oldest_in_buf && start > 0 {
849 continue;
850 }
851
852 let entry_text = entry_text_at(&buf, start, abs, &headers, i);
853 if let Some(entry) = parse_single_entry(&entry_text) {
854 emitted_abs.push(abs);
855 if take(entry) {
856 stop = true;
857 break;
858 }
859 } else {
860 emitted_abs.push(abs);
861 }
862 }
863 }
864
865 if !stop && start == 0 {
869 let headers = header_offsets(&buf, start);
870 for i in (0..headers.len()).rev() {
871 let abs = headers[i];
872 if emitted_abs.contains(&abs) {
873 continue;
874 }
875 let entry_text = entry_text_at(&buf, start, abs, &headers, i);
876 if let Some(entry) = parse_single_entry(&entry_text) {
877 emitted_abs.push(abs);
878 if take(entry) {
879 break;
880 }
881 } else {
882 emitted_abs.push(abs);
883 }
884 }
885 }
886
887 Ok(())
888}
889
890fn header_offsets(buf: &[u8], base: u64) -> Vec<u64> {
893 const PAT: &[u8] = b"## [";
894 let mut out = Vec::new();
895 let n = buf.len();
896 let mut i = 0;
897 while i + PAT.len() <= n {
898 if &buf[i..i + PAT.len()] == PAT {
899 let at_line_start = i == 0 || buf[i - 1] == b'\n';
900 if at_line_start {
901 out.push(base + i as u64);
902 i += PAT.len();
904 continue;
905 }
906 }
907 i += 1;
908 }
909 out
910}
911
912fn entry_text_at(buf: &[u8], base: u64, header_abs: u64, headers: &[u64], idx: usize) -> String {
916 let rel_start = (header_abs - base) as usize;
917 let rel_end = if idx + 1 < headers.len() {
918 (headers[idx + 1] - base) as usize
919 } else {
920 buf.len()
921 };
922 String::from_utf8_lossy(&buf[rel_start..rel_end]).into_owned()
923}
924
925fn parse_single_entry(text: &str) -> Option<LogEntry> {
927 parse_entries(text).into_iter().next()
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933 use crate::parser::Config;
934 use std::fs;
935 use tempfile::TempDir;
936
937 fn temp_store() -> (TempDir, Store) {
941 let dir = tempfile::tempdir().expect("tempdir");
942 fs::write(dir.path().join("DB.md"), "---\ntype: db-md\n---\n").expect("write DB.md");
943 let store = Store {
944 root: dir.path().to_path_buf(),
945 config: Config::default(),
946 };
947 (dir, store)
948 }
949
950 fn ts(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<FixedOffset> {
952 let naive = chrono::NaiveDate::from_ymd_opt(y, mo, d)
953 .unwrap()
954 .and_hms_opt(h, mi, 0)
955 .unwrap();
956 FixedOffset::east_opt(0)
957 .unwrap()
958 .from_local_datetime(&naive)
959 .single()
960 .unwrap()
961 }
962
963 #[allow(clippy::too_many_arguments)] fn entry(
965 y: i32,
966 mo: u32,
967 d: u32,
968 h: u32,
969 mi: u32,
970 kind: LogKind,
971 object: Option<&str>,
972 note: &str,
973 ) -> LogEntry {
974 LogEntry {
975 timestamp: ts(y, mo, d, h, mi),
976 kind,
977 object: object.map(|s| s.to_string()),
978 note: note.to_string(),
979 }
980 }
981
982 #[test]
985 fn parse_header_with_object() {
986 let (t, k, o) =
987 Log::parse_header("## [2026-05-27 10:00] ingest | sources/emails/x.eml").unwrap();
988 assert_eq!(t, ts(2026, 5, 27, 10, 0));
989 assert_eq!(k, LogKind::Ingest);
990 assert_eq!(o.as_deref(), Some("sources/emails/x.eml"));
991 }
992
993 #[test]
994 fn parse_header_without_object_is_none_object() {
995 let (t, k, o) = Log::parse_header("## [2026-05-27 10:20] validate").unwrap();
996 assert_eq!(t, ts(2026, 5, 27, 10, 20));
997 assert_eq!(k, LogKind::Validate);
998 assert_eq!(o, None);
999 }
1000
1001 #[test]
1002 fn parse_header_custom_kind_roundtrips_token() {
1003 let (_, k, o) = Log::parse_header("## [2026-05-27 10:00] proposal | records/x").unwrap();
1004 assert_eq!(k, LogKind::Custom("proposal".to_string()));
1005 assert!(!k.is_recognized());
1006 assert_eq!(o.as_deref(), Some("records/x"));
1007 }
1008
1009 #[test]
1010 fn parse_header_index_rebuild_hyphenated_kind() {
1011 let (_, k, _) = Log::parse_header("## [2026-05-27 10:00] index-rebuild").unwrap();
1012 assert_eq!(k, LogKind::IndexRebuild);
1013 assert_eq!(k.as_str(), "index-rebuild");
1014 }
1015
1016 #[test]
1017 fn parse_header_rejects_non_headers() {
1018 assert!(Log::parse_header("Not a header").is_none());
1019 assert!(Log::parse_header("# Curator log").is_none());
1020 assert!(Log::parse_header("## [garbage] ingest | x").is_none());
1021 assert!(Log::parse_header("## [2026-05-27 10:00]").is_none()); assert!(Log::parse_header("## [2026-13-40 99:99] ingest | x").is_none());
1024 }
1025
1026 #[test]
1029 fn kind_as_str_parse_roundtrip_for_all_recognized() {
1030 for k in [
1031 LogKind::Ingest,
1032 LogKind::Create,
1033 LogKind::Update,
1034 LogKind::Delete,
1035 LogKind::Rename,
1036 LogKind::Link,
1037 LogKind::Validate,
1038 LogKind::IndexRebuild,
1039 LogKind::Contradiction,
1040 ] {
1041 assert_eq!(LogKind::parse(k.as_str()), k);
1042 assert!(k.is_recognized());
1043 }
1044 }
1045
1046 #[test]
1049 fn append_creates_log_with_frontmatter_and_entry() {
1050 let (_d, store) = temp_store();
1051 let e = entry(
1052 2026,
1053 5,
1054 27,
1055 10,
1056 0,
1057 LogKind::Ingest,
1058 Some("sources/emails/x.eml"),
1059 "Email received.",
1060 );
1061 Log::append(&store, &e).unwrap();
1062
1063 let content = fs::read_to_string(store.root.join("log.md")).unwrap();
1064 assert!(
1066 content.starts_with("---\ntype: log\n---\n"),
1067 "missing log frontmatter; got:\n{content}"
1068 );
1069 assert!(content.contains("## [2026-05-27 10:00] ingest | sources/emails/x.eml"));
1071 assert!(content.contains("Email received."));
1072 assert!(!store.root.join("log").exists());
1074 }
1075
1076 #[test]
1079 fn append_tail_since_roundtrip() {
1080 let (_d, store) = temp_store();
1081 let e1 = entry(2026, 5, 27, 10, 0, LogKind::Ingest, Some("a"), "first");
1082 let e2 = entry(2026, 5, 27, 10, 5, LogKind::Create, Some("b"), "second");
1083 let e3 = entry(2026, 5, 27, 10, 10, LogKind::Update, Some("c"), "third");
1084 Log::append(&store, &e1).unwrap();
1085 Log::append(&store, &e2).unwrap();
1086 Log::append(&store, &e3).unwrap();
1087
1088 let tail = Log::tail(&store, 2).unwrap();
1090 assert_eq!(tail.len(), 2);
1091 assert_eq!(tail[0], e2);
1092 assert_eq!(tail[1], e3);
1093
1094 let all = Log::tail(&store, 99).unwrap();
1096 assert_eq!(all, vec![e1.clone(), e2.clone(), e3.clone()]);
1097
1098 let since = Log::since(&store, ts(2026, 5, 27, 10, 5)).unwrap();
1100 assert_eq!(since, vec![e3.clone()]);
1101
1102 let since_all = Log::since(&store, ts(2026, 5, 27, 9, 0)).unwrap();
1104 assert_eq!(since_all, vec![e1, e2, e3]);
1105 }
1106
1107 #[test]
1108 fn tail_zero_is_empty() {
1109 let (_d, store) = temp_store();
1110 Log::append(
1111 &store,
1112 &entry(2026, 5, 27, 10, 0, LogKind::Ingest, Some("a"), "x"),
1113 )
1114 .unwrap();
1115 assert!(Log::tail(&store, 0).unwrap().is_empty());
1116 }
1117
1118 #[test]
1119 fn tail_and_since_on_missing_log_are_empty() {
1120 let (_d, store) = temp_store();
1121 assert!(Log::tail(&store, 5).unwrap().is_empty());
1122 assert!(Log::since(&store, ts(2000, 1, 1, 0, 0)).unwrap().is_empty());
1123 assert!(Log::last_validate_at(&store).unwrap().is_none());
1124 }
1125
1126 #[test]
1127 fn since_exact_timestamp_is_exclusive() {
1128 let (_d, store) = temp_store();
1129 let e = entry(2026, 5, 27, 10, 0, LogKind::Validate, None, "PASS");
1130 Log::append(&store, &e).unwrap();
1131 assert!(Log::since(&store, ts(2026, 5, 27, 10, 0))
1133 .unwrap()
1134 .is_empty());
1135 }
1136
1137 fn write_raw_log(store: &Store, entries: &[LogEntry]) {
1146 let mut content = String::from(LOG_FRONTMATTER);
1147 content.push('\n');
1148 for e in entries {
1149 content.push_str(&e.render());
1150 }
1151 fs::write(store.root.join("log.md"), content).expect("write raw log.md");
1152 }
1153
1154 #[test]
1155 fn since_returns_newer_entries_even_when_disk_order_is_non_monotonic() {
1156 let (_d, store) = temp_store();
1162 let e_1010 = entry(2026, 5, 27, 10, 10, LogKind::Update, Some("c"), "newest");
1163 let e_1005 = entry(2026, 5, 27, 10, 5, LogKind::Create, Some("b"), "middle");
1164 let e_1000 = entry(
1165 2026,
1166 5,
1167 27,
1168 10,
1169 0,
1170 LogKind::Update,
1171 Some("a"),
1172 "backdated fix",
1173 );
1174 write_raw_log(&store, &[e_1010, e_1005, e_1000]);
1176
1177 let got = Log::since(&store, ts(2026, 5, 27, 10, 2)).unwrap();
1182 let stamps: std::collections::BTreeSet<_> = got.iter().map(|e| e.timestamp).collect();
1183 assert_eq!(
1184 stamps,
1185 [ts(2026, 5, 27, 10, 5), ts(2026, 5, 27, 10, 10)]
1186 .into_iter()
1187 .collect(),
1188 "since(10:02) must include both 10:05 and 10:10 despite the backdated \
1189 10:00 entry sitting physically last, and exclude 10:00; got {got:?}"
1190 );
1191
1192 let all = Log::since(&store, ts(2026, 5, 27, 9, 0)).unwrap();
1195 let all_stamps: std::collections::BTreeSet<_> = all.iter().map(|e| e.timestamp).collect();
1196 assert_eq!(
1197 all_stamps,
1198 [
1199 ts(2026, 5, 27, 10, 0),
1200 ts(2026, 5, 27, 10, 5),
1201 ts(2026, 5, 27, 10, 10),
1202 ]
1203 .into_iter()
1204 .collect()
1205 );
1206 }
1207
1208 #[test]
1209 fn since_crosses_archive_when_newer_entry_is_out_of_order_inside_it() {
1210 let (_d, store) = temp_store();
1216
1217 let may = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may1");
1219 write_raw_log(&store, &[may]);
1220
1221 let apr_late = entry(
1223 2026,
1224 4,
1225 20,
1226 9,
1227 0,
1228 LogKind::Create,
1229 Some("apr-b"),
1230 "apr-late",
1231 );
1232 let apr_early = entry(
1233 2026,
1234 4,
1235 5,
1236 9,
1237 0,
1238 LogKind::Ingest,
1239 Some("apr-a"),
1240 "apr-early",
1241 );
1242 let dir = store.root.join("log");
1243 fs::create_dir_all(&dir).unwrap();
1244 let mut arch = String::from(LOG_FRONTMATTER);
1245 arch.push('\n');
1246 arch.push_str(&apr_late.render());
1247 arch.push_str(&apr_early.render());
1248 fs::write(dir.join("2026-04.md"), arch).unwrap();
1249
1250 let got = Log::since(&store, ts(2026, 4, 15, 0, 0)).unwrap();
1253 let stamps: std::collections::BTreeSet<_> = got.iter().map(|e| e.timestamp).collect();
1254 assert_eq!(
1255 stamps,
1256 [ts(2026, 4, 20, 9, 0), ts(2026, 5, 2, 8, 0)]
1257 .into_iter()
1258 .collect(),
1259 "since(mid-April) must include the out-of-order later April entry \
1260 and the May entry, and exclude the earlier April entry; got {got:?}"
1261 );
1262 }
1263
1264 #[test]
1267 fn multiline_note_is_preserved() {
1268 let (_d, store) = temp_store();
1269 let e = entry(
1270 2026,
1271 5,
1272 27,
1273 10,
1274 0,
1275 LogKind::Create,
1276 Some("records/x"),
1277 "Line one.\nLine two.\nLine three.",
1278 );
1279 Log::append(&store, &e).unwrap();
1280 let got = Log::tail(&store, 1).unwrap();
1281 assert_eq!(got[0].note, "Line one.\nLine two.\nLine three.");
1282 }
1283
1284 #[test]
1285 fn empty_note_roundtrips_as_empty() {
1286 let (_d, store) = temp_store();
1287 let e = entry(2026, 5, 27, 10, 0, LogKind::Validate, None, "");
1288 Log::append(&store, &e).unwrap();
1289 let got = Log::tail(&store, 1).unwrap();
1290 assert_eq!(got[0], e);
1291 assert_eq!(got[0].note, "");
1292 }
1293
1294 #[test]
1297 fn last_validate_at_finds_most_recent_validate() {
1298 let (_d, store) = temp_store();
1299 Log::append(
1300 &store,
1301 &entry(2026, 5, 27, 10, 0, LogKind::Validate, None, "first pass"),
1302 )
1303 .unwrap();
1304 Log::append(
1305 &store,
1306 &entry(2026, 5, 27, 10, 5, LogKind::Create, Some("a"), "made a"),
1307 )
1308 .unwrap();
1309 Log::append(
1310 &store,
1311 &entry(2026, 5, 27, 10, 10, LogKind::Validate, None, "second pass"),
1312 )
1313 .unwrap();
1314 Log::append(
1315 &store,
1316 &entry(2026, 5, 27, 10, 15, LogKind::Update, Some("a"), "edit a"),
1317 )
1318 .unwrap();
1319
1320 let last = Log::last_validate_at(&store).unwrap();
1321 assert_eq!(last, Some(ts(2026, 5, 27, 10, 10)));
1322 }
1323
1324 #[test]
1325 fn last_validate_at_none_when_no_validate() {
1326 let (_d, store) = temp_store();
1327 Log::append(
1328 &store,
1329 &entry(2026, 5, 27, 10, 0, LogKind::Create, Some("a"), "x"),
1330 )
1331 .unwrap();
1332 assert_eq!(Log::last_validate_at(&store).unwrap(), None);
1333 }
1334
1335 #[test]
1338 fn rotation_rolls_prior_months_into_archives() {
1339 let (_d, store) = temp_store();
1340 let a1 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("apr-a"), "apr one");
1343 let a2 = entry(2026, 4, 20, 9, 0, LogKind::Create, Some("apr-b"), "apr two");
1344 Log::append(&store, &a1).unwrap();
1345 Log::append(&store, &a2).unwrap();
1346
1347 assert!(!store.root.join("log").exists());
1349
1350 let m1 = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may one");
1352 Log::append(&store, &m1).unwrap();
1353
1354 let arch_path = store.root.join("log").join("2026-04.md");
1356 assert!(arch_path.exists(), "expected April archive to be created");
1357 let arch = fs::read_to_string(&arch_path).unwrap();
1358 assert!(arch.starts_with("---\ntype: log\n---\n"));
1359 assert!(arch.contains("## [2026-04-10 09:00] ingest | apr-a"));
1360 assert!(arch.contains("## [2026-04-20 09:00] create | apr-b"));
1361 assert!(arch.contains("apr one"));
1362 assert!(arch.contains("apr two"));
1363
1364 let active = fs::read_to_string(store.root.join("log.md")).unwrap();
1366 assert!(active.contains("## [2026-05-02 08:00] update | may-a"));
1367 assert!(
1368 !active.contains("apr-a") && !active.contains("apr-b"),
1369 "April entries must be gone from the active file; got:\n{active}"
1370 );
1371
1372 let all = Log::tail(&store, 99).unwrap();
1374 assert_eq!(all, vec![a1, a2, m1]);
1375 }
1376
1377 #[test]
1378 fn rotation_groups_distinct_prior_months_into_separate_archives() {
1379 let (_d, store) = temp_store();
1380 let mar = entry(2026, 3, 5, 9, 0, LogKind::Ingest, Some("mar"), "march");
1383 let apr = entry(2026, 4, 5, 9, 0, LogKind::Create, Some("apr"), "april");
1384 Log::append(&store, &mar).unwrap();
1385 Log::append(&store, &apr).unwrap();
1386 assert!(store.root.join("log").join("2026-03.md").exists());
1388
1389 let may = entry(2026, 5, 5, 9, 0, LogKind::Update, Some("may"), "may");
1390 Log::append(&store, &may).unwrap();
1391
1392 assert!(store.root.join("log").join("2026-03.md").exists());
1393 assert!(store.root.join("log").join("2026-04.md").exists());
1394
1395 let mar_arch = fs::read_to_string(store.root.join("log").join("2026-03.md")).unwrap();
1397 let apr_arch = fs::read_to_string(store.root.join("log").join("2026-04.md")).unwrap();
1398 assert!(mar_arch.contains("mar") && !mar_arch.contains("apr"));
1399 assert!(apr_arch.contains("apr") && !apr_arch.contains("mar"));
1400
1401 let active = fs::read_to_string(store.root.join("log.md")).unwrap();
1403 assert!(active.contains("may") && !active.contains("mar") && !active.contains("apr"));
1404
1405 let all = Log::tail(&store, 99).unwrap();
1407 assert_eq!(all, vec![mar, apr, may]);
1408 }
1409
1410 #[test]
1411 fn tail_crosses_into_archive_when_n_spans_month_boundary() {
1412 let (_d, store) = temp_store();
1413 let a1 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("apr-a"), "apr1");
1414 let a2 = entry(2026, 4, 20, 9, 0, LogKind::Create, Some("apr-b"), "apr2");
1415 let m1 = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may1");
1416 let m2 = entry(2026, 5, 3, 8, 0, LogKind::Update, Some("may-b"), "may2");
1417 for e in [&a1, &a2, &m1, &m2] {
1418 Log::append(&store, e).unwrap();
1419 }
1420 let tail3 = Log::tail(&store, 3).unwrap();
1423 assert_eq!(tail3, vec![a2.clone(), m1.clone(), m2.clone()]);
1424
1425 let tail2 = Log::tail(&store, 2).unwrap();
1428 assert_eq!(tail2, vec![m1, m2]);
1429 }
1430
1431 #[test]
1432 fn since_crosses_into_archive_and_early_stops() {
1433 let (_d, store) = temp_store();
1434 let a1 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("apr-a"), "apr1");
1435 let a2 = entry(2026, 4, 20, 9, 0, LogKind::Create, Some("apr-b"), "apr2");
1436 let m1 = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may1");
1437 for e in [&a1, &a2, &m1] {
1438 Log::append(&store, e).unwrap();
1439 }
1440 let got = Log::since(&store, ts(2026, 4, 15, 0, 0)).unwrap();
1443 assert_eq!(got, vec![a2, m1]);
1444 }
1445
1446 #[test]
1447 fn last_validate_at_crosses_into_archive() {
1448 let (_d, store) = temp_store();
1449 Log::append(
1451 &store,
1452 &entry(2026, 4, 10, 9, 0, LogKind::Validate, None, "apr validate"),
1453 )
1454 .unwrap();
1455 Log::append(
1456 &store,
1457 &entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may work"),
1458 )
1459 .unwrap();
1460 let last = Log::last_validate_at(&store).unwrap();
1463 assert_eq!(last, Some(ts(2026, 4, 10, 9, 0)));
1464 }
1465
1466 #[test]
1469 fn reverse_read_correct_on_large_single_month_log() {
1470 let (_d, store) = temp_store();
1471 let n = 400usize;
1477 let mut expected: Vec<LogEntry> = Vec::new();
1478 for i in 0..n {
1479 let total_min = (i as u32) * 3;
1480 let day = 1 + total_min / (24 * 60);
1481 let hour = (total_min / 60) % 24;
1482 let min = total_min % 60;
1483 let note = format!(
1485 "entry number {i}\nbody line A for {i}\nbody line B for {i} with padding {}",
1486 "x".repeat(40)
1487 );
1488 let e = entry(
1489 2026,
1490 6,
1491 day,
1492 hour,
1493 min,
1494 LogKind::Update,
1495 Some(&format!("records/item-{i:04}")),
1496 ¬e,
1497 );
1498 Log::append(&store, &e).unwrap();
1499 expected.push(e);
1500 }
1501
1502 let size = fs::metadata(store.root.join("log.md")).unwrap().len();
1504 assert!(
1505 size > (REVERSE_BLOCK as u64) * 2,
1506 "test log not large enough ({size} bytes) to exercise multi-block reverse-read"
1507 );
1508
1509 let tail5 = Log::tail(&store, 5).unwrap();
1511 assert_eq!(tail5, expected[n - 5..].to_vec());
1512
1513 let tail50 = Log::tail(&store, 50).unwrap();
1515 assert_eq!(tail50, expected[n - 50..].to_vec());
1516
1517 let all = Log::tail(&store, n + 10).unwrap();
1519 assert_eq!(all.len(), n);
1520 assert_eq!(all, expected);
1521 }
1522
1523 fn write_log_physical(store: &Store, entries: &[LogEntry]) {
1536 let mut body = String::new();
1537 for e in entries {
1538 body.push_str(&e.render());
1539 }
1540 let full = compose_active(LOG_FRONTMATTER, &body);
1541 fs::write(store.root.join("log.md"), full).expect("write log.md");
1542 }
1543
1544 #[test]
1545 fn tail_returns_newest_by_timestamp_on_demonstrated_out_of_order_log() {
1546 let (_d, store) = temp_store();
1551 let e_1010 = entry(2026, 5, 27, 10, 10, LogKind::Update, Some("c"), "ten-ten");
1552 let e_1005 = entry(
1553 2026,
1554 5,
1555 27,
1556 10,
1557 5,
1558 LogKind::Create,
1559 Some("b"),
1560 "ten-oh-five",
1561 );
1562 let e_1000 = entry(2026, 5, 27, 10, 0, LogKind::Ingest, Some("a"), "ten-oh-oh");
1563 write_log_physical(&store, &[e_1010.clone(), e_1005.clone(), e_1000.clone()]);
1565
1566 let tail2 = Log::tail(&store, 2).unwrap();
1567 assert_eq!(
1568 tail2,
1569 vec![e_1005.clone(), e_1010.clone()],
1570 "tail(2) must be the two NEWEST by timestamp (chronological), \
1571 not the last two physical entries"
1572 );
1573 assert!(tail2.contains(&e_1010), "newest (10:10) must be included");
1575 assert!(!tail2.contains(&e_1000), "oldest (10:00) must be excluded");
1576
1577 assert_eq!(Log::tail(&store, 1).unwrap(), vec![e_1010.clone()]);
1579 assert_eq!(Log::tail(&store, 99).unwrap(), vec![e_1000, e_1005, e_1010]);
1581 }
1582
1583 #[test]
1584 fn tail_no_early_stop_when_newer_entry_sits_before_an_older_one() {
1585 let (_d, store) = temp_store();
1593 let e55 = entry(2026, 5, 27, 10, 55, LogKind::Update, Some("x55"), "55");
1594 let e10 = entry(2026, 5, 27, 10, 10, LogKind::Update, Some("x10"), "10");
1595 let e50 = entry(2026, 5, 27, 10, 50, LogKind::Update, Some("x50"), "50");
1596 let e00 = entry(2026, 5, 27, 10, 0, LogKind::Update, Some("x00"), "00");
1597 write_log_physical(
1598 &store,
1599 &[e55.clone(), e10.clone(), e50.clone(), e00.clone()],
1600 );
1601
1602 let tail2 = Log::tail(&store, 2).unwrap();
1605 assert_eq!(tail2, vec![e50.clone(), e55.clone()]);
1606
1607 let tail3 = Log::tail(&store, 3).unwrap();
1608 assert_eq!(tail3, vec![e10.clone(), e50.clone(), e55.clone()]);
1609 }
1610
1611 #[test]
1612 fn tail_orders_equal_timestamps_by_physical_recency() {
1613 let (_d, store) = temp_store();
1617 let early = entry(2026, 5, 27, 9, 59, LogKind::Create, Some("early"), "before");
1618 let tie_a = entry(
1619 2026,
1620 5,
1621 27,
1622 10,
1623 0,
1624 LogKind::Update,
1625 Some("tie-a"),
1626 "first 10:00",
1627 );
1628 let tie_b = entry(
1629 2026,
1630 5,
1631 27,
1632 10,
1633 0,
1634 LogKind::Update,
1635 Some("tie-b"),
1636 "second 10:00",
1637 );
1638 write_log_physical(&store, &[early.clone(), tie_a.clone(), tie_b.clone()]);
1640
1641 let tail2 = Log::tail(&store, 2).unwrap();
1642 assert_eq!(
1643 tail2,
1644 vec![tie_a.clone(), tie_b.clone()],
1645 "both 10:00 entries kept, physically-later one (tie_b) last; 09:59 dropped"
1646 );
1647 assert_eq!(Log::tail(&store, 1).unwrap(), vec![tie_b]);
1649 }
1650
1651 #[test]
1652 fn tail_finds_newest_across_a_backdated_entry_spanning_the_month_boundary() {
1653 let (_d, store) = temp_store();
1660 let may1 = entry(2026, 5, 10, 9, 0, LogKind::Ingest, Some("may-1"), "may one");
1661 let may2 = entry(2026, 5, 20, 9, 0, LogKind::Create, Some("may-2"), "may two");
1662 let jun1 = entry(2026, 6, 2, 8, 0, LogKind::Update, Some("jun-1"), "jun one");
1663 Log::append(&store, &may1).unwrap();
1664 Log::append(&store, &may2).unwrap();
1665 Log::append(&store, &jun1).unwrap(); assert!(store.root.join("log").join("2026-05.md").exists());
1667
1668 let may_corr = entry(
1672 2026,
1673 5,
1674 25,
1675 9,
1676 0,
1677 LogKind::Update,
1678 Some("may-2"),
1679 "may correction",
1680 );
1681 Log::append(&store, &may_corr).unwrap();
1682 let active = fs::read_to_string(store.root.join("log.md")).unwrap();
1683 assert!(
1684 active.contains("jun-1") && active.contains("may correction"),
1685 "backdated May entry should be in the active file alongside June; got:\n{active}"
1686 );
1687
1688 assert_eq!(Log::tail(&store, 1).unwrap(), vec![jun1.clone()]);
1691
1692 let tail2 = Log::tail(&store, 2).unwrap();
1694 assert_eq!(tail2, vec![may_corr.clone(), jun1.clone()]);
1695
1696 let tail3 = Log::tail(&store, 3).unwrap();
1699 assert_eq!(tail3, vec![may2.clone(), may_corr.clone(), jun1.clone()]);
1700
1701 let all = Log::tail(&store, 99).unwrap();
1703 assert_eq!(all, vec![may1, may2, may_corr, jun1]);
1704 }
1705
1706 #[test]
1707 fn parse_entries_skips_unparseable_header_folding_into_body() {
1708 let text = "\
1712## [2026-05-27 10:00] create | records/x
1713Body mentions a literal: ## [not a real header here]
1714More body.
1715
1716## [2026-05-27 10:05] update | records/y
1717Second.
1718";
1719 let entries = parse_entries(text);
1720 assert_eq!(entries.len(), 2);
1721 assert_eq!(entries[0].kind, LogKind::Create);
1722 assert!(entries[0].note.contains("## [not a real header here]"));
1723 assert!(entries[0].note.contains("More body."));
1724 assert_eq!(entries[1].kind, LogKind::Update);
1725 assert_eq!(entries[1].note, "Second.");
1726 }
1727
1728 #[test]
1731 fn append_only_corrective_entry_goes_on_end_without_rewriting() {
1732 let (_d, store) = temp_store();
1733 let original = entry(
1734 2026,
1735 5,
1736 27,
1737 10,
1738 0,
1739 LogKind::Update,
1740 Some("records/northstar"),
1741 "Seat count 120 -> 175.",
1742 );
1743 Log::append(&store, &original).unwrap();
1744 let after_first = fs::read_to_string(store.root.join("log.md")).unwrap();
1745
1746 let correction = entry(
1749 2026,
1750 5,
1751 27,
1752 11,
1753 0,
1754 LogKind::Update,
1755 Some("records/northstar"),
1756 "Correction: seat count is 165, not 175.",
1757 );
1758 Log::append(&store, &correction).unwrap();
1759 let after_second = fs::read_to_string(store.root.join("log.md")).unwrap();
1760
1761 assert!(
1762 after_second.starts_with(&after_first),
1763 "appending must not rewrite earlier bytes"
1764 );
1765 assert!(after_second.contains("Correction: seat count is 165, not 175."));
1766
1767 let all = Log::tail(&store, 99).unwrap();
1769 assert_eq!(all, vec![original, correction]);
1770 }
1771
1772 #[test]
1775 fn concurrent_appends_are_atomic_and_total() {
1776 use std::sync::{Arc, Barrier};
1777 use std::thread;
1778
1779 let (_d, store) = temp_store();
1780 Log::append(
1782 &store,
1783 &entry(2026, 7, 1, 0, 0, LogKind::Create, Some("seed"), "seed"),
1784 )
1785 .unwrap();
1786
1787 let threads = 8usize;
1788 let per = 25usize;
1789 let barrier = Arc::new(Barrier::new(threads));
1790 let store = Arc::new(store);
1791
1792 let mut handles = Vec::new();
1793 for tnum in 0..threads {
1794 let b = Arc::clone(&barrier);
1795 let s = Arc::clone(&store);
1796 handles.push(thread::spawn(move || {
1797 b.wait();
1798 for i in 0..per {
1799 let e = entry(
1800 2026,
1801 7,
1802 1,
1803 (tnum % 24) as u32,
1804 (i % 60) as u32,
1805 LogKind::Update,
1806 Some(&format!("t{tnum}-i{i}")),
1807 &format!("thread {tnum} item {i}"),
1808 );
1809 Log::append(&s, &e).unwrap();
1810 }
1811 }));
1812 }
1813 for h in handles {
1814 h.join().unwrap();
1815 }
1816
1817 let content = fs::read_to_string(store.root.join("log.md")).unwrap();
1825 assert!(content.starts_with("---\ntype: log\n---\n"));
1826
1827 for line in content.lines() {
1829 if line.starts_with("## [") {
1830 assert!(
1831 Log::parse_header(line).is_some(),
1832 "corrupt/torn header line on disk: {line:?}"
1833 );
1834 }
1835 }
1836
1837 assert!(content.contains("## [2026-07-01 00:00] create | seed"));
1840
1841 let all = Log::tail(&store, 10_000).unwrap();
1843 assert!(!all.is_empty());
1844 for e in &all {
1849 let rendered = e.render();
1850 let reparsed = parse_single_entry(&rendered).unwrap();
1851 assert_eq!(&reparsed, e);
1852 }
1853 }
1854
1855 #[test]
1858 fn render_then_parse_is_identity() {
1859 let cases = vec![
1860 entry(
1861 2026,
1862 1,
1863 2,
1864 3,
1865 4,
1866 LogKind::Ingest,
1867 Some("sources/a.eml"),
1868 "n",
1869 ),
1870 entry(
1871 2026,
1872 12,
1873 31,
1874 23,
1875 59,
1876 LogKind::Validate,
1877 None,
1878 "PASS - 0 errors",
1879 ),
1880 entry(
1881 2026,
1882 6,
1883 15,
1884 12,
1885 30,
1886 LogKind::Custom("proposal".to_string()),
1887 Some("records/p"),
1888 "multi\nline\nnote",
1889 ),
1890 entry(2026, 6, 15, 12, 30, LogKind::Contradiction, Some("obj"), ""),
1891 ];
1892 for e in cases {
1893 let rendered = e.render();
1894 let parsed = parse_single_entry(&rendered).unwrap_or_else(|| {
1895 panic!("failed to reparse rendered entry:\n{rendered}");
1896 });
1897 assert_eq!(parsed, e, "round-trip mismatch for {e:?}");
1898 }
1899 }
1900}