memstead_base/ops/diff.rs
1//! Two-ref structural diff types.
2//!
3//! `Diff` is the engine-level response shape for `Engine::diff(ref_a,
4//! ref_b, config)`. Consumers (LLM replay skills, PR-review UIs,
5//! pre-merge previews, snapshot comparisons, cross-mem reflection
6//! tools) all consume this one struct rather than re-walking git trees
7//! themselves.
8//!
9//! Wire format is deterministic and stable so external tooling
10//! (memstead-mcp, memstead-cli, future Webhooks) can deserialise into the same
11//! types it serialises.
12//!
13//! The rename-chain + ripple fields are motivated by the LLM-replay
14//! flow that consumes these diffs.
15
16use serde::{Deserialize, Serialize};
17
18use crate::entity::EntityId;
19
20/// Per-entity diff entry. Variants mirror the change kinds an
21/// entity-level diff can produce; `InvalidEntity` is the soft-failure
22/// path for entities that fail to parse on either side (consumers
23/// decide how to handle each case — `memstead_diff` does not refuse the
24/// whole call just because one entity is malformed).
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(tag = "status", rename_all = "snake_case")]
27pub enum EntityDiff {
28 /// Entity exists on the `ref_b` side and not on `ref_a`. No
29 /// rename was detected — this is a fresh entity.
30 Added {
31 id: EntityId,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 title: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 entity_type: Option<String>,
36 /// Full markdown body on the `ref_b` side. `None` when the
37 /// caller passed `include_content: false`.
38 #[serde(skip_serializing_if = "Option::is_none")]
39 content_after: Option<String>,
40 /// Entities on either side that link inbound to this id.
41 /// Empty when ripple is disabled or no inbound links exist.
42 #[serde(default, skip_serializing_if = "Vec::is_empty")]
43 ripple: Vec<IncomingRipple>,
44 },
45 /// Entity exists on both sides; bodies differ.
46 Modified {
47 id: EntityId,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 title: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 entity_type: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 content_before: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 content_after: Option<String>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 ripple: Vec<IncomingRipple>,
58 },
59 /// Entity exists on `ref_a` but not on `ref_b` — and no rename
60 /// was detected to a surviving entity.
61 Deleted {
62 id: EntityId,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 title: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 entity_type: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 content_before: Option<String>,
69 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 ripple: Vec<IncomingRipple>,
71 },
72 /// Entity was renamed from `from_id` (present on `ref_a`) to
73 /// `to_id` (present on `ref_b`). The two ids are reported with
74 /// the rename chain — the sequence of intermediate ids when the
75 /// rename passed through multiple commits between `ref_a` and
76 /// `ref_b`.
77 Renamed {
78 from_id: EntityId,
79 to_id: EntityId,
80 /// Intermediate ids the rename passed through, oldest to
81 /// newest. Empty for a direct one-step rename. Pulled from
82 /// the Tier-D agent-notes trail so a single `Renamed` entry
83 /// covers the full chain rather than emitting N intermediate
84 /// pairs.
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 rename_chain: Vec<EntityId>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 title: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 entity_type: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 content_before: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 content_after: Option<String>,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 ripple: Vec<IncomingRipple>,
97 },
98 /// Entity failed to parse on at least one side. The caller sees
99 /// the id (best-effort) and the parse-error message; the
100 /// surviving side's content can still ride along when available.
101 InvalidEntity {
102 id: EntityId,
103 /// `"ref_a"` or `"ref_b"` — whichever side tripped the
104 /// parser. `"both"` when both sides fail.
105 side: String,
106 /// Human-readable parse error message. Stable enough for
107 /// consumers to grep / regex against — variant-specific
108 /// payloads stay on the structured channel.
109 error: String,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 content_before: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 content_after: Option<String>,
114 },
115}
116
117/// One entry in an entity's incoming-wikilink ripple list. The
118/// referrer entity is on either `ref_a` or `ref_b` (`side` discriminates);
119/// consumers building a "what would break" preview consult both sides.
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121pub struct IncomingRipple {
122 /// The entity that holds the inbound wiki-link.
123 pub from_id: EntityId,
124 /// Which side of the diff this referrer lives on: `"ref_a"` or
125 /// `"ref_b"`. Pre- and post-state referrers can both appear in
126 /// the same list — consumers branching on `side` know which one
127 /// would still hold the link after a hypothetical merge.
128 pub side: String,
129 /// Section key where the inbound wiki-link surfaces. `None` when
130 /// the relation was derived from `## Relationships` rather than
131 /// a body link.
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub section: Option<String>,
134}
135
136/// Caller-supplied diff configuration. Defaults yield a useful diff
137/// without requiring callers to opt in to every feature.
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
139pub struct DiffConfig {
140 /// Rename-similarity threshold in `[0.1, 1.0]` (floor
141 /// `RENAME_SIMILARITY_MIN`). Lower values match more aggressively.
142 /// Out-of-range values refuse with `INVALID_INPUT` (mirrors
143 /// `memstead_changes_since`).
144 pub rename_similarity: f32,
145 /// When `true` (default), each entry carries the entity's full
146 /// markdown body on both sides. When `false`, only the metadata
147 /// (id, title, type, status) survives — smaller payload, useful
148 /// for audit counts.
149 pub include_content: bool,
150 /// When `true` (default), each entry carries the set of inbound
151 /// wiki-links — what would break if a downstream consumer
152 /// applied or skipped this change. When `false`, the ripple
153 /// field stays empty.
154 pub include_ripple: bool,
155}
156
157impl Default for DiffConfig {
158 fn default() -> Self {
159 Self {
160 rename_similarity: crate::ops::RENAME_SIMILARITY_DEFAULT,
161 include_content: true,
162 include_ripple: true,
163 }
164 }
165}
166
167/// Top-level diff response. Echoes the two refs the caller passed in
168/// (verbatim), reports the SHAs they resolved to, surfaces the
169/// configuration the operation used, and lists every per-entity
170/// entry.
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
172pub struct Diff {
173 /// The first ref the caller passed in, verbatim.
174 pub ref_a: String,
175 /// The second ref the caller passed in, verbatim.
176 pub ref_b: String,
177 /// SHA that `ref_a` resolved to. Stable cursor for follow-up
178 /// calls — consumers re-issue the diff with these SHAs to get
179 /// identical output regardless of branch tip movement.
180 pub resolved_a_sha: String,
181 /// SHA that `ref_b` resolved to.
182 pub resolved_b_sha: String,
183 /// Configuration in effect for this diff.
184 pub config: DiffConfig,
185 /// Per-entity diff entries. Ordering is implementation-defined
186 /// (today: stable by primary entity id) — consumers that need a
187 /// specific order sort client-side.
188 pub entries: Vec<EntityDiff>,
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn diff_config_default_matches_rename_similarity_default() {
197 let cfg = DiffConfig::default();
198 assert_eq!(cfg.rename_similarity, crate::ops::RENAME_SIMILARITY_DEFAULT);
199 assert!(cfg.include_content);
200 assert!(cfg.include_ripple);
201 }
202
203 #[test]
204 fn entity_diff_added_serialises_with_status_tag() {
205 let entry = EntityDiff::Added {
206 id: EntityId::new("specs", "alpha"),
207 title: Some("Alpha".to_string()),
208 entity_type: Some("spec".to_string()),
209 content_after: Some("# Alpha\n".to_string()),
210 ripple: Vec::new(),
211 };
212 let json = serde_json::to_value(&entry).unwrap();
213 assert_eq!(json["status"], "added");
214 assert_eq!(json["id"], "specs--alpha");
215 assert_eq!(json["title"], "Alpha");
216 assert_eq!(json["content_after"], "# Alpha\n");
217 // Empty ripple list is omitted via skip_serializing_if so the
218 // wire shape stays compact for the common case.
219 assert!(json.get("ripple").is_none());
220 }
221
222 #[test]
223 fn entity_diff_renamed_carries_optional_rename_chain() {
224 let entry = EntityDiff::Renamed {
225 from_id: EntityId::new("specs", "old"),
226 to_id: EntityId::new("specs", "new"),
227 rename_chain: vec![EntityId::new("specs", "interim")],
228 title: None,
229 entity_type: None,
230 content_before: None,
231 content_after: None,
232 ripple: Vec::new(),
233 };
234 let json = serde_json::to_value(&entry).unwrap();
235 assert_eq!(json["status"], "renamed");
236 assert_eq!(json["from_id"], "specs--old");
237 assert_eq!(json["to_id"], "specs--new");
238 assert_eq!(json["rename_chain"], serde_json::json!(["specs--interim"]));
239 }
240
241 #[test]
242 fn entity_diff_invalid_entity_carries_side_and_error() {
243 let entry = EntityDiff::InvalidEntity {
244 id: EntityId::new("specs", "broken"),
245 side: "ref_a".to_string(),
246 error: "missing frontmatter".to_string(),
247 content_before: Some("not yaml".to_string()),
248 content_after: None,
249 };
250 let json = serde_json::to_value(&entry).unwrap();
251 assert_eq!(json["status"], "invalid_entity");
252 assert_eq!(json["side"], "ref_a");
253 assert_eq!(json["error"], "missing frontmatter");
254 }
255
256 #[test]
257 fn diff_top_level_round_trips_through_serde() {
258 let diff = Diff {
259 ref_a: "main".to_string(),
260 ref_b: "feature".to_string(),
261 resolved_a_sha: "a".repeat(40),
262 resolved_b_sha: "b".repeat(40),
263 config: DiffConfig::default(),
264 entries: vec![EntityDiff::Added {
265 id: EntityId::new("v", "x"),
266 title: None,
267 entity_type: None,
268 content_after: None,
269 ripple: Vec::new(),
270 }],
271 };
272 let json = serde_json::to_string(&diff).unwrap();
273 let parsed: Diff = serde_json::from_str(&json).unwrap();
274 assert_eq!(parsed, diff);
275 }
276}