Skip to main content

memstead_base/ops/
commit_envelope.rs

1//! Per-commit wire envelope and entity-change variants.
2//!
3//! Engine-owned value types in the `memstead-base::ops` family
4//! (sibling to [`Diff`](crate::ops::Diff),
5//! [`ChangeEnvelope`](crate::ops::ChangeEnvelope),
6//! [`MemChangedEvent`](crate::engine::MemChangedEvent)).
7//!
8//! Mirrors the browser-sync JSON shapes one-to-one: the commit
9//! envelope and the SSE event.
10//!
11//! Two producers exist today:
12//! - `memstead_bridge::builder` — walks a git-branch tree-diff to build
13//!   envelopes from native repos for thin-client consumers.
14//! - WASM clients — receive envelopes over the bridge wire and pass
15//!   them to [`crate::Engine::apply_external_commit`] to materialize
16//!   the new state in their in-memory store.
17//!
18//! Field order matches the spec; field names are the canonical wire
19//! identifiers — do not rename or reshape without bumping the
20//! wire-format version.
21
22use std::collections::BTreeMap;
23
24use serde::{Deserialize, Serialize};
25
26/// One commit's wire envelope. JSON example from the spec:
27///
28/// ```json
29/// {
30///   "sha": "c4f2a8...",
31///   "parent": "a3f9b1...",
32///   "mem": "engine",
33///   "timestamp": "2026-05-18T14:23:01Z",
34///   "trailers": { "Tool": "memstead_update", "Actor": "agent" },
35///   "changes": [
36///     { "op": "modified", "path": "engine--mem.md", "content": "..." }
37///   ]
38/// }
39/// ```
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct CommitEnvelope {
42    /// Full commit SHA.
43    pub sha: String,
44    /// Parent commit SHA. Empty string for the first commit of a
45    /// branch (no parent).
46    #[serde(skip_serializing_if = "String::is_empty", default)]
47    pub parent: String,
48    /// Mem name this commit landed on.
49    pub mem: String,
50    /// Commit timestamp in RFC 3339 / ISO 8601 form (UTC, second
51    /// granularity).
52    pub timestamp: String,
53    /// Commit-message trailers parsed via the engine's standard
54    /// trailer convention. Keyed by trailer name (e.g. `Tool`,
55    /// `Actor`, `Client`, `Replays`, `Integration-Run`).
56    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
57    pub trailers: BTreeMap<String, String>,
58    /// Per-entity changes this commit introduced.
59    pub changes: Vec<EntityChange>,
60}
61
62/// One entity-level change carried by a [`CommitEnvelope`]. Tagged
63/// via the `op` discriminator so the wire shape matches the spec's
64/// `{ "op": "...", ... }` envelope.
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(tag = "op", rename_all = "lowercase")]
67pub enum EntityChange {
68    /// Entity newly created in this commit.
69    Added {
70        /// Mem-relative path on the new side (`.md` suffix
71        /// included).
72        path: String,
73        /// Full markdown body on the new side.
74        content: String,
75    },
76    /// Entity body changed in this commit.
77    Modified { path: String, content: String },
78    /// Entity removed in this commit. No content travels.
79    Deleted { path: String },
80    /// Entity renamed in this commit. `from` is the pre-rename
81    /// path, `to` is the post-rename path; `content` is the body
82    /// on the new side.
83    Renamed {
84        from: String,
85        to: String,
86        content: String,
87    },
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn commit_envelope_json_matches_spec_example() {
96        let env = CommitEnvelope {
97            sha: "c4f2a8".to_string(),
98            parent: "a3f9b1".to_string(),
99            mem: "engine".to_string(),
100            timestamp: "2026-05-18T14:23:01Z".to_string(),
101            trailers: {
102                let mut m = BTreeMap::new();
103                m.insert("Tool".to_string(), "memstead_update".to_string());
104                m.insert("Actor".to_string(), "agent".to_string());
105                m
106            },
107            changes: vec![
108                EntityChange::Modified {
109                    path: "engine--mem.md".to_string(),
110                    content: "body".to_string(),
111                },
112                EntityChange::Deleted {
113                    path: "engine--alt.md".to_string(),
114                },
115            ],
116        };
117        let json = serde_json::to_value(&env).unwrap();
118        assert_eq!(json["sha"], "c4f2a8");
119        assert_eq!(json["parent"], "a3f9b1");
120        assert_eq!(json["mem"], "engine");
121        assert_eq!(json["timestamp"], "2026-05-18T14:23:01Z");
122        assert_eq!(json["trailers"]["Tool"], "memstead_update");
123        assert_eq!(json["changes"][0]["op"], "modified");
124        assert_eq!(json["changes"][0]["path"], "engine--mem.md");
125        assert_eq!(json["changes"][1]["op"], "deleted");
126    }
127
128    #[test]
129    fn entity_change_renamed_serialises_with_from_to() {
130        let c = EntityChange::Renamed {
131            from: "engine--x.md".to_string(),
132            to: "engine--z.md".to_string(),
133            content: "body".to_string(),
134        };
135        let json = serde_json::to_value(&c).unwrap();
136        assert_eq!(json["op"], "renamed");
137        assert_eq!(json["from"], "engine--x.md");
138        assert_eq!(json["to"], "engine--z.md");
139        assert_eq!(json["content"], "body");
140    }
141}