1use std::io::Write as _;
42use std::path::{Path, PathBuf};
43
44use serde::Serialize;
45
46use crate::provenance::ProvenanceKind;
47use crate::vcs::{Actor, ClientId};
48
49pub fn changelog_path(workspace_root: &Path) -> PathBuf {
51 workspace_root
52 .join(crate::mem::MEM_META_DIR)
53 .join("changes.jsonl")
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum MutationKind {
61 Create,
62 Update,
63 Delete,
64 Relate,
65 Rename,
66 Batch,
67}
68
69impl MutationKind {
70 pub fn as_str(&self) -> &'static str {
71 match self {
72 MutationKind::Create => "create",
73 MutationKind::Update => "update",
74 MutationKind::Delete => "delete",
75 MutationKind::Relate => "relate",
76 MutationKind::Rename => "rename",
77 MutationKind::Batch => "batch",
78 }
79 }
80}
81
82impl From<ProvenanceKind> for MutationKind {
87 fn from(k: ProvenanceKind) -> Self {
88 match k {
89 ProvenanceKind::Create => MutationKind::Create,
90 ProvenanceKind::Update => MutationKind::Update,
91 ProvenanceKind::Delete => MutationKind::Delete,
92 ProvenanceKind::Relate => MutationKind::Relate,
93 ProvenanceKind::Rename => MutationKind::Rename,
94 ProvenanceKind::Batch => MutationKind::Batch,
95 }
96 }
97}
98
99impl From<MutationKind> for ProvenanceKind {
100 fn from(k: MutationKind) -> Self {
101 match k {
102 MutationKind::Create => ProvenanceKind::Create,
103 MutationKind::Update => ProvenanceKind::Update,
104 MutationKind::Delete => ProvenanceKind::Delete,
105 MutationKind::Relate => ProvenanceKind::Relate,
106 MutationKind::Rename => ProvenanceKind::Rename,
107 MutationKind::Batch => ProvenanceKind::Batch,
108 }
109 }
110}
111
112pub struct ChangeEntry<'a> {
115 pub kind: MutationKind,
116 pub entity: Option<&'a str>,
118 pub actor: Actor,
119 pub client: Option<&'a ClientId>,
121 pub note: Option<&'a str>,
124 pub logical_operation_id: Option<&'a str>,
130 pub role: crate::vcs::Role,
133 pub identity: Option<&'a str>,
136}
137
138#[derive(Debug, thiserror::Error)]
140pub enum ChangelogError {
141 #[error("changelog io error at {path}: {source}")]
142 Io {
143 path: PathBuf,
144 #[source]
145 source: std::io::Error,
146 },
147 #[error("changelog serialisation: {0}")]
148 Serialise(#[from] serde_json::Error),
149}
150
151pub fn append_change(workspace_root: &Path, entry: &ChangeEntry<'_>) -> Result<(), ChangelogError> {
154 let now = std::time::SystemTime::now();
155 append_change_monotonic(workspace_root, entry, now)
156}
157
158pub fn append_change_monotonic(
175 workspace_root: &Path,
176 entry: &ChangeEntry<'_>,
177 now: std::time::SystemTime,
178) -> Result<(), ChangelogError> {
179 let effective = match last_line_ts(&changelog_path(workspace_root)) {
185 Some(last) if format_rfc3339_utc(now) <= format_rfc3339_utc(last) => {
186 last + std::time::Duration::from_millis(1)
187 }
188 _ => now,
189 };
190 append_change_at(workspace_root, entry, effective)
191}
192
193fn last_line_ts(path: &Path) -> Option<std::time::SystemTime> {
200 use std::io::{Read as _, Seek as _, SeekFrom};
201 let mut file = std::fs::File::open(path).ok()?;
202 let len = file.metadata().ok()?.len();
203 let start = len.saturating_sub(4096);
204 file.seek(SeekFrom::Start(start)).ok()?;
205 let mut buf = Vec::new();
206 file.read_to_end(&mut buf).ok()?;
207 let tail = String::from_utf8_lossy(&buf);
210 tail.lines()
211 .rev()
212 .map(str::trim)
213 .filter(|l| !l.is_empty())
214 .filter_map(|l| {
215 let ts = serde_json::from_str::<serde_json::Value>(l)
216 .ok()?
217 .get("ts")?
218 .as_str()?
219 .to_string();
220 parse_rfc3339_utc(&ts)
221 })
222 .next()
223}
224
225pub fn append_change_at(
229 workspace_root: &Path,
230 entry: &ChangeEntry<'_>,
231 now: std::time::SystemTime,
232) -> Result<(), ChangelogError> {
233 let target = changelog_path(workspace_root);
234 if let Some(parent) = target.parent() {
235 std::fs::create_dir_all(parent).map_err(|e| ChangelogError::Io {
236 path: parent.to_path_buf(),
237 source: e,
238 })?;
239 }
240
241 let ts = format_rfc3339_utc(now);
242 let note = entry
243 .note
244 .map(str::trim)
245 .filter(|n| !n.is_empty())
246 .map(|s| s.to_string());
247 let client = entry.client.map(|c| format!("{}@{}", c.name, c.version));
248
249 #[derive(Serialize)]
250 struct Wire<'a> {
251 ts: &'a str,
252 kind: &'a str,
253 entity: Option<&'a str>,
254 actor: &'a str,
255 #[serde(skip_serializing_if = "Option::is_none")]
256 note: Option<String>,
257 #[serde(skip_serializing_if = "Option::is_none")]
258 client: Option<String>,
259 #[serde(skip_serializing_if = "Option::is_none", rename = "logical_op")]
260 logical_operation_id: Option<&'a str>,
261 #[serde(skip_serializing_if = "Option::is_none")]
262 role: Option<&'static str>,
263 #[serde(skip_serializing_if = "Option::is_none")]
264 identity: Option<&'a str>,
265 }
266
267 let mut line = serde_json::to_string(&Wire {
268 ts: &ts,
269 kind: entry.kind.as_str(),
270 entity: entry.entity,
271 actor: entry.actor.as_trailer(),
272 note,
273 client,
274 role: entry.role.as_trailer(),
275 identity: entry.identity,
276 logical_operation_id: entry.logical_operation_id,
277 })?;
278 line.push('\n');
279
280 let mut file = std::fs::OpenOptions::new()
281 .create(true)
282 .append(true)
283 .open(&target)
284 .map_err(|e| ChangelogError::Io {
285 path: target.clone(),
286 source: e,
287 })?;
288 file.write_all(line.as_bytes())
289 .map_err(|e| ChangelogError::Io {
290 path: target,
291 source: e,
292 })?;
293 Ok(())
294}
295
296pub fn format_rfc3339_utc(now: std::time::SystemTime) -> String {
304 let dur = now
305 .duration_since(std::time::UNIX_EPOCH)
306 .unwrap_or_default();
307 let total_secs = dur.as_secs();
308 let millis = dur.subsec_millis();
309 let (y, m, d, hh, mm, ss) = decompose_unix_seconds(total_secs);
310 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}.{millis:03}Z")
311}
312
313pub fn parse_rfc3339_utc(s: &str) -> Option<std::time::SystemTime> {
320 let bytes = s.as_bytes();
321 if bytes.len() != 24
322 || bytes[23] != b'Z'
323 || bytes[4] != b'-'
324 || bytes[7] != b'-'
325 || bytes[10] != b'T'
326 || bytes[13] != b':'
327 || bytes[16] != b':'
328 || bytes[19] != b'.'
329 {
330 return None;
331 }
332 let year: i32 = s.get(0..4)?.parse().ok()?;
333 let month: u32 = s.get(5..7)?.parse().ok()?;
334 let day: u32 = s.get(8..10)?.parse().ok()?;
335 let hour: u32 = s.get(11..13)?.parse().ok()?;
336 let minute: u32 = s.get(14..16)?.parse().ok()?;
337 let second: u32 = s.get(17..19)?.parse().ok()?;
338 let millis: u32 = s.get(20..23)?.parse().ok()?;
339 if month == 0
340 || month > 12
341 || day == 0
342 || day > 31
343 || hour > 23
344 || minute > 59
345 || second > 60
346 || millis > 999
347 {
348 return None;
349 }
350 let days = ymd_to_days(year, month, day)?;
351 let total_secs = days
352 .checked_mul(86_400)?
353 .checked_add(hour as i64 * 3_600)?
354 .checked_add(minute as i64 * 60)?
355 .checked_add(second as i64)?;
356 if total_secs < 0 {
357 return None;
358 }
359 Some(std::time::UNIX_EPOCH + std::time::Duration::new(total_secs as u64, millis * 1_000_000))
360}
361
362fn ymd_to_days(year: i32, month: u32, day: u32) -> Option<i64> {
365 let y = if month <= 2 {
366 year as i64 - 1
367 } else {
368 year as i64
369 };
370 let m = month as i64;
371 let era = if y >= 0 { y } else { y - 399 } / 400;
372 let yoe = y - era * 400;
373 if !(0..=399).contains(&yoe) {
374 return None;
375 }
376 let doy_offset = if m > 2 { m - 3 } else { m + 9 };
377 let doy = (153 * doy_offset + 2) / 5 + day as i64 - 1;
378 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
379 Some(era * 146_097 + doe - 719_468)
380}
381
382fn decompose_unix_seconds(total_secs: u64) -> (i32, u32, u32, u32, u32, u32) {
391 const SECONDS_PER_DAY: u64 = 86_400;
392 let days = (total_secs / SECONDS_PER_DAY) as i64;
393 let secs_of_day = (total_secs % SECONDS_PER_DAY) as u32;
394 let hh = secs_of_day / 3_600;
395 let mm = (secs_of_day / 60) % 60;
396 let ss = secs_of_day % 60;
397
398 let z = days + 719_468;
400 let era = z.div_euclid(146_097);
401 let doe = z - era * 146_097;
402 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
403 let y = (yoe + era * 400) as i32;
404 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
405 let mp = (5 * doy + 2) / 153;
406 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
407 let m = if mp < 10 {
408 (mp + 3) as u32
409 } else {
410 (mp - 9) as u32
411 };
412 let y = if m <= 2 { y + 1 } else { y };
413 (y, m, d, hh, mm, ss)
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::vcs::{Actor, ClientId};
420 use tempfile::TempDir;
421
422 fn read_lines(path: &Path) -> Vec<String> {
423 std::fs::read_to_string(path)
424 .unwrap()
425 .lines()
426 .map(|s| s.to_string())
427 .collect()
428 }
429
430 fn ts(seconds: u64, millis: u32) -> std::time::SystemTime {
431 std::time::UNIX_EPOCH + std::time::Duration::new(seconds, millis * 1_000_000)
432 }
433
434 #[test]
435 fn appends_a_create_line_with_all_fields() {
436 let tmp = TempDir::new().unwrap();
437 let client = ClientId {
438 name: "claude-code".into(),
439 version: "2.1.0".into(),
440 };
441 append_change_at(
442 tmp.path(),
443 &ChangeEntry {
444 kind: MutationKind::Create,
445 entity: Some("spec:hello"),
446 actor: Actor::Agent,
447 client: Some(&client),
448 note: Some("first draft"),
449 logical_operation_id: None,
450 role: crate::vcs::Role::Unspecified,
451 identity: None,
452 },
453 ts(1_715_000_000, 1),
454 )
455 .unwrap();
456
457 let lines = read_lines(&changelog_path(tmp.path()));
458 assert_eq!(lines.len(), 1);
459 let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
460 assert_eq!(value["kind"], "create");
461 assert_eq!(value["entity"], "spec:hello");
462 assert_eq!(value["actor"], "agent");
463 assert_eq!(value["client"], "claude-code@2.1.0");
464 assert_eq!(value["note"], "first draft");
465 let ts_str = value["ts"].as_str().unwrap();
467 assert!(ts_str.ends_with("Z"));
468 assert!(ts_str.contains("T"));
469 assert_eq!(ts_str, "2024-05-06T12:53:20.001Z");
471 assert!(value.get("identity").is_none());
474 }
475
476 #[test]
479 fn appends_the_declared_identity() {
480 let tmp = TempDir::new().unwrap();
481 append_change_at(
482 tmp.path(),
483 &ChangeEntry {
484 kind: MutationKind::Update,
485 entity: Some("spec:hello"),
486 actor: Actor::Agent,
487 client: None,
488 note: None,
489 logical_operation_id: None,
490 role: crate::vcs::Role::Unspecified,
491 identity: Some("plenum-agent"),
492 },
493 ts(1_715_000_000, 1),
494 )
495 .unwrap();
496 let lines = read_lines(&changelog_path(tmp.path()));
497 let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
498 assert_eq!(value["identity"], "plenum-agent");
499 }
500
501 #[test]
502 fn omits_optional_fields_when_absent() {
503 let tmp = TempDir::new().unwrap();
504 append_change_at(
505 tmp.path(),
506 &ChangeEntry {
507 kind: MutationKind::Update,
508 entity: Some("spec:foo"),
509 actor: Actor::Cli,
510 client: None,
511 note: None,
512 logical_operation_id: None,
513 role: crate::vcs::Role::Unspecified,
514 identity: None,
515 },
516 ts(0, 0),
517 )
518 .unwrap();
519 let lines = read_lines(&changelog_path(tmp.path()));
520 let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
521 assert!(value.get("note").is_none());
522 assert!(value.get("client").is_none());
523 }
524
525 #[test]
526 fn whitespace_only_note_is_treated_as_absent() {
527 let tmp = TempDir::new().unwrap();
528 append_change_at(
529 tmp.path(),
530 &ChangeEntry {
531 kind: MutationKind::Update,
532 entity: Some("spec:foo"),
533 actor: Actor::Cli,
534 client: None,
535 note: Some(" \t "),
536 logical_operation_id: None,
537 role: crate::vcs::Role::Unspecified,
538 identity: None,
539 },
540 ts(0, 0),
541 )
542 .unwrap();
543 let lines = read_lines(&changelog_path(tmp.path()));
544 let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
545 assert!(value.get("note").is_none());
546 }
547
548 #[test]
549 fn batch_mutation_writes_null_entity() {
550 let tmp = TempDir::new().unwrap();
551 append_change_at(
552 tmp.path(),
553 &ChangeEntry {
554 kind: MutationKind::Batch,
555 entity: None,
556 actor: Actor::Agent,
557 client: None,
558 note: Some("multi-entity refactor"),
559 logical_operation_id: None,
560 role: crate::vcs::Role::Unspecified,
561 identity: None,
562 },
563 ts(0, 0),
564 )
565 .unwrap();
566 let lines = read_lines(&changelog_path(tmp.path()));
567 let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
568 assert!(value["entity"].is_null());
569 assert_eq!(value["kind"], "batch");
570 }
571
572 #[test]
573 fn appends_create_then_update_in_order() {
574 let tmp = TempDir::new().unwrap();
575 for (kind, ent, t) in [
576 (MutationKind::Create, "a", 0),
577 (MutationKind::Update, "a", 1),
578 (MutationKind::Delete, "a", 2),
579 ] {
580 append_change_at(
581 tmp.path(),
582 &ChangeEntry {
583 kind,
584 entity: Some(ent),
585 actor: Actor::Cli,
586 client: None,
587 note: None,
588 logical_operation_id: None,
589 role: crate::vcs::Role::Unspecified,
590 identity: None,
591 },
592 ts(t, 0),
593 )
594 .unwrap();
595 }
596
597 let lines = read_lines(&changelog_path(tmp.path()));
598 assert_eq!(lines.len(), 3);
599 let kinds: Vec<String> = lines
600 .iter()
601 .map(|line| {
602 serde_json::from_str::<serde_json::Value>(line).unwrap()["kind"]
603 .as_str()
604 .unwrap()
605 .to_string()
606 })
607 .collect();
608 assert_eq!(kinds, vec!["create", "update", "delete"]);
609 }
610
611 #[test]
612 fn creates_memstead_parent_lazily() {
613 let tmp = TempDir::new().unwrap();
614 assert!(!tmp.path().join(".memstead").exists());
615 append_change_at(
616 tmp.path(),
617 &ChangeEntry {
618 kind: MutationKind::Create,
619 entity: Some("a"),
620 actor: Actor::Cli,
621 client: None,
622 note: None,
623 logical_operation_id: None,
624 role: crate::vcs::Role::Unspecified,
625 identity: None,
626 },
627 ts(0, 0),
628 )
629 .unwrap();
630 assert!(tmp.path().join(".memstead").is_dir());
631 assert!(tmp.path().join(".memstead").join("changes.jsonl").is_file());
632 }
633
634 #[test]
635 fn does_not_create_memstead_until_first_change() {
636 let tmp = TempDir::new().unwrap();
637 let _ = changelog_path(tmp.path());
642 assert!(!tmp.path().join(".memstead").exists());
643 }
644
645 #[test]
646 fn timestamp_handles_year_2026() {
647 let s = format_rfc3339_utc(ts(1_777_077_296, 0));
651 assert_eq!(s, "2026-04-25T00:34:56.000Z");
652 }
653
654 #[test]
655 fn timestamp_round_trips_a_known_2026_date() {
656 let s = format_rfc3339_utc(ts(1_778_243_696, 0));
663 assert_eq!(s, "2026-05-08T12:34:56.000Z");
664 }
665
666 #[test]
667 fn timestamp_handles_epoch() {
668 let s = format_rfc3339_utc(ts(0, 0));
669 assert_eq!(s, "1970-01-01T00:00:00.000Z");
670 }
671
672 fn bare_entry() -> ChangeEntry<'static> {
673 ChangeEntry {
674 kind: MutationKind::Create,
675 entity: Some("spec:x"),
676 actor: Actor::Agent,
677 client: None,
678 note: None,
679 logical_operation_id: None,
680 role: crate::vcs::Role::Unspecified,
681 identity: None,
682 }
683 }
684
685 fn line_ts(line: &str) -> String {
686 serde_json::from_str::<serde_json::Value>(line).unwrap()["ts"]
687 .as_str()
688 .unwrap()
689 .to_string()
690 }
691
692 #[test]
693 fn monotonic_append_bumps_same_millisecond_timestamp() {
694 let tmp = TempDir::new().unwrap();
698 let now = ts(1_778_243_696, 500);
699 append_change_monotonic(tmp.path(), &bare_entry(), now).unwrap();
700 append_change_monotonic(tmp.path(), &bare_entry(), now).unwrap();
701 let lines = read_lines(&changelog_path(tmp.path()));
702 assert_eq!(line_ts(&lines[0]), "2026-05-08T12:34:56.500Z");
703 assert_eq!(line_ts(&lines[1]), "2026-05-08T12:34:56.501Z");
704 }
705
706 #[test]
707 fn monotonic_append_bumps_backwards_clock() {
708 let tmp = TempDir::new().unwrap();
709 append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_696, 500)).unwrap();
710 append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_695, 0)).unwrap();
713 let lines = read_lines(&changelog_path(tmp.path()));
714 assert_eq!(line_ts(&lines[1]), "2026-05-08T12:34:56.501Z");
715 }
716
717 #[test]
718 fn monotonic_append_keeps_strictly_later_timestamp_verbatim() {
719 let tmp = TempDir::new().unwrap();
720 append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_696, 500)).unwrap();
721 append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_696, 501)).unwrap();
722 let lines = read_lines(&changelog_path(tmp.path()));
723 assert_eq!(line_ts(&lines[1]), "2026-05-08T12:34:56.501Z");
724 }
725
726 #[test]
727 fn rfc3339_parser_round_trips_known_dates() {
728 for &(secs, ms) in &[
729 (0u64, 0u32),
730 (1_715_000_000, 1),
731 (1_777_077_296, 0),
732 (1_778_243_696, 0),
733 (1_778_243_696, 999),
734 ] {
735 let t = ts(secs, ms);
736 let s = format_rfc3339_utc(t);
737 let parsed = parse_rfc3339_utc(&s).expect("parse round-trip");
738 assert_eq!(parsed, t, "round-trip failed for {s}");
739 }
740 }
741
742 #[test]
743 fn rfc3339_parser_rejects_malformed_input() {
744 assert!(parse_rfc3339_utc("").is_none());
745 assert!(parse_rfc3339_utc("2026-05-08T12:34:56Z").is_none()); assert!(parse_rfc3339_utc("2026-05-08T12:34:56.000+02:00").is_none()); assert!(parse_rfc3339_utc("2026-13-08T12:34:56.000Z").is_none()); assert!(parse_rfc3339_utc("not a date at all aaaaa").is_none());
749 }
750
751 #[test]
752 fn mutation_kind_str_is_stable() {
753 assert_eq!(MutationKind::Create.as_str(), "create");
756 assert_eq!(MutationKind::Update.as_str(), "update");
757 assert_eq!(MutationKind::Delete.as_str(), "delete");
758 assert_eq!(MutationKind::Relate.as_str(), "relate");
759 assert_eq!(MutationKind::Rename.as_str(), "rename");
760 assert_eq!(MutationKind::Batch.as_str(), "batch");
761 }
762}
763
764#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
774pub struct LedgerReconciliation {
775 pub ledger_without_file: Vec<String>,
777 pub file_without_ledger: Vec<String>,
779}
780
781impl LedgerReconciliation {
782 pub fn is_clean(&self) -> bool {
783 self.ledger_without_file.is_empty() && self.file_without_ledger.is_empty()
784 }
785}
786
787pub fn reconcile_ledger(mem_root: &Path) -> Result<LedgerReconciliation, ChangelogError> {
798 let mut on_disk: std::collections::BTreeSet<String> = Default::default();
799 let meta_dir = mem_root.join(crate::mem::MEM_META_DIR);
800 let mut stack = vec![mem_root.to_path_buf()];
801 while let Some(dir) = stack.pop() {
802 let entries = match std::fs::read_dir(&dir) {
803 Ok(e) => e,
804 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
805 Err(source) => {
806 return Err(ChangelogError::Io {
807 path: dir.clone(),
808 source,
809 });
810 }
811 };
812 for entry in entries.flatten() {
813 let path = entry.path();
814 if path == meta_dir {
816 continue;
817 }
818 if path.is_dir() {
819 stack.push(path);
820 } else if path.extension().is_some_and(|x| x == "md")
821 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
822 {
823 on_disk.insert(stem.to_string());
824 }
825 }
826 }
827
828 let mut in_ledger: std::collections::BTreeSet<String> = Default::default();
829 let log_path = changelog_path(mem_root);
830 match std::fs::read_to_string(&log_path) {
831 Ok(raw) => {
832 for line in raw.lines() {
833 let trimmed = line.trim();
834 if trimmed.is_empty() {
835 continue;
836 }
837 if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed)
838 && let Some(entity) = v.get("entity").and_then(|x| x.as_str())
839 {
840 let slug = entity.rsplit("--").next().unwrap_or(entity);
844 in_ledger.insert(slug.to_string());
845 }
846 }
847 }
848 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
849 Err(source) => {
850 return Err(ChangelogError::Io {
851 path: log_path,
852 source,
853 });
854 }
855 }
856
857 Ok(LedgerReconciliation {
858 ledger_without_file: in_ledger.difference(&on_disk).cloned().collect(),
859 file_without_ledger: on_disk.difference(&in_ledger).cloned().collect(),
860 })
861}
862
863#[cfg(test)]
864mod reconcile_tests {
865 use super::*;
866 use tempfile::TempDir;
867
868 fn seed(root: &Path, files: &[&str], ledger_entities: &[&str]) {
869 for f in files {
870 std::fs::write(root.join(format!("{f}.md")), b"# x\n").unwrap();
871 }
872 let meta = root.join(crate::mem::MEM_META_DIR);
873 std::fs::create_dir_all(&meta).unwrap();
874 let lines: String = ledger_entities
875 .iter()
876 .map(|e| format!(r#"{{"ts":"2026-08-27T00:00:00Z","entity":"docs--{e}"}}"#) + "\n")
877 .collect();
878 std::fs::write(meta.join("changes.jsonl"), lines).unwrap();
879 }
880
881 #[test]
885 fn both_directions_of_ledger_divergence_are_named_separately() {
886 let tmp = TempDir::new().unwrap();
887 seed(tmp.path(), &["alpha", "orphaned-file"], &["alpha", "ghost"]);
888 let r = reconcile_ledger(tmp.path()).unwrap();
889 assert_eq!(r.ledger_without_file, vec!["ghost".to_string()]);
890 assert_eq!(r.file_without_ledger, vec!["orphaned-file".to_string()]);
891 assert!(!r.is_clean());
892 }
893
894 #[test]
898 fn reconciliation_writes_nothing_at_all() {
899 let tmp = TempDir::new().unwrap();
900 seed(tmp.path(), &["alpha", "orphaned-file"], &["alpha", "ghost"]);
901 let ledger = tmp
902 .path()
903 .join(crate::mem::MEM_META_DIR)
904 .join("changes.jsonl");
905 let before_ledger = std::fs::read(&ledger).unwrap();
906 let before_files: Vec<_> = std::fs::read_dir(tmp.path())
907 .unwrap()
908 .filter_map(|e| e.ok())
909 .map(|e| (e.path(), e.metadata().map(|m| m.len()).unwrap_or(0)))
910 .collect();
911
912 let _ = reconcile_ledger(tmp.path()).unwrap();
913
914 assert_eq!(std::fs::read(&ledger).unwrap(), before_ledger);
915 let after_files: Vec<_> = std::fs::read_dir(tmp.path())
916 .unwrap()
917 .filter_map(|e| e.ok())
918 .map(|e| (e.path(), e.metadata().map(|m| m.len()).unwrap_or(0)))
919 .collect();
920 assert_eq!(after_files.len(), before_files.len());
921 }
922
923 #[test]
926 fn an_agreeing_mem_reconciles_clean() {
927 let tmp = TempDir::new().unwrap();
928 seed(tmp.path(), &["alpha", "beta"], &["alpha", "beta"]);
929 assert!(reconcile_ledger(tmp.path()).unwrap().is_clean());
930 }
931
932 #[test]
935 fn the_engine_sidecar_is_not_mistaken_for_an_entity() {
936 let tmp = TempDir::new().unwrap();
937 seed(tmp.path(), &["alpha"], &["alpha"]);
938 std::fs::write(
939 tmp.path().join(crate::mem::MEM_META_DIR).join("notes.md"),
940 b"not an entity\n",
941 )
942 .unwrap();
943 assert!(reconcile_ledger(tmp.path()).unwrap().is_clean());
944 }
945}