Skip to main content

memstead_base/filesystem/
changelog.rs

1//! JSONL changelog at `.memstead/changes.jsonl` for filesystem mems.
2//!
3//! filesystem mems have no commit history, so the agent's `note` parameter and
4//! the per-mutation provenance trail (timestamp, mutation type, entity
5//! id, actor) lands here instead. Append-only; one line per mutation;
6//! the file is created lazily on first append.
7//!
8//! ## Line shape
9//!
10//! Each line is a JSON object terminated by `\n`:
11//!
12//! ```json
13//! {"ts":"2026-05-08T15:42:13.001Z","kind":"create","entity":"mem:slug","actor":"agent","note":"first draft"}
14//! ```
15//!
16//! - `ts` — RFC 3339 timestamp with millisecond precision, always UTC
17//!   (`Z` suffix). Sortable lexicographically.
18//! - `kind` — mutation kind: `create`, `update`, `delete`, `relate`,
19//!   `rename`, or `batch`. Future kinds extend the set; readers should
20//!   tolerate unknown values.
21//! - `entity` — mem-relative entity id (`mem:slug` form), or
22//!   `null` for batch mutations that span multiple entities.
23//! - `actor` — caller category from
24//!   [`memstead_base::vcs::Actor::as_trailer`]: `agent`, `cli`, `external`,
25//!   `unknown`.
26//! - `note` — agent-authored provenance note when present. Omitted
27//!   from the JSON object when absent or whitespace-only.
28//! - `client` — optional caller identity (`name@version`). Omitted
29//!   when absent.
30//!
31//! ## Atomicity
32//!
33//! Writes use `OpenOptions::append(true)` with a single `write_all`
34//! call against the buffered line. POSIX guarantees `O_APPEND` writes
35//! ≤ `PIPE_BUF` (4 KiB on Linux/macOS) appear atomically; the line
36//! shape stays well under that. Concurrent writers from a single
37//! process are serialised by the file's append-mode kernel lock.
38//! Cross-process concurrency is out of scope (filesystem mems are single-writer
39//! per the plan).
40
41use std::io::Write as _;
42use std::path::{Path, PathBuf};
43
44use serde::Serialize;
45
46use crate::provenance::ProvenanceKind;
47use crate::vcs::{Actor, ClientId};
48
49/// Conventional path of the changelog inside a workspace root.
50pub fn changelog_path(workspace_root: &Path) -> PathBuf {
51    workspace_root
52        .join(crate::mem::MEM_META_DIR)
53        .join("changes.jsonl")
54}
55
56/// Mutation kind written to the `kind` field of each line. The set
57/// covers today's MCP mutating tools; new mutations extend the enum.
58/// Readers branch on the string form (stable wire shape).
59#[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
82/// Two enums name the same six mutation classes — one is the legacy
83/// folder-backend on-disk encoder, the other is the backend-neutral
84/// shape consumed by [`crate::backend::MemBackend`]. Bridge here so
85/// callers crossing between the two surfaces don't drift.
86impl 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
112/// A single mutation event. Built at the call site in the filesystem
113/// engine (criterion 1's wiring) and passed to [`append_change`].
114pub struct ChangeEntry<'a> {
115    pub kind: MutationKind,
116    /// Mem-relative entity id, or `None` for batch mutations.
117    pub entity: Option<&'a str>,
118    pub actor: Actor,
119    /// Optional caller identity (e.g. `claude-code@2.1.0`).
120    pub client: Option<&'a ClientId>,
121    /// Optional agent-authored provenance note. Whitespace-only values
122    /// are treated as absent.
123    pub note: Option<&'a str>,
124    /// Correlation id linking every commit produced by a single
125    /// logical operation (notably multi-mem `memstead_rename`). `None`
126    /// for single-call mutations that don't participate in
127    /// correlation. Round-trips through the JSONL wire shape; reader
128    /// reconstructs the same value on `read_provenance`.
129    pub logical_operation_id: Option<&'a str>,
130}
131
132/// Errors surfaced by [`append_change`].
133#[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
145/// Append a single change to `<workspace_root>/.memstead/changes.jsonl`.
146/// Creates the `.memstead/` parent directory and the file if absent.
147pub 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
152/// Variant of [`append_change`] that takes the timestamp explicitly.
153/// Used by tests that need deterministic line ordering; production
154/// callers go through [`append_change`].
155pub 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
217/// Format a `SystemTime` as RFC 3339 with millisecond precision and
218/// the `Z` suffix. Always UTC. Hand-rolled because the project does
219/// not pull in `chrono` and the underlying `std::time::SystemTime` is
220/// epoch-based.
221///
222/// Public so the folder-backend [`crate::backend::MemBackend`] impl
223/// can reuse the same encoder when it constructs cursor strings.
224pub 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
234/// Inverse of [`format_rfc3339_utc`]. Returns `None` when `s` is not
235/// the exact 24-character `YYYY-MM-DDTHH:MM:SS.mmmZ` shape this
236/// crate emits — round-trip precision is the contract; lenient
237/// parsing is not. Used by the folder-backend `MemBackend::read_provenance`
238/// impl when reconstructing [`crate::provenance::Provenance`] records
239/// from on-disk JSONL lines.
240pub 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
283/// Civil-to-days inverse of the algorithm in [`decompose_unix_seconds`].
284/// Returns days since 1970-01-01. Hinnant's algorithm.
285fn 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
303/// Decompose a UNIX-epoch second count into (year, month, day, hour,
304/// minute, second) in UTC. Algorithm from Howard Hinnant's
305/// [chrono-Compatible Low-Level Date Algorithms]
306/// (https://howardhinnant.github.io/date_algorithms.html).
307///
308/// Valid for any seconds-since-epoch value the caller is likely to
309/// observe (`SystemTime` on POSIX is bounded by `time_t`, well within
310/// the algorithm's i64-domain).
311fn 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    // Civil-from-days.
320    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        // Timestamp shape is RFC 3339 UTC, ms precision, sortable.
385        let ts_str = value["ts"].as_str().unwrap();
386        assert!(ts_str.ends_with("Z"));
387        assert!(ts_str.contains("T"));
388        // 2024-05-06T12:53:20 UTC + 1ms == seconds=1_715_000_000, ms=1.
389        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        // The `memstead init` step is responsible for creating `.memstead/`,
519        // but the changelog must not require an extra setup call —
520        // the helper creates the parent on demand. Verify that just
521        // reading `changelog_path` does not touch disk.
522        let _ = changelog_path(tmp.path());
523        assert!(!tmp.path().join(".memstead").exists());
524    }
525
526    #[test]
527    fn timestamp_handles_year_2026() {
528        // Sanity-check the civil-from-days helper at a recent epoch
529        // that does not sit on a friendly boundary. seconds=1_777_077_296
530        // is 2026-04-25 00:34:56 UTC.
531        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        // 2026-05-08T12:34:56 UTC. Verifies the helper holds across
538        // months — leap-year handling at year boundaries lives in the
539        // epoch and 2024 tests above.
540        // Days since 1970-01-01:
541        //   56 full years (14 leap) + day-of-year 127 = 20581 days
542        // 20581 * 86400 + 12*3600 + 34*60 + 56 = 1_778_243_696
543        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()); // missing ms
573        assert!(parse_rfc3339_utc("2026-05-08T12:34:56.000+02:00").is_none()); // wrong tz
574        assert!(parse_rfc3339_utc("2026-13-08T12:34:56.000Z").is_none()); // bad month
575        assert!(parse_rfc3339_utc("not a date at all aaaaa").is_none());
576    }
577
578    #[test]
579    fn mutation_kind_str_is_stable() {
580        // The string form is the wire shape — these values are read by
581        // external tools (jq, grep). Lock them.
582        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}