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`, `app`,
25//!   `external`, `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    /// Caller-declared role (agent-trust plan 13); `Unspecified`
131    /// omits the field — absence recorded as absence.
132    pub role: crate::vcs::Role,
133}
134
135/// Errors surfaced by [`append_change`].
136#[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
148/// Append a single change to `<workspace_root>/.memstead/changes.jsonl`.
149/// Creates the `.memstead/` parent directory and the file if absent.
150pub 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
155/// Variant of [`append_change_at`] that guarantees the written `ts`
156/// is strictly greater than the changelog's current last-line `ts`.
157///
158/// The last-line `ts` doubles as the folder backend's drift cursor
159/// (`current_head()`), and the engine's self-write bookkeeping treats
160/// "cursor unchanged" as "no commit landed" — so two mutations landing
161/// inside the same millisecond must not share a `ts`, or the second
162/// one becomes invisible to drift detection and the events channel.
163/// When `now` is not strictly after the last line (same-millisecond
164/// commits, or a clock that stepped backwards), the written timestamp
165/// is bumped to `last + 1ms`. The format and the lexicographic-cursor
166/// dialect are unchanged — strictly increasing fixed-width RFC 3339
167/// stays strictly increasing lexicographically.
168///
169/// Production callers route here; [`append_change_at`] stays the exact
170/// write-this-timestamp primitive for deterministic tests.
171pub fn append_change_monotonic(
172    workspace_root: &Path,
173    entry: &ChangeEntry<'_>,
174    now: std::time::SystemTime,
175) -> Result<(), ChangelogError> {
176    // Compare at the cursor's own granularity — the formatted
177    // millisecond string — not raw `SystemTime`: `now` carries
178    // sub-millisecond nanos that make it "later" than the parsed
179    // last-line ts even when both format to the same millisecond,
180    // which is exactly the collision the clamp exists to prevent.
181    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
190/// Read the `ts` of the changelog's last non-empty line, parsed back
191/// to a `SystemTime`. `None` when the file is absent, unreadable, or
192/// its last line doesn't carry a parseable `ts` — the monotonic clamp
193/// is best-effort and must never block an append. Reads only the tail
194/// of the file (last 4 KiB) so appends stay O(1) in changelog length;
195/// a line is well under 4 KiB per the atomicity contract above.
196fn 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    // Lossy: a seek that lands mid-UTF-8-char only garbles the first
205    // (incomplete) line of the tail, which the rev() scan never needs.
206    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
222/// Variant of [`append_change`] that takes the timestamp explicitly.
223/// Used by tests that need deterministic line ordering; production
224/// callers go through [`append_change`].
225pub 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
290/// Format a `SystemTime` as RFC 3339 with millisecond precision and
291/// the `Z` suffix. Always UTC. Hand-rolled because the project does
292/// not pull in `chrono` and the underlying `std::time::SystemTime` is
293/// epoch-based.
294///
295/// Public so the folder-backend [`crate::backend::MemBackend`] impl
296/// can reuse the same encoder when it constructs cursor strings.
297pub 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
307/// Inverse of [`format_rfc3339_utc`]. Returns `None` when `s` is not
308/// the exact 24-character `YYYY-MM-DDTHH:MM:SS.mmmZ` shape this
309/// crate emits — round-trip precision is the contract; lenient
310/// parsing is not. Used by the folder-backend `MemBackend::read_provenance`
311/// impl when reconstructing [`crate::provenance::Provenance`] records
312/// from on-disk JSONL lines.
313pub 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
356/// Civil-to-days inverse of the algorithm in [`decompose_unix_seconds`].
357/// Returns days since 1970-01-01. Hinnant's algorithm.
358fn 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
376/// Decompose a UNIX-epoch second count into (year, month, day, hour,
377/// minute, second) in UTC. Algorithm from Howard Hinnant's
378/// [chrono-Compatible Low-Level Date Algorithms]
379/// (https://howardhinnant.github.io/date_algorithms.html).
380///
381/// Valid for any seconds-since-epoch value the caller is likely to
382/// observe (`SystemTime` on POSIX is bounded by `time_t`, well within
383/// the algorithm's i64-domain).
384fn 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    // Civil-from-days.
393    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        // Timestamp shape is RFC 3339 UTC, ms precision, sortable.
459        let ts_str = value["ts"].as_str().unwrap();
460        assert!(ts_str.ends_with("Z"));
461        assert!(ts_str.contains("T"));
462        // 2024-05-06T12:53:20 UTC + 1ms == seconds=1_715_000_000, ms=1.
463        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        // The `memstead init` step is responsible for creating `.memstead/`,
598        // but the changelog must not require an extra setup call —
599        // the helper creates the parent on demand. Verify that just
600        // reading `changelog_path` does not touch disk.
601        let _ = changelog_path(tmp.path());
602        assert!(!tmp.path().join(".memstead").exists());
603    }
604
605    #[test]
606    fn timestamp_handles_year_2026() {
607        // Sanity-check the civil-from-days helper at a recent epoch
608        // that does not sit on a friendly boundary. seconds=1_777_077_296
609        // is 2026-04-25 00:34:56 UTC.
610        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        // 2026-05-08T12:34:56 UTC. Verifies the helper holds across
617        // months — leap-year handling at year boundaries lives in the
618        // epoch and 2024 tests above.
619        // Days since 1970-01-01:
620        //   56 full years (14 leap) + day-of-year 127 = 20581 days
621        // 20581 * 86400 + 12*3600 + 34*60 + 56 = 1_778_243_696
622        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        // The last-line ts is the folder drift cursor; two commits in
654        // the same millisecond must still advance it, or the second
655        // becomes invisible to drift detection and the events channel.
656        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        // Clock stepped backwards a full second — still clamps to
670        // last + 1ms rather than writing a regressing cursor.
671        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()); // missing ms
705        assert!(parse_rfc3339_utc("2026-05-08T12:34:56.000+02:00").is_none()); // wrong tz
706        assert!(parse_rfc3339_utc("2026-13-08T12:34:56.000Z").is_none()); // bad month
707        assert!(parse_rfc3339_utc("not a date at all aaaaa").is_none());
708    }
709
710    #[test]
711    fn mutation_kind_str_is_stable() {
712        // The string form is the wire shape — these values are read by
713        // external tools (jq, grep). Lock them.
714        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}