Skip to main content

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}