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}
134
135#[derive(Debug, thiserror::Error)]
137pub enum ChangelogError {
138 #[error("changelog io error at {path}: {source}")]
139 Io {
140 path: PathBuf,
141 #[source]
142 source: std::io::Error,
143 },
144 #[error("changelog serialisation: {0}")]
145 Serialise(#[from] serde_json::Error),
146}
147
148pub fn append_change(workspace_root: &Path, entry: &ChangeEntry<'_>) -> Result<(), ChangelogError> {
151 let now = std::time::SystemTime::now();
152 append_change_monotonic(workspace_root, entry, now)
153}
154
155pub fn append_change_monotonic(
172 workspace_root: &Path,
173 entry: &ChangeEntry<'_>,
174 now: std::time::SystemTime,
175) -> Result<(), ChangelogError> {
176 let effective = match last_line_ts(&changelog_path(workspace_root)) {
182 Some(last) if format_rfc3339_utc(now) <= format_rfc3339_utc(last) => {
183 last + std::time::Duration::from_millis(1)
184 }
185 _ => now,
186 };
187 append_change_at(workspace_root, entry, effective)
188}
189
190fn last_line_ts(path: &Path) -> Option<std::time::SystemTime> {
197 use std::io::{Read as _, Seek as _, SeekFrom};
198 let mut file = std::fs::File::open(path).ok()?;
199 let len = file.metadata().ok()?.len();
200 let start = len.saturating_sub(4096);
201 file.seek(SeekFrom::Start(start)).ok()?;
202 let mut buf = Vec::new();
203 file.read_to_end(&mut buf).ok()?;
204 let tail = String::from_utf8_lossy(&buf);
207 tail.lines()
208 .rev()
209 .map(str::trim)
210 .filter(|l| !l.is_empty())
211 .filter_map(|l| {
212 let ts = serde_json::from_str::<serde_json::Value>(l)
213 .ok()?
214 .get("ts")?
215 .as_str()?
216 .to_string();
217 parse_rfc3339_utc(&ts)
218 })
219 .next()
220}
221
222pub fn append_change_at(
226 workspace_root: &Path,
227 entry: &ChangeEntry<'_>,
228 now: std::time::SystemTime,
229) -> Result<(), ChangelogError> {
230 let target = changelog_path(workspace_root);
231 if let Some(parent) = target.parent() {
232 std::fs::create_dir_all(parent).map_err(|e| ChangelogError::Io {
233 path: parent.to_path_buf(),
234 source: e,
235 })?;
236 }
237
238 let ts = format_rfc3339_utc(now);
239 let note = entry
240 .note
241 .map(str::trim)
242 .filter(|n| !n.is_empty())
243 .map(|s| s.to_string());
244 let client = entry.client.map(|c| format!("{}@{}", c.name, c.version));
245
246 #[derive(Serialize)]
247 struct Wire<'a> {
248 ts: &'a str,
249 kind: &'a str,
250 entity: Option<&'a str>,
251 actor: &'a str,
252 #[serde(skip_serializing_if = "Option::is_none")]
253 note: Option<String>,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 client: Option<String>,
256 #[serde(skip_serializing_if = "Option::is_none", rename = "logical_op")]
257 logical_operation_id: Option<&'a str>,
258 #[serde(skip_serializing_if = "Option::is_none")]
259 role: Option<&'static str>,
260 }
261
262 let mut line = serde_json::to_string(&Wire {
263 ts: &ts,
264 kind: entry.kind.as_str(),
265 entity: entry.entity,
266 actor: entry.actor.as_trailer(),
267 note,
268 client,
269 role: entry.role.as_trailer(),
270 logical_operation_id: entry.logical_operation_id,
271 })?;
272 line.push('\n');
273
274 let mut file = std::fs::OpenOptions::new()
275 .create(true)
276 .append(true)
277 .open(&target)
278 .map_err(|e| ChangelogError::Io {
279 path: target.clone(),
280 source: e,
281 })?;
282 file.write_all(line.as_bytes())
283 .map_err(|e| ChangelogError::Io {
284 path: target,
285 source: e,
286 })?;
287 Ok(())
288}
289
290pub fn format_rfc3339_utc(now: std::time::SystemTime) -> String {
298 let dur = now
299 .duration_since(std::time::UNIX_EPOCH)
300 .unwrap_or_default();
301 let total_secs = dur.as_secs();
302 let millis = dur.subsec_millis();
303 let (y, m, d, hh, mm, ss) = decompose_unix_seconds(total_secs);
304 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}.{millis:03}Z")
305}
306
307pub fn parse_rfc3339_utc(s: &str) -> Option<std::time::SystemTime> {
314 let bytes = s.as_bytes();
315 if bytes.len() != 24
316 || bytes[23] != b'Z'
317 || bytes[4] != b'-'
318 || bytes[7] != b'-'
319 || bytes[10] != b'T'
320 || bytes[13] != b':'
321 || bytes[16] != b':'
322 || bytes[19] != b'.'
323 {
324 return None;
325 }
326 let year: i32 = s.get(0..4)?.parse().ok()?;
327 let month: u32 = s.get(5..7)?.parse().ok()?;
328 let day: u32 = s.get(8..10)?.parse().ok()?;
329 let hour: u32 = s.get(11..13)?.parse().ok()?;
330 let minute: u32 = s.get(14..16)?.parse().ok()?;
331 let second: u32 = s.get(17..19)?.parse().ok()?;
332 let millis: u32 = s.get(20..23)?.parse().ok()?;
333 if month == 0
334 || month > 12
335 || day == 0
336 || day > 31
337 || hour > 23
338 || minute > 59
339 || second > 60
340 || millis > 999
341 {
342 return None;
343 }
344 let days = ymd_to_days(year, month, day)?;
345 let total_secs = days
346 .checked_mul(86_400)?
347 .checked_add(hour as i64 * 3_600)?
348 .checked_add(minute as i64 * 60)?
349 .checked_add(second as i64)?;
350 if total_secs < 0 {
351 return None;
352 }
353 Some(std::time::UNIX_EPOCH + std::time::Duration::new(total_secs as u64, millis * 1_000_000))
354}
355
356fn ymd_to_days(year: i32, month: u32, day: u32) -> Option<i64> {
359 let y = if month <= 2 {
360 year as i64 - 1
361 } else {
362 year as i64
363 };
364 let m = month as i64;
365 let era = if y >= 0 { y } else { y - 399 } / 400;
366 let yoe = y - era * 400;
367 if !(0..=399).contains(&yoe) {
368 return None;
369 }
370 let doy_offset = if m > 2 { m - 3 } else { m + 9 };
371 let doy = (153 * doy_offset + 2) / 5 + day as i64 - 1;
372 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
373 Some(era * 146_097 + doe - 719_468)
374}
375
376fn decompose_unix_seconds(total_secs: u64) -> (i32, u32, u32, u32, u32, u32) {
385 const SECONDS_PER_DAY: u64 = 86_400;
386 let days = (total_secs / SECONDS_PER_DAY) as i64;
387 let secs_of_day = (total_secs % SECONDS_PER_DAY) as u32;
388 let hh = secs_of_day / 3_600;
389 let mm = (secs_of_day / 60) % 60;
390 let ss = secs_of_day % 60;
391
392 let z = days + 719_468;
394 let era = z.div_euclid(146_097);
395 let doe = z - era * 146_097;
396 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
397 let y = (yoe + era * 400) as i32;
398 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
399 let mp = (5 * doy + 2) / 153;
400 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
401 let m = if mp < 10 {
402 (mp + 3) as u32
403 } else {
404 (mp - 9) as u32
405 };
406 let y = if m <= 2 { y + 1 } else { y };
407 (y, m, d, hh, mm, ss)
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use crate::vcs::{Actor, ClientId};
414 use tempfile::TempDir;
415
416 fn read_lines(path: &Path) -> Vec<String> {
417 std::fs::read_to_string(path)
418 .unwrap()
419 .lines()
420 .map(|s| s.to_string())
421 .collect()
422 }
423
424 fn ts(seconds: u64, millis: u32) -> std::time::SystemTime {
425 std::time::UNIX_EPOCH + std::time::Duration::new(seconds, millis * 1_000_000)
426 }
427
428 #[test]
429 fn appends_a_create_line_with_all_fields() {
430 let tmp = TempDir::new().unwrap();
431 let client = ClientId {
432 name: "claude-code".into(),
433 version: "2.1.0".into(),
434 };
435 append_change_at(
436 tmp.path(),
437 &ChangeEntry {
438 kind: MutationKind::Create,
439 entity: Some("spec:hello"),
440 actor: Actor::Agent,
441 client: Some(&client),
442 note: Some("first draft"),
443 logical_operation_id: None,
444 role: crate::vcs::Role::Unspecified,
445 },
446 ts(1_715_000_000, 1),
447 )
448 .unwrap();
449
450 let lines = read_lines(&changelog_path(tmp.path()));
451 assert_eq!(lines.len(), 1);
452 let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
453 assert_eq!(value["kind"], "create");
454 assert_eq!(value["entity"], "spec:hello");
455 assert_eq!(value["actor"], "agent");
456 assert_eq!(value["client"], "claude-code@2.1.0");
457 assert_eq!(value["note"], "first draft");
458 let ts_str = value["ts"].as_str().unwrap();
460 assert!(ts_str.ends_with("Z"));
461 assert!(ts_str.contains("T"));
462 assert_eq!(ts_str, "2024-05-06T12:53:20.001Z");
464 }
465
466 #[test]
467 fn omits_optional_fields_when_absent() {
468 let tmp = TempDir::new().unwrap();
469 append_change_at(
470 tmp.path(),
471 &ChangeEntry {
472 kind: MutationKind::Update,
473 entity: Some("spec:foo"),
474 actor: Actor::Cli,
475 client: None,
476 note: None,
477 logical_operation_id: None,
478 role: crate::vcs::Role::Unspecified,
479 },
480 ts(0, 0),
481 )
482 .unwrap();
483 let lines = read_lines(&changelog_path(tmp.path()));
484 let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
485 assert!(value.get("note").is_none());
486 assert!(value.get("client").is_none());
487 }
488
489 #[test]
490 fn whitespace_only_note_is_treated_as_absent() {
491 let tmp = TempDir::new().unwrap();
492 append_change_at(
493 tmp.path(),
494 &ChangeEntry {
495 kind: MutationKind::Update,
496 entity: Some("spec:foo"),
497 actor: Actor::Cli,
498 client: None,
499 note: Some(" \t "),
500 logical_operation_id: None,
501 role: crate::vcs::Role::Unspecified,
502 },
503 ts(0, 0),
504 )
505 .unwrap();
506 let lines = read_lines(&changelog_path(tmp.path()));
507 let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
508 assert!(value.get("note").is_none());
509 }
510
511 #[test]
512 fn batch_mutation_writes_null_entity() {
513 let tmp = TempDir::new().unwrap();
514 append_change_at(
515 tmp.path(),
516 &ChangeEntry {
517 kind: MutationKind::Batch,
518 entity: None,
519 actor: Actor::Agent,
520 client: None,
521 note: Some("multi-entity refactor"),
522 logical_operation_id: None,
523 role: crate::vcs::Role::Unspecified,
524 },
525 ts(0, 0),
526 )
527 .unwrap();
528 let lines = read_lines(&changelog_path(tmp.path()));
529 let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
530 assert!(value["entity"].is_null());
531 assert_eq!(value["kind"], "batch");
532 }
533
534 #[test]
535 fn appends_create_then_update_in_order() {
536 let tmp = TempDir::new().unwrap();
537 for (kind, ent, t) in [
538 (MutationKind::Create, "a", 0),
539 (MutationKind::Update, "a", 1),
540 (MutationKind::Delete, "a", 2),
541 ] {
542 append_change_at(
543 tmp.path(),
544 &ChangeEntry {
545 kind,
546 entity: Some(ent),
547 actor: Actor::Cli,
548 client: None,
549 note: None,
550 logical_operation_id: None,
551 role: crate::vcs::Role::Unspecified,
552 },
553 ts(t, 0),
554 )
555 .unwrap();
556 }
557
558 let lines = read_lines(&changelog_path(tmp.path()));
559 assert_eq!(lines.len(), 3);
560 let kinds: Vec<String> = lines
561 .iter()
562 .map(|line| {
563 serde_json::from_str::<serde_json::Value>(line).unwrap()["kind"]
564 .as_str()
565 .unwrap()
566 .to_string()
567 })
568 .collect();
569 assert_eq!(kinds, vec!["create", "update", "delete"]);
570 }
571
572 #[test]
573 fn creates_memstead_parent_lazily() {
574 let tmp = TempDir::new().unwrap();
575 assert!(!tmp.path().join(".memstead").exists());
576 append_change_at(
577 tmp.path(),
578 &ChangeEntry {
579 kind: MutationKind::Create,
580 entity: Some("a"),
581 actor: Actor::Cli,
582 client: None,
583 note: None,
584 logical_operation_id: None,
585 role: crate::vcs::Role::Unspecified,
586 },
587 ts(0, 0),
588 )
589 .unwrap();
590 assert!(tmp.path().join(".memstead").is_dir());
591 assert!(tmp.path().join(".memstead").join("changes.jsonl").is_file());
592 }
593
594 #[test]
595 fn does_not_create_memstead_until_first_change() {
596 let tmp = TempDir::new().unwrap();
597 let _ = changelog_path(tmp.path());
602 assert!(!tmp.path().join(".memstead").exists());
603 }
604
605 #[test]
606 fn timestamp_handles_year_2026() {
607 let s = format_rfc3339_utc(ts(1_777_077_296, 0));
611 assert_eq!(s, "2026-04-25T00:34:56.000Z");
612 }
613
614 #[test]
615 fn timestamp_round_trips_a_known_2026_date() {
616 let s = format_rfc3339_utc(ts(1_778_243_696, 0));
623 assert_eq!(s, "2026-05-08T12:34:56.000Z");
624 }
625
626 #[test]
627 fn timestamp_handles_epoch() {
628 let s = format_rfc3339_utc(ts(0, 0));
629 assert_eq!(s, "1970-01-01T00:00:00.000Z");
630 }
631
632 fn bare_entry() -> ChangeEntry<'static> {
633 ChangeEntry {
634 kind: MutationKind::Create,
635 entity: Some("spec:x"),
636 actor: Actor::Agent,
637 client: None,
638 note: None,
639 logical_operation_id: None,
640 role: crate::vcs::Role::Unspecified,
641 }
642 }
643
644 fn line_ts(line: &str) -> String {
645 serde_json::from_str::<serde_json::Value>(line).unwrap()["ts"]
646 .as_str()
647 .unwrap()
648 .to_string()
649 }
650
651 #[test]
652 fn monotonic_append_bumps_same_millisecond_timestamp() {
653 let tmp = TempDir::new().unwrap();
657 let now = ts(1_778_243_696, 500);
658 append_change_monotonic(tmp.path(), &bare_entry(), now).unwrap();
659 append_change_monotonic(tmp.path(), &bare_entry(), now).unwrap();
660 let lines = read_lines(&changelog_path(tmp.path()));
661 assert_eq!(line_ts(&lines[0]), "2026-05-08T12:34:56.500Z");
662 assert_eq!(line_ts(&lines[1]), "2026-05-08T12:34:56.501Z");
663 }
664
665 #[test]
666 fn monotonic_append_bumps_backwards_clock() {
667 let tmp = TempDir::new().unwrap();
668 append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_696, 500)).unwrap();
669 append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_695, 0)).unwrap();
672 let lines = read_lines(&changelog_path(tmp.path()));
673 assert_eq!(line_ts(&lines[1]), "2026-05-08T12:34:56.501Z");
674 }
675
676 #[test]
677 fn monotonic_append_keeps_strictly_later_timestamp_verbatim() {
678 let tmp = TempDir::new().unwrap();
679 append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_696, 500)).unwrap();
680 append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_696, 501)).unwrap();
681 let lines = read_lines(&changelog_path(tmp.path()));
682 assert_eq!(line_ts(&lines[1]), "2026-05-08T12:34:56.501Z");
683 }
684
685 #[test]
686 fn rfc3339_parser_round_trips_known_dates() {
687 for &(secs, ms) in &[
688 (0u64, 0u32),
689 (1_715_000_000, 1),
690 (1_777_077_296, 0),
691 (1_778_243_696, 0),
692 (1_778_243_696, 999),
693 ] {
694 let t = ts(secs, ms);
695 let s = format_rfc3339_utc(t);
696 let parsed = parse_rfc3339_utc(&s).expect("parse round-trip");
697 assert_eq!(parsed, t, "round-trip failed for {s}");
698 }
699 }
700
701 #[test]
702 fn rfc3339_parser_rejects_malformed_input() {
703 assert!(parse_rfc3339_utc("").is_none());
704 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());
708 }
709
710 #[test]
711 fn mutation_kind_str_is_stable() {
712 assert_eq!(MutationKind::Create.as_str(), "create");
715 assert_eq!(MutationKind::Update.as_str(), "update");
716 assert_eq!(MutationKind::Delete.as_str(), "delete");
717 assert_eq!(MutationKind::Relate.as_str(), "relate");
718 assert_eq!(MutationKind::Rename.as_str(), "rename");
719 assert_eq!(MutationKind::Batch.as_str(), "batch");
720 }
721}