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}
131
132#[derive(Debug, thiserror::Error)]
134pub enum ChangelogError {
135 #[error("changelog io error at {path}: {source}")]
136 Io {
137 path: PathBuf,
138 #[source]
139 source: std::io::Error,
140 },
141 #[error("changelog serialisation: {0}")]
142 Serialise(#[from] serde_json::Error),
143}
144
145pub fn append_change(workspace_root: &Path, entry: &ChangeEntry<'_>) -> Result<(), ChangelogError> {
148 let now = std::time::SystemTime::now();
149 append_change_at(workspace_root, entry, now)
150}
151
152pub fn append_change_at(
156 workspace_root: &Path,
157 entry: &ChangeEntry<'_>,
158 now: std::time::SystemTime,
159) -> Result<(), ChangelogError> {
160 let target = changelog_path(workspace_root);
161 if let Some(parent) = target.parent() {
162 std::fs::create_dir_all(parent).map_err(|e| ChangelogError::Io {
163 path: parent.to_path_buf(),
164 source: e,
165 })?;
166 }
167
168 let ts = format_rfc3339_utc(now);
169 let note = entry
170 .note
171 .map(str::trim)
172 .filter(|n| !n.is_empty())
173 .map(|s| s.to_string());
174 let client = entry.client.map(|c| format!("{}@{}", c.name, c.version));
175
176 #[derive(Serialize)]
177 struct Wire<'a> {
178 ts: &'a str,
179 kind: &'a str,
180 entity: Option<&'a str>,
181 actor: &'a str,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 note: Option<String>,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 client: Option<String>,
186 #[serde(skip_serializing_if = "Option::is_none", rename = "logical_op")]
187 logical_operation_id: Option<&'a str>,
188 }
189
190 let mut line = serde_json::to_string(&Wire {
191 ts: &ts,
192 kind: entry.kind.as_str(),
193 entity: entry.entity,
194 actor: entry.actor.as_trailer(),
195 note,
196 client,
197 logical_operation_id: entry.logical_operation_id,
198 })?;
199 line.push('\n');
200
201 let mut file = std::fs::OpenOptions::new()
202 .create(true)
203 .append(true)
204 .open(&target)
205 .map_err(|e| ChangelogError::Io {
206 path: target.clone(),
207 source: e,
208 })?;
209 file.write_all(line.as_bytes())
210 .map_err(|e| ChangelogError::Io {
211 path: target,
212 source: e,
213 })?;
214 Ok(())
215}
216
217pub fn format_rfc3339_utc(now: std::time::SystemTime) -> String {
225 let dur = now
226 .duration_since(std::time::UNIX_EPOCH)
227 .unwrap_or_default();
228 let total_secs = dur.as_secs();
229 let millis = dur.subsec_millis();
230 let (y, m, d, hh, mm, ss) = decompose_unix_seconds(total_secs);
231 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}.{millis:03}Z")
232}
233
234pub fn parse_rfc3339_utc(s: &str) -> Option<std::time::SystemTime> {
241 let bytes = s.as_bytes();
242 if bytes.len() != 24
243 || bytes[23] != b'Z'
244 || bytes[4] != b'-'
245 || bytes[7] != b'-'
246 || bytes[10] != b'T'
247 || bytes[13] != b':'
248 || bytes[16] != b':'
249 || bytes[19] != b'.'
250 {
251 return None;
252 }
253 let year: i32 = s.get(0..4)?.parse().ok()?;
254 let month: u32 = s.get(5..7)?.parse().ok()?;
255 let day: u32 = s.get(8..10)?.parse().ok()?;
256 let hour: u32 = s.get(11..13)?.parse().ok()?;
257 let minute: u32 = s.get(14..16)?.parse().ok()?;
258 let second: u32 = s.get(17..19)?.parse().ok()?;
259 let millis: u32 = s.get(20..23)?.parse().ok()?;
260 if month == 0
261 || month > 12
262 || day == 0
263 || day > 31
264 || hour > 23
265 || minute > 59
266 || second > 60
267 || millis > 999
268 {
269 return None;
270 }
271 let days = ymd_to_days(year, month, day)?;
272 let total_secs = days
273 .checked_mul(86_400)?
274 .checked_add(hour as i64 * 3_600)?
275 .checked_add(minute as i64 * 60)?
276 .checked_add(second as i64)?;
277 if total_secs < 0 {
278 return None;
279 }
280 Some(std::time::UNIX_EPOCH + std::time::Duration::new(total_secs as u64, millis * 1_000_000))
281}
282
283fn ymd_to_days(year: i32, month: u32, day: u32) -> Option<i64> {
286 let y = if month <= 2 {
287 year as i64 - 1
288 } else {
289 year as i64
290 };
291 let m = month as i64;
292 let era = if y >= 0 { y } else { y - 399 } / 400;
293 let yoe = y - era * 400;
294 if !(0..=399).contains(&yoe) {
295 return None;
296 }
297 let doy_offset = if m > 2 { m - 3 } else { m + 9 };
298 let doy = (153 * doy_offset + 2) / 5 + day as i64 - 1;
299 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
300 Some(era * 146_097 + doe - 719_468)
301}
302
303fn decompose_unix_seconds(total_secs: u64) -> (i32, u32, u32, u32, u32, u32) {
312 const SECONDS_PER_DAY: u64 = 86_400;
313 let days = (total_secs / SECONDS_PER_DAY) as i64;
314 let secs_of_day = (total_secs % SECONDS_PER_DAY) as u32;
315 let hh = secs_of_day / 3_600;
316 let mm = (secs_of_day / 60) % 60;
317 let ss = secs_of_day % 60;
318
319 let z = days + 719_468;
321 let era = z.div_euclid(146_097);
322 let doe = z - era * 146_097;
323 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
324 let y = (yoe + era * 400) as i32;
325 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
326 let mp = (5 * doy + 2) / 153;
327 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
328 let m = if mp < 10 {
329 (mp + 3) as u32
330 } else {
331 (mp - 9) as u32
332 };
333 let y = if m <= 2 { y + 1 } else { y };
334 (y, m, d, hh, mm, ss)
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use crate::vcs::{Actor, ClientId};
341 use tempfile::TempDir;
342
343 fn read_lines(path: &Path) -> Vec<String> {
344 std::fs::read_to_string(path)
345 .unwrap()
346 .lines()
347 .map(|s| s.to_string())
348 .collect()
349 }
350
351 fn ts(seconds: u64, millis: u32) -> std::time::SystemTime {
352 std::time::UNIX_EPOCH + std::time::Duration::new(seconds, millis * 1_000_000)
353 }
354
355 #[test]
356 fn appends_a_create_line_with_all_fields() {
357 let tmp = TempDir::new().unwrap();
358 let client = ClientId {
359 name: "claude-code".into(),
360 version: "2.1.0".into(),
361 };
362 append_change_at(
363 tmp.path(),
364 &ChangeEntry {
365 kind: MutationKind::Create,
366 entity: Some("spec:hello"),
367 actor: Actor::Agent,
368 client: Some(&client),
369 note: Some("first draft"),
370 logical_operation_id: None,
371 },
372 ts(1_715_000_000, 1),
373 )
374 .unwrap();
375
376 let lines = read_lines(&changelog_path(tmp.path()));
377 assert_eq!(lines.len(), 1);
378 let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
379 assert_eq!(value["kind"], "create");
380 assert_eq!(value["entity"], "spec:hello");
381 assert_eq!(value["actor"], "agent");
382 assert_eq!(value["client"], "claude-code@2.1.0");
383 assert_eq!(value["note"], "first draft");
384 let ts_str = value["ts"].as_str().unwrap();
386 assert!(ts_str.ends_with("Z"));
387 assert!(ts_str.contains("T"));
388 assert_eq!(ts_str, "2024-05-06T12:53:20.001Z");
390 }
391
392 #[test]
393 fn omits_optional_fields_when_absent() {
394 let tmp = TempDir::new().unwrap();
395 append_change_at(
396 tmp.path(),
397 &ChangeEntry {
398 kind: MutationKind::Update,
399 entity: Some("spec:foo"),
400 actor: Actor::Cli,
401 client: None,
402 note: None,
403 logical_operation_id: None,
404 },
405 ts(0, 0),
406 )
407 .unwrap();
408 let lines = read_lines(&changelog_path(tmp.path()));
409 let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
410 assert!(value.get("note").is_none());
411 assert!(value.get("client").is_none());
412 }
413
414 #[test]
415 fn whitespace_only_note_is_treated_as_absent() {
416 let tmp = TempDir::new().unwrap();
417 append_change_at(
418 tmp.path(),
419 &ChangeEntry {
420 kind: MutationKind::Update,
421 entity: Some("spec:foo"),
422 actor: Actor::Cli,
423 client: None,
424 note: Some(" \t "),
425 logical_operation_id: None,
426 },
427 ts(0, 0),
428 )
429 .unwrap();
430 let lines = read_lines(&changelog_path(tmp.path()));
431 let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
432 assert!(value.get("note").is_none());
433 }
434
435 #[test]
436 fn batch_mutation_writes_null_entity() {
437 let tmp = TempDir::new().unwrap();
438 append_change_at(
439 tmp.path(),
440 &ChangeEntry {
441 kind: MutationKind::Batch,
442 entity: None,
443 actor: Actor::Agent,
444 client: None,
445 note: Some("multi-entity refactor"),
446 logical_operation_id: None,
447 },
448 ts(0, 0),
449 )
450 .unwrap();
451 let lines = read_lines(&changelog_path(tmp.path()));
452 let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
453 assert!(value["entity"].is_null());
454 assert_eq!(value["kind"], "batch");
455 }
456
457 #[test]
458 fn appends_create_then_update_in_order() {
459 let tmp = TempDir::new().unwrap();
460 for (kind, ent, t) in [
461 (MutationKind::Create, "a", 0),
462 (MutationKind::Update, "a", 1),
463 (MutationKind::Delete, "a", 2),
464 ] {
465 append_change_at(
466 tmp.path(),
467 &ChangeEntry {
468 kind,
469 entity: Some(ent),
470 actor: Actor::Cli,
471 client: None,
472 note: None,
473 logical_operation_id: None,
474 },
475 ts(t, 0),
476 )
477 .unwrap();
478 }
479
480 let lines = read_lines(&changelog_path(tmp.path()));
481 assert_eq!(lines.len(), 3);
482 let kinds: Vec<String> = lines
483 .iter()
484 .map(|line| {
485 serde_json::from_str::<serde_json::Value>(line).unwrap()["kind"]
486 .as_str()
487 .unwrap()
488 .to_string()
489 })
490 .collect();
491 assert_eq!(kinds, vec!["create", "update", "delete"]);
492 }
493
494 #[test]
495 fn creates_memstead_parent_lazily() {
496 let tmp = TempDir::new().unwrap();
497 assert!(!tmp.path().join(".memstead").exists());
498 append_change_at(
499 tmp.path(),
500 &ChangeEntry {
501 kind: MutationKind::Create,
502 entity: Some("a"),
503 actor: Actor::Cli,
504 client: None,
505 note: None,
506 logical_operation_id: None,
507 },
508 ts(0, 0),
509 )
510 .unwrap();
511 assert!(tmp.path().join(".memstead").is_dir());
512 assert!(tmp.path().join(".memstead").join("changes.jsonl").is_file());
513 }
514
515 #[test]
516 fn does_not_create_memstead_until_first_change() {
517 let tmp = TempDir::new().unwrap();
518 let _ = changelog_path(tmp.path());
523 assert!(!tmp.path().join(".memstead").exists());
524 }
525
526 #[test]
527 fn timestamp_handles_year_2026() {
528 let s = format_rfc3339_utc(ts(1_777_077_296, 0));
532 assert_eq!(s, "2026-04-25T00:34:56.000Z");
533 }
534
535 #[test]
536 fn timestamp_round_trips_a_known_2026_date() {
537 let s = format_rfc3339_utc(ts(1_778_243_696, 0));
544 assert_eq!(s, "2026-05-08T12:34:56.000Z");
545 }
546
547 #[test]
548 fn timestamp_handles_epoch() {
549 let s = format_rfc3339_utc(ts(0, 0));
550 assert_eq!(s, "1970-01-01T00:00:00.000Z");
551 }
552
553 #[test]
554 fn rfc3339_parser_round_trips_known_dates() {
555 for &(secs, ms) in &[
556 (0u64, 0u32),
557 (1_715_000_000, 1),
558 (1_777_077_296, 0),
559 (1_778_243_696, 0),
560 (1_778_243_696, 999),
561 ] {
562 let t = ts(secs, ms);
563 let s = format_rfc3339_utc(t);
564 let parsed = parse_rfc3339_utc(&s).expect("parse round-trip");
565 assert_eq!(parsed, t, "round-trip failed for {s}");
566 }
567 }
568
569 #[test]
570 fn rfc3339_parser_rejects_malformed_input() {
571 assert!(parse_rfc3339_utc("").is_none());
572 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());
576 }
577
578 #[test]
579 fn mutation_kind_str_is_stable() {
580 assert_eq!(MutationKind::Create.as_str(), "create");
583 assert_eq!(MutationKind::Update.as_str(), "update");
584 assert_eq!(MutationKind::Delete.as_str(), "delete");
585 assert_eq!(MutationKind::Relate.as_str(), "relate");
586 assert_eq!(MutationKind::Rename.as_str(), "rename");
587 assert_eq!(MutationKind::Batch.as_str(), "batch");
588 }
589}