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//! - Native embedders — walk a git-branch tree-diff to build envelopes
13//! 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}