use std::io::Write as _;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::provenance::ProvenanceKind;
use crate::vcs::{Actor, ClientId};
pub fn changelog_path(workspace_root: &Path) -> PathBuf {
workspace_root
.join(crate::mem::MEM_META_DIR)
.join("changes.jsonl")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MutationKind {
Create,
Update,
Delete,
Relate,
Rename,
Batch,
}
impl MutationKind {
pub fn as_str(&self) -> &'static str {
match self {
MutationKind::Create => "create",
MutationKind::Update => "update",
MutationKind::Delete => "delete",
MutationKind::Relate => "relate",
MutationKind::Rename => "rename",
MutationKind::Batch => "batch",
}
}
}
impl From<ProvenanceKind> for MutationKind {
fn from(k: ProvenanceKind) -> Self {
match k {
ProvenanceKind::Create => MutationKind::Create,
ProvenanceKind::Update => MutationKind::Update,
ProvenanceKind::Delete => MutationKind::Delete,
ProvenanceKind::Relate => MutationKind::Relate,
ProvenanceKind::Rename => MutationKind::Rename,
ProvenanceKind::Batch => MutationKind::Batch,
}
}
}
impl From<MutationKind> for ProvenanceKind {
fn from(k: MutationKind) -> Self {
match k {
MutationKind::Create => ProvenanceKind::Create,
MutationKind::Update => ProvenanceKind::Update,
MutationKind::Delete => ProvenanceKind::Delete,
MutationKind::Relate => ProvenanceKind::Relate,
MutationKind::Rename => ProvenanceKind::Rename,
MutationKind::Batch => ProvenanceKind::Batch,
}
}
}
pub struct ChangeEntry<'a> {
pub kind: MutationKind,
pub entity: Option<&'a str>,
pub actor: Actor,
pub client: Option<&'a ClientId>,
pub note: Option<&'a str>,
pub logical_operation_id: Option<&'a str>,
pub role: crate::vcs::Role,
}
#[derive(Debug, thiserror::Error)]
pub enum ChangelogError {
#[error("changelog io error at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("changelog serialisation: {0}")]
Serialise(#[from] serde_json::Error),
}
pub fn append_change(workspace_root: &Path, entry: &ChangeEntry<'_>) -> Result<(), ChangelogError> {
let now = std::time::SystemTime::now();
append_change_monotonic(workspace_root, entry, now)
}
pub fn append_change_monotonic(
workspace_root: &Path,
entry: &ChangeEntry<'_>,
now: std::time::SystemTime,
) -> Result<(), ChangelogError> {
let effective = match last_line_ts(&changelog_path(workspace_root)) {
Some(last) if format_rfc3339_utc(now) <= format_rfc3339_utc(last) => {
last + std::time::Duration::from_millis(1)
}
_ => now,
};
append_change_at(workspace_root, entry, effective)
}
fn last_line_ts(path: &Path) -> Option<std::time::SystemTime> {
use std::io::{Read as _, Seek as _, SeekFrom};
let mut file = std::fs::File::open(path).ok()?;
let len = file.metadata().ok()?.len();
let start = len.saturating_sub(4096);
file.seek(SeekFrom::Start(start)).ok()?;
let mut buf = Vec::new();
file.read_to_end(&mut buf).ok()?;
let tail = String::from_utf8_lossy(&buf);
tail.lines()
.rev()
.map(str::trim)
.filter(|l| !l.is_empty())
.filter_map(|l| {
let ts = serde_json::from_str::<serde_json::Value>(l)
.ok()?
.get("ts")?
.as_str()?
.to_string();
parse_rfc3339_utc(&ts)
})
.next()
}
pub fn append_change_at(
workspace_root: &Path,
entry: &ChangeEntry<'_>,
now: std::time::SystemTime,
) -> Result<(), ChangelogError> {
let target = changelog_path(workspace_root);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| ChangelogError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
let ts = format_rfc3339_utc(now);
let note = entry
.note
.map(str::trim)
.filter(|n| !n.is_empty())
.map(|s| s.to_string());
let client = entry.client.map(|c| format!("{}@{}", c.name, c.version));
#[derive(Serialize)]
struct Wire<'a> {
ts: &'a str,
kind: &'a str,
entity: Option<&'a str>,
actor: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
client: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "logical_op")]
logical_operation_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
role: Option<&'static str>,
}
let mut line = serde_json::to_string(&Wire {
ts: &ts,
kind: entry.kind.as_str(),
entity: entry.entity,
actor: entry.actor.as_trailer(),
note,
client,
role: entry.role.as_trailer(),
logical_operation_id: entry.logical_operation_id,
})?;
line.push('\n');
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&target)
.map_err(|e| ChangelogError::Io {
path: target.clone(),
source: e,
})?;
file.write_all(line.as_bytes())
.map_err(|e| ChangelogError::Io {
path: target,
source: e,
})?;
Ok(())
}
pub fn format_rfc3339_utc(now: std::time::SystemTime) -> String {
let dur = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let total_secs = dur.as_secs();
let millis = dur.subsec_millis();
let (y, m, d, hh, mm, ss) = decompose_unix_seconds(total_secs);
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}.{millis:03}Z")
}
pub fn parse_rfc3339_utc(s: &str) -> Option<std::time::SystemTime> {
let bytes = s.as_bytes();
if bytes.len() != 24
|| bytes[23] != b'Z'
|| bytes[4] != b'-'
|| bytes[7] != b'-'
|| bytes[10] != b'T'
|| bytes[13] != b':'
|| bytes[16] != b':'
|| bytes[19] != b'.'
{
return None;
}
let year: i32 = s.get(0..4)?.parse().ok()?;
let month: u32 = s.get(5..7)?.parse().ok()?;
let day: u32 = s.get(8..10)?.parse().ok()?;
let hour: u32 = s.get(11..13)?.parse().ok()?;
let minute: u32 = s.get(14..16)?.parse().ok()?;
let second: u32 = s.get(17..19)?.parse().ok()?;
let millis: u32 = s.get(20..23)?.parse().ok()?;
if month == 0
|| month > 12
|| day == 0
|| day > 31
|| hour > 23
|| minute > 59
|| second > 60
|| millis > 999
{
return None;
}
let days = ymd_to_days(year, month, day)?;
let total_secs = days
.checked_mul(86_400)?
.checked_add(hour as i64 * 3_600)?
.checked_add(minute as i64 * 60)?
.checked_add(second as i64)?;
if total_secs < 0 {
return None;
}
Some(std::time::UNIX_EPOCH + std::time::Duration::new(total_secs as u64, millis * 1_000_000))
}
fn ymd_to_days(year: i32, month: u32, day: u32) -> Option<i64> {
let y = if month <= 2 {
year as i64 - 1
} else {
year as i64
};
let m = month as i64;
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
if !(0..=399).contains(&yoe) {
return None;
}
let doy_offset = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * doy_offset + 2) / 5 + day as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
Some(era * 146_097 + doe - 719_468)
}
fn decompose_unix_seconds(total_secs: u64) -> (i32, u32, u32, u32, u32, u32) {
const SECONDS_PER_DAY: u64 = 86_400;
let days = (total_secs / SECONDS_PER_DAY) as i64;
let secs_of_day = (total_secs % SECONDS_PER_DAY) as u32;
let hh = secs_of_day / 3_600;
let mm = (secs_of_day / 60) % 60;
let ss = secs_of_day % 60;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = (yoe + era * 400) as i32;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 {
(mp + 3) as u32
} else {
(mp - 9) as u32
};
let y = if m <= 2 { y + 1 } else { y };
(y, m, d, hh, mm, ss)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vcs::{Actor, ClientId};
use tempfile::TempDir;
fn read_lines(path: &Path) -> Vec<String> {
std::fs::read_to_string(path)
.unwrap()
.lines()
.map(|s| s.to_string())
.collect()
}
fn ts(seconds: u64, millis: u32) -> std::time::SystemTime {
std::time::UNIX_EPOCH + std::time::Duration::new(seconds, millis * 1_000_000)
}
#[test]
fn appends_a_create_line_with_all_fields() {
let tmp = TempDir::new().unwrap();
let client = ClientId {
name: "claude-code".into(),
version: "2.1.0".into(),
};
append_change_at(
tmp.path(),
&ChangeEntry {
kind: MutationKind::Create,
entity: Some("spec:hello"),
actor: Actor::Agent,
client: Some(&client),
note: Some("first draft"),
logical_operation_id: None,
role: crate::vcs::Role::Unspecified,
},
ts(1_715_000_000, 1),
)
.unwrap();
let lines = read_lines(&changelog_path(tmp.path()));
assert_eq!(lines.len(), 1);
let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(value["kind"], "create");
assert_eq!(value["entity"], "spec:hello");
assert_eq!(value["actor"], "agent");
assert_eq!(value["client"], "claude-code@2.1.0");
assert_eq!(value["note"], "first draft");
let ts_str = value["ts"].as_str().unwrap();
assert!(ts_str.ends_with("Z"));
assert!(ts_str.contains("T"));
assert_eq!(ts_str, "2024-05-06T12:53:20.001Z");
}
#[test]
fn omits_optional_fields_when_absent() {
let tmp = TempDir::new().unwrap();
append_change_at(
tmp.path(),
&ChangeEntry {
kind: MutationKind::Update,
entity: Some("spec:foo"),
actor: Actor::Cli,
client: None,
note: None,
logical_operation_id: None,
role: crate::vcs::Role::Unspecified,
},
ts(0, 0),
)
.unwrap();
let lines = read_lines(&changelog_path(tmp.path()));
let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert!(value.get("note").is_none());
assert!(value.get("client").is_none());
}
#[test]
fn whitespace_only_note_is_treated_as_absent() {
let tmp = TempDir::new().unwrap();
append_change_at(
tmp.path(),
&ChangeEntry {
kind: MutationKind::Update,
entity: Some("spec:foo"),
actor: Actor::Cli,
client: None,
note: Some(" \t "),
logical_operation_id: None,
role: crate::vcs::Role::Unspecified,
},
ts(0, 0),
)
.unwrap();
let lines = read_lines(&changelog_path(tmp.path()));
let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert!(value.get("note").is_none());
}
#[test]
fn batch_mutation_writes_null_entity() {
let tmp = TempDir::new().unwrap();
append_change_at(
tmp.path(),
&ChangeEntry {
kind: MutationKind::Batch,
entity: None,
actor: Actor::Agent,
client: None,
note: Some("multi-entity refactor"),
logical_operation_id: None,
role: crate::vcs::Role::Unspecified,
},
ts(0, 0),
)
.unwrap();
let lines = read_lines(&changelog_path(tmp.path()));
let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert!(value["entity"].is_null());
assert_eq!(value["kind"], "batch");
}
#[test]
fn appends_create_then_update_in_order() {
let tmp = TempDir::new().unwrap();
for (kind, ent, t) in [
(MutationKind::Create, "a", 0),
(MutationKind::Update, "a", 1),
(MutationKind::Delete, "a", 2),
] {
append_change_at(
tmp.path(),
&ChangeEntry {
kind,
entity: Some(ent),
actor: Actor::Cli,
client: None,
note: None,
logical_operation_id: None,
role: crate::vcs::Role::Unspecified,
},
ts(t, 0),
)
.unwrap();
}
let lines = read_lines(&changelog_path(tmp.path()));
assert_eq!(lines.len(), 3);
let kinds: Vec<String> = lines
.iter()
.map(|line| {
serde_json::from_str::<serde_json::Value>(line).unwrap()["kind"]
.as_str()
.unwrap()
.to_string()
})
.collect();
assert_eq!(kinds, vec!["create", "update", "delete"]);
}
#[test]
fn creates_memstead_parent_lazily() {
let tmp = TempDir::new().unwrap();
assert!(!tmp.path().join(".memstead").exists());
append_change_at(
tmp.path(),
&ChangeEntry {
kind: MutationKind::Create,
entity: Some("a"),
actor: Actor::Cli,
client: None,
note: None,
logical_operation_id: None,
role: crate::vcs::Role::Unspecified,
},
ts(0, 0),
)
.unwrap();
assert!(tmp.path().join(".memstead").is_dir());
assert!(tmp.path().join(".memstead").join("changes.jsonl").is_file());
}
#[test]
fn does_not_create_memstead_until_first_change() {
let tmp = TempDir::new().unwrap();
let _ = changelog_path(tmp.path());
assert!(!tmp.path().join(".memstead").exists());
}
#[test]
fn timestamp_handles_year_2026() {
let s = format_rfc3339_utc(ts(1_777_077_296, 0));
assert_eq!(s, "2026-04-25T00:34:56.000Z");
}
#[test]
fn timestamp_round_trips_a_known_2026_date() {
let s = format_rfc3339_utc(ts(1_778_243_696, 0));
assert_eq!(s, "2026-05-08T12:34:56.000Z");
}
#[test]
fn timestamp_handles_epoch() {
let s = format_rfc3339_utc(ts(0, 0));
assert_eq!(s, "1970-01-01T00:00:00.000Z");
}
fn bare_entry() -> ChangeEntry<'static> {
ChangeEntry {
kind: MutationKind::Create,
entity: Some("spec:x"),
actor: Actor::Agent,
client: None,
note: None,
logical_operation_id: None,
role: crate::vcs::Role::Unspecified,
}
}
fn line_ts(line: &str) -> String {
serde_json::from_str::<serde_json::Value>(line).unwrap()["ts"]
.as_str()
.unwrap()
.to_string()
}
#[test]
fn monotonic_append_bumps_same_millisecond_timestamp() {
let tmp = TempDir::new().unwrap();
let now = ts(1_778_243_696, 500);
append_change_monotonic(tmp.path(), &bare_entry(), now).unwrap();
append_change_monotonic(tmp.path(), &bare_entry(), now).unwrap();
let lines = read_lines(&changelog_path(tmp.path()));
assert_eq!(line_ts(&lines[0]), "2026-05-08T12:34:56.500Z");
assert_eq!(line_ts(&lines[1]), "2026-05-08T12:34:56.501Z");
}
#[test]
fn monotonic_append_bumps_backwards_clock() {
let tmp = TempDir::new().unwrap();
append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_696, 500)).unwrap();
append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_695, 0)).unwrap();
let lines = read_lines(&changelog_path(tmp.path()));
assert_eq!(line_ts(&lines[1]), "2026-05-08T12:34:56.501Z");
}
#[test]
fn monotonic_append_keeps_strictly_later_timestamp_verbatim() {
let tmp = TempDir::new().unwrap();
append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_696, 500)).unwrap();
append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_696, 501)).unwrap();
let lines = read_lines(&changelog_path(tmp.path()));
assert_eq!(line_ts(&lines[1]), "2026-05-08T12:34:56.501Z");
}
#[test]
fn rfc3339_parser_round_trips_known_dates() {
for &(secs, ms) in &[
(0u64, 0u32),
(1_715_000_000, 1),
(1_777_077_296, 0),
(1_778_243_696, 0),
(1_778_243_696, 999),
] {
let t = ts(secs, ms);
let s = format_rfc3339_utc(t);
let parsed = parse_rfc3339_utc(&s).expect("parse round-trip");
assert_eq!(parsed, t, "round-trip failed for {s}");
}
}
#[test]
fn rfc3339_parser_rejects_malformed_input() {
assert!(parse_rfc3339_utc("").is_none());
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());
}
#[test]
fn mutation_kind_str_is_stable() {
assert_eq!(MutationKind::Create.as_str(), "create");
assert_eq!(MutationKind::Update.as_str(), "update");
assert_eq!(MutationKind::Delete.as_str(), "delete");
assert_eq!(MutationKind::Relate.as_str(), "relate");
assert_eq!(MutationKind::Rename.as_str(), "rename");
assert_eq!(MutationKind::Batch.as_str(), "batch");
}
}