Skip to main content

khive_changeset/
op.rs

1//! The five typed change-set operations and their stage-time payloads.
2
3use std::collections::BTreeMap;
4
5use khive_types::{EdgeRelation, Entity, EntityKind, Id128, Link, Namespace, Note, PropertyValue};
6use serde::{Deserialize, Deserializer, Serialize};
7
8use crate::strict::{StrictEntity, StrictLink, StrictNote};
9
10/// One staged mutation, internally tagged by the snake-case `op` field.
11///
12/// See `crates/khive-changeset/docs/api/create-and-link.md` for wire examples.
13#[derive(Clone, Debug, Serialize, Deserialize)]
14#[serde(tag = "op", rename_all = "snake_case")]
15pub enum Op {
16    Create(CreateOp),
17    Link(LinkOp),
18    Update(UpdateOp),
19    Delete(DeleteOp),
20    Merge(MergeOp),
21}
22
23/// Creates an entity or note with an ID minted and stabilized at stage time.
24///
25/// See `crates/khive-changeset/docs/api/create-and-link.md` for field semantics.
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct CreateOp {
29    pub id: Id128,
30    pub namespace: Namespace,
31    pub target: CreateTarget,
32}
33
34/// Substrate-specific create fields, tagged by `kind`.
35#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
36#[serde(tag = "kind", rename_all = "snake_case")]
37pub enum CreateTarget {
38    Entity(EntityCreateFields),
39    Note(NoteCreateFields),
40}
41
42/// Fields staged for a new entity.
43#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct EntityCreateFields {
46    pub entity_kind: EntityKind,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub entity_type: Option<String>,
49    pub name: String,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub description: Option<String>,
52    #[serde(default)]
53    pub properties: BTreeMap<String, PropertyValue>,
54    #[serde(default)]
55    pub tags: Vec<String>,
56}
57
58/// Fields staged for a new note.
59#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct NoteCreateFields {
62    /// Pack-declared note kind string (e.g. `"observation"`); not a closed enum.
63    pub note_kind: String,
64    pub content: String,
65    #[serde(default)]
66    pub properties: BTreeMap<String, PropertyValue>,
67    #[serde(default)]
68    pub tags: Vec<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub salience: Option<f64>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub decay_factor: Option<f64>,
73}
74
75/// Creates a directed edge whose ID is minted at stage time.
76///
77/// Endpoints may reference stage-time IDs from this or another change-set.
78/// `weight` must be finite and in `[0.0, 1.0]` when deserialized.
79/// See `crates/khive-changeset/docs/api/create-and-link.md` for field semantics.
80#[derive(Clone, Debug, PartialEq, Serialize)]
81#[serde(into = "LinkOpRaw")]
82pub struct LinkOp {
83    pub id: Id128,
84    pub namespace: Namespace,
85    pub source: Id128,
86    pub target: Id128,
87    pub relation: EdgeRelation,
88    pub weight: f64,
89    pub properties: BTreeMap<String, PropertyValue>,
90}
91
92#[derive(Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94struct LinkOpRaw {
95    id: Id128,
96    namespace: Namespace,
97    source: Id128,
98    target: Id128,
99    relation: EdgeRelation,
100    weight: f64,
101    #[serde(default)]
102    properties: BTreeMap<String, PropertyValue>,
103}
104
105impl From<LinkOp> for LinkOpRaw {
106    fn from(l: LinkOp) -> Self {
107        Self {
108            id: l.id,
109            namespace: l.namespace,
110            source: l.source,
111            target: l.target,
112            relation: l.relation,
113            weight: l.weight,
114            properties: l.properties,
115        }
116    }
117}
118
119impl TryFrom<LinkOpRaw> for LinkOp {
120    type Error = String;
121
122    fn try_from(raw: LinkOpRaw) -> Result<Self, Self::Error> {
123        if !raw.weight.is_finite() {
124            return Err(format!("LinkOp weight must be finite, got {}", raw.weight));
125        }
126        if !(0.0..=1.0).contains(&raw.weight) {
127            return Err(format!(
128                "LinkOp weight must be in [0.0, 1.0], got {}",
129                raw.weight
130            ));
131        }
132        Ok(LinkOp {
133            id: raw.id,
134            namespace: raw.namespace,
135            source: raw.source,
136            target: raw.target,
137            relation: raw.relation,
138            weight: raw.weight,
139            properties: raw.properties,
140        })
141    }
142}
143
144impl<'de> Deserialize<'de> for LinkOp {
145    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
146    where
147        D: serde::Deserializer<'de>,
148    {
149        let raw = LinkOpRaw::deserialize(deserializer)?;
150        LinkOp::try_from(raw).map_err(serde::de::Error::custom)
151    }
152}
153
154/// Patches one record with a stage-time preimage of exactly the touched fields.
155///
156/// Patch and preimage must target the same substrate and have identical field
157/// presence; both construction and deserialization enforce this invariant.
158/// See `crates/khive-changeset/docs/api/update.md` for nullable-field semantics.
159#[derive(Clone, Debug, PartialEq, Serialize)]
160pub struct UpdateOp {
161    target_id: Id128,
162    patch: UpdatePatch,
163    preimage: UpdatePreimage,
164}
165
166impl UpdateOp {
167    /// Constructs an update after validating patch/preimage congruence.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error for differing substrates or field sets, or for an
172    /// invalid captured salience, decay factor, or edge weight, or an invalid
173    /// new edge weight.
174    pub fn new(
175        target_id: Id128,
176        patch: UpdatePatch,
177        preimage: UpdatePreimage,
178    ) -> Result<Self, String> {
179        validate_update_congruence(&patch, &preimage)?;
180        Ok(UpdateOp {
181            target_id,
182            patch,
183            preimage,
184        })
185    }
186
187    /// The identifier of the record this update targets.
188    pub fn target_id(&self) -> Id128 {
189        self.target_id
190    }
191
192    /// The staged patch.
193    pub fn patch(&self) -> &UpdatePatch {
194        &self.patch
195    }
196
197    /// The field-scoped prior value the patch touches.
198    pub fn preimage(&self) -> &UpdatePreimage {
199        &self.preimage
200    }
201}
202
203#[derive(Deserialize)]
204#[serde(deny_unknown_fields)]
205struct UpdateOpRaw {
206    target_id: Id128,
207    patch: UpdatePatch,
208    preimage: UpdatePreimage,
209}
210
211impl<'de> Deserialize<'de> for UpdateOp {
212    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
213    where
214        D: Deserializer<'de>,
215    {
216        let raw = UpdateOpRaw::deserialize(deserializer)?;
217        UpdateOp::new(raw.target_id, raw.patch, raw.preimage).map_err(serde::de::Error::custom)
218    }
219}
220
221/// Substrate-specific patch tagged by `target`.
222///
223/// See `crates/khive-changeset/docs/api/update.md` for field-presence semantics.
224#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
225#[serde(tag = "target", rename_all = "snake_case")]
226pub enum UpdatePatch {
227    Entity(EntityPatch),
228    Note(NotePatch),
229    Edge(EdgePatch),
230}
231
232/// Entity fields to mutate; absent means unchanged and `Some(None)` clears
233/// a nullable field (ADR-014 tri-state, applied to `description` and
234/// `entity_type`).
235#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct EntityPatch {
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub name: Option<String>,
240    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
241    pub description: Option<Option<String>>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub properties: Option<BTreeMap<String, PropertyValue>>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub tags: Option<Vec<String>>,
246    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
247    pub entity_type: Option<Option<String>>,
248}
249
250/// Note fields to mutate; nested options distinguish unchanged, clear, and set.
251#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct NotePatch {
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub content: Option<String>,
256    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
257    pub salience: Option<Option<f64>>,
258    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
259    pub decay_factor: Option<Option<f64>>,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub properties: Option<BTreeMap<String, PropertyValue>>,
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub tags: Option<Vec<String>>,
264}
265
266/// Edge fields to mutate; weight must be finite and in `[0.0, 1.0]`.
267#[derive(Clone, Debug, PartialEq, Serialize)]
268#[serde(into = "EdgePatchRaw")]
269pub struct EdgePatch {
270    pub relation: Option<EdgeRelation>,
271    pub weight: Option<f64>,
272}
273
274#[derive(Serialize, Deserialize)]
275#[serde(deny_unknown_fields)]
276struct EdgePatchRaw {
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    relation: Option<EdgeRelation>,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    weight: Option<f64>,
281}
282
283impl From<EdgePatch> for EdgePatchRaw {
284    fn from(p: EdgePatch) -> Self {
285        Self {
286            relation: p.relation,
287            weight: p.weight,
288        }
289    }
290}
291
292impl TryFrom<EdgePatchRaw> for EdgePatch {
293    type Error = String;
294
295    fn try_from(raw: EdgePatchRaw) -> Result<Self, Self::Error> {
296        let patch = EdgePatch {
297            relation: raw.relation,
298            weight: raw.weight,
299        };
300        patch.validate_weight()?;
301        Ok(patch)
302    }
303}
304
305impl EdgePatch {
306    fn validate_weight(&self) -> Result<(), String> {
307        if let Some(w) = self.weight {
308            if !w.is_finite() {
309                return Err(format!("EdgePatch weight must be finite, got {w}"));
310            }
311            if !(0.0..=1.0).contains(&w) {
312                return Err(format!("EdgePatch weight must be in [0.0, 1.0], got {w}"));
313            }
314        }
315        Ok(())
316    }
317}
318
319impl<'de> Deserialize<'de> for EdgePatch {
320    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
321    where
322        D: Deserializer<'de>,
323    {
324        let raw = EdgePatchRaw::deserialize(deserializer)?;
325        EdgePatch::try_from(raw).map_err(serde::de::Error::custom)
326    }
327}
328
329/// Distinguishes an absent patch field from an explicit `null` clear.
330mod opt_opt {
331    use serde::{Deserialize, Deserializer, Serialize, Serializer};
332
333    pub fn serialize<T, S>(val: &Option<Option<T>>, s: S) -> Result<S::Ok, S::Error>
334    where
335        T: Serialize,
336        S: Serializer,
337    {
338        match val {
339            None => unreachable!("skip_serializing_if guards the None case"),
340            Some(inner) => inner.serialize(s),
341        }
342    }
343
344    pub fn deserialize<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error>
345    where
346        T: Deserialize<'de>,
347        D: Deserializer<'de>,
348    {
349        let opt: Option<T> = Option::deserialize(d)?;
350        Ok(Some(opt))
351    }
352}
353
354/// Prior entity values for exactly the fields touched by [`EntityPatch`].
355#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
356#[serde(deny_unknown_fields)]
357pub struct EntityPreimage {
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub name: Option<String>,
360    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
361    pub description: Option<Option<String>>,
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub properties: Option<BTreeMap<String, PropertyValue>>,
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub tags: Option<Vec<String>>,
366    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
367    pub entity_type: Option<Option<String>>,
368}
369
370/// Prior note values for exactly the fields touched by [`NotePatch`].
371///
372/// Captured salience is finite in `[0.0, 1.0]`; decay factor is finite and non-negative.
373#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
374#[serde(deny_unknown_fields)]
375pub struct NotePreimage {
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub content: Option<String>,
378    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
379    pub salience: Option<Option<f64>>,
380    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
381    pub decay_factor: Option<Option<f64>>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub properties: Option<BTreeMap<String, PropertyValue>>,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub tags: Option<Vec<String>>,
386}
387
388/// Prior edge values for exactly the fields touched by [`EdgePatch`].
389///
390/// Captured weight must be finite and in `[0.0, 1.0]`.
391#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
392#[serde(deny_unknown_fields)]
393pub struct EdgePreimage {
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub relation: Option<EdgeRelation>,
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub weight: Option<f64>,
398}
399
400/// Field-scoped preimage tagged with the same `target` as [`UpdatePatch`].
401#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
402#[serde(tag = "target", rename_all = "snake_case")]
403pub enum UpdatePreimage {
404    Entity(EntityPreimage),
405    Note(NotePreimage),
406    Edge(EdgePreimage),
407}
408
409/// Enforces substrate, exact field-set, and captured numeric-value invariants.
410fn validate_update_congruence(
411    patch: &UpdatePatch,
412    preimage: &UpdatePreimage,
413) -> Result<(), String> {
414    match (patch, preimage) {
415        (UpdatePatch::Entity(p), UpdatePreimage::Entity(pre)) => {
416            check_touched("name", p.name.is_some(), pre.name.is_some())?;
417            check_touched(
418                "description",
419                p.description.is_some(),
420                pre.description.is_some(),
421            )?;
422            check_touched(
423                "properties",
424                p.properties.is_some(),
425                pre.properties.is_some(),
426            )?;
427            check_touched("tags", p.tags.is_some(), pre.tags.is_some())?;
428            check_touched(
429                "entity_type",
430                p.entity_type.is_some(),
431                pre.entity_type.is_some(),
432            )?;
433            Ok(())
434        }
435        (UpdatePatch::Note(p), UpdatePreimage::Note(pre)) => {
436            check_touched("content", p.content.is_some(), pre.content.is_some())?;
437            check_touched("salience", p.salience.is_some(), pre.salience.is_some())?;
438            check_touched(
439                "decay_factor",
440                p.decay_factor.is_some(),
441                pre.decay_factor.is_some(),
442            )?;
443            check_touched(
444                "properties",
445                p.properties.is_some(),
446                pre.properties.is_some(),
447            )?;
448            check_touched("tags", p.tags.is_some(), pre.tags.is_some())?;
449            if let Some(Some(s)) = pre.salience {
450                if !s.is_finite() || !(0.0..=1.0).contains(&s) {
451                    return Err(format!(
452                        "UpdateOp note preimage salience must be finite and in [0.0, 1.0], got {s}"
453                    ));
454                }
455            }
456            if let Some(Some(d)) = pre.decay_factor {
457                if !d.is_finite() || d < 0.0 {
458                    return Err(format!(
459                        "UpdateOp note preimage decay_factor must be finite and non-negative, got {d}"
460                    ));
461                }
462            }
463            Ok(())
464        }
465        (UpdatePatch::Edge(p), UpdatePreimage::Edge(pre)) => {
466            check_touched("relation", p.relation.is_some(), pre.relation.is_some())?;
467            check_touched("weight", p.weight.is_some(), pre.weight.is_some())?;
468            if let Some(w) = pre.weight {
469                if !w.is_finite() || !(0.0..=1.0).contains(&w) {
470                    return Err(format!(
471                        "UpdateOp edge preimage weight must be finite and in [0.0, 1.0], got {w}"
472                    ));
473                }
474            }
475            p.validate_weight()?;
476            Ok(())
477        }
478        _ => Err(format!(
479            "UpdateOp patch target ({}) does not match preimage target ({})",
480            patch_target_name(patch),
481            preimage_target_name(preimage)
482        )),
483    }
484}
485
486fn check_touched(field: &str, patch_touched: bool, preimage_present: bool) -> Result<(), String> {
487    match (patch_touched, preimage_present) {
488        (true, false) => Err(format!(
489            "UpdateOp preimage is missing `{field}`, which the patch sets or clears"
490        )),
491        (false, true) => Err(format!(
492            "UpdateOp preimage carries `{field}`, which the patch leaves unchanged"
493        )),
494        _ => Ok(()),
495    }
496}
497
498fn patch_target_name(patch: &UpdatePatch) -> &'static str {
499    match patch {
500        UpdatePatch::Entity(_) => "entity",
501        UpdatePatch::Note(_) => "note",
502        UpdatePatch::Edge(_) => "edge",
503    }
504}
505
506fn preimage_target_name(preimage: &UpdatePreimage) -> &'static str {
507    match preimage {
508        UpdatePreimage::Entity(_) => "entity",
509        UpdatePreimage::Note(_) => "note",
510        UpdatePreimage::Edge(_) => "edge",
511    }
512}
513
514/// Removes one record with its required, full stage-time preimage.
515///
516/// Deserialization requires `target_id` to equal the embedded record ID.
517/// See `crates/khive-changeset/docs/api/delete-and-merge.md` for strictness rules.
518#[derive(Clone, Debug, Serialize)]
519pub struct DeleteOp {
520    pub target_id: Id128,
521    pub hard: bool,
522    pub preimage: DeletePreimage,
523}
524
525#[derive(Deserialize)]
526#[serde(deny_unknown_fields)]
527struct DeleteOpRaw {
528    target_id: Id128,
529    hard: bool,
530    preimage: DeletePreimage,
531}
532
533impl<'de> Deserialize<'de> for DeleteOp {
534    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
535    where
536        D: Deserializer<'de>,
537    {
538        let raw = DeleteOpRaw::deserialize(deserializer)?;
539        let preimage_id = raw.preimage.record_id();
540        if raw.target_id != preimage_id {
541            return Err(serde::de::Error::custom(format!(
542                "DeleteOp target_id {} does not match preimage record id {preimage_id}",
543                raw.target_id
544            )));
545        }
546        Ok(DeleteOp {
547            target_id: raw.target_id,
548            hard: raw.hard,
549            preimage: raw.preimage,
550        })
551    }
552}
553
554/// Full prior record state, tagged by `substrate` to avoid its embedded `kind` field.
555#[derive(Clone, Debug, Serialize)]
556#[serde(tag = "substrate", rename_all = "snake_case")]
557pub enum DeletePreimage {
558    Entity(Box<Entity>),
559    Note(Box<Note>),
560    Edge(Box<Link>),
561}
562
563impl DeletePreimage {
564    /// The identifier of the record this preimage was captured from.
565    fn record_id(&self) -> Id128 {
566        match self {
567            DeletePreimage::Entity(e) => e.header.id,
568            DeletePreimage::Note(n) => n.header.id,
569            DeletePreimage::Edge(l) => l.id,
570        }
571    }
572}
573
574/// Strict deserialize-only mirror of [`DeletePreimage`].
575#[derive(Deserialize)]
576#[serde(tag = "substrate", rename_all = "snake_case")]
577enum DeletePreimageRaw {
578    Entity(StrictEntity),
579    Note(StrictNote),
580    Edge(StrictLink),
581}
582
583impl<'de> Deserialize<'de> for DeletePreimage {
584    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
585    where
586        D: Deserializer<'de>,
587    {
588        let raw = DeletePreimageRaw::deserialize(deserializer)?;
589        Ok(match raw {
590            DeletePreimageRaw::Entity(e) => DeletePreimage::Entity(Box::new(e.into())),
591            DeletePreimageRaw::Note(n) => {
592                DeletePreimage::Note(Box::new(n.try_into().map_err(serde::de::Error::custom)?))
593            }
594            DeletePreimageRaw::Edge(l) => {
595                DeletePreimage::Edge(Box::new(l.try_into().map_err(serde::de::Error::custom)?))
596            }
597        })
598    }
599}
600
601/// Merges two entities with required participant and incident-edge preimages.
602///
603/// Deserialization matches both IDs and requires every incident edge to touch
604/// at least one participant.
605/// See `crates/khive-changeset/docs/api/delete-and-merge.md` for the full contract.
606#[derive(Clone, Debug, Serialize)]
607pub struct MergeOp {
608    pub into_id: Id128,
609    pub from_id: Id128,
610    pub preimage: MergePreimage,
611}
612
613#[derive(Deserialize)]
614#[serde(deny_unknown_fields)]
615struct MergeOpRaw {
616    into_id: Id128,
617    from_id: Id128,
618    preimage: MergePreimage,
619}
620
621impl<'de> Deserialize<'de> for MergeOp {
622    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
623    where
624        D: Deserializer<'de>,
625    {
626        let raw = MergeOpRaw::deserialize(deserializer)?;
627        if raw.into_id != raw.preimage.into.header.id {
628            return Err(serde::de::Error::custom(format!(
629                "MergeOp into_id {} does not match preimage.into record id {}",
630                raw.into_id, raw.preimage.into.header.id
631            )));
632        }
633        if raw.from_id != raw.preimage.from.header.id {
634            return Err(serde::de::Error::custom(format!(
635                "MergeOp from_id {} does not match preimage.from record id {}",
636                raw.from_id, raw.preimage.from.header.id
637            )));
638        }
639        for edge in &raw.preimage.incident_edges {
640            let touches_into = edge.source == raw.into_id || edge.target == raw.into_id;
641            let touches_from = edge.source == raw.from_id || edge.target == raw.from_id;
642            if !(touches_into || touches_from) {
643                return Err(serde::de::Error::custom(format!(
644                    "MergeOp incident edge {} references neither into_id {} nor from_id {}",
645                    edge.id, raw.into_id, raw.from_id
646                )));
647            }
648        }
649        Ok(MergeOp {
650            into_id: raw.into_id,
651            from_id: raw.from_id,
652            preimage: raw.preimage,
653        })
654    }
655}
656
657/// Full prior state required to validate and rewire an entity merge.
658///
659/// See `crates/khive-changeset/docs/api/delete-and-merge.md` for edge coverage rules.
660#[derive(Clone, Debug, Serialize)]
661pub struct MergePreimage {
662    pub into: Box<Entity>,
663    pub from: Box<Entity>,
664    pub incident_edges: Vec<Link>,
665}
666
667#[derive(Deserialize)]
668#[serde(deny_unknown_fields)]
669struct MergePreimageRaw {
670    into: StrictEntity,
671    from: StrictEntity,
672    incident_edges: Vec<StrictLink>,
673}
674
675impl<'de> Deserialize<'de> for MergePreimage {
676    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
677    where
678        D: Deserializer<'de>,
679    {
680        let raw = MergePreimageRaw::deserialize(deserializer)?;
681        let incident_edges = raw
682            .incident_edges
683            .into_iter()
684            .map(|l| l.try_into().map_err(serde::de::Error::custom))
685            .collect::<Result<Vec<Link>, D::Error>>()?;
686        Ok(MergePreimage {
687            into: Box::new(raw.into.into()),
688            from: Box::new(raw.from.into()),
689            incident_edges,
690        })
691    }
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697
698    #[test]
699    fn link_op_rejects_out_of_range_weight() {
700        let json = serde_json::json!({
701            "id": "00000000-0000-0000-0000-000000000001",
702            "namespace": "local",
703            "source": "00000000-0000-0000-0000-000000000002",
704            "target": "00000000-0000-0000-0000-000000000003",
705            "relation": "extends",
706            "weight": 1.5,
707            "properties": {}
708        });
709        let result: Result<LinkOp, _> = serde_json::from_value(json);
710        assert!(result.is_err());
711    }
712
713    #[test]
714    fn create_op_rejects_unknown_field() {
715        let json = serde_json::json!({
716            "id": "00000000-0000-0000-0000-000000000001",
717            "namespace": "local",
718            "target": {
719                "kind": "entity",
720                "entity_kind": "concept",
721                "name": "X",
722                "surprise": true
723            }
724        });
725        let result: Result<CreateOp, _> = serde_json::from_value(json);
726        assert!(result.is_err());
727    }
728
729    #[test]
730    fn entity_patch_distinguishes_absent_and_null_description() {
731        let absent: EntityPatch = serde_json::from_value(serde_json::json!({})).unwrap();
732        assert_eq!(absent.description, None);
733
734        let cleared: EntityPatch =
735            serde_json::from_value(serde_json::json!({ "description": null })).unwrap();
736        assert_eq!(cleared.description, Some(None));
737
738        let set: EntityPatch =
739            serde_json::from_value(serde_json::json!({ "description": "new" })).unwrap();
740        assert_eq!(set.description, Some(Some("new".to_string())));
741    }
742
743    #[test]
744    fn delete_op_requires_preimage() {
745        let json = serde_json::json!({
746            "target_id": "00000000-0000-0000-0000-000000000001",
747            "hard": false
748        });
749        let result: Result<DeleteOp, _> = serde_json::from_value(json);
750        assert!(result.is_err());
751    }
752
753    #[test]
754    fn merge_op_requires_preimage() {
755        let json = serde_json::json!({
756            "into_id": "00000000-0000-0000-0000-000000000001",
757            "from_id": "00000000-0000-0000-0000-000000000002"
758        });
759        let result: Result<MergeOp, _> = serde_json::from_value(json);
760        assert!(result.is_err());
761    }
762
763    #[test]
764    fn update_op_rejects_preimage_missing_touched_field() {
765        let json = serde_json::json!({
766            "target_id": "00000000-0000-0000-0000-000000000001",
767            "patch": { "target": "entity", "name": "new-name" },
768            "preimage": { "target": "entity" }
769        });
770        let result: Result<UpdateOp, _> = serde_json::from_value(json);
771        assert!(result.is_err());
772    }
773
774    #[test]
775    fn update_op_rejects_preimage_with_extra_untouched_field() {
776        let json = serde_json::json!({
777            "target_id": "00000000-0000-0000-0000-000000000001",
778            "patch": { "target": "entity", "tags": ["a"] },
779            "preimage": {
780                "target": "entity",
781                "name": "stale-prior-name",
782                "tags": ["prior-a"]
783            }
784        });
785        let result: Result<UpdateOp, _> = serde_json::from_value(json);
786        assert!(result.is_err());
787    }
788
789    #[test]
790    fn update_op_rejects_mismatched_target_variant() {
791        let json = serde_json::json!({
792            "target_id": "00000000-0000-0000-0000-000000000001",
793            "patch": { "target": "entity", "name": "n" },
794            "preimage": { "target": "note", "content": "prior" }
795        });
796        let result: Result<UpdateOp, _> = serde_json::from_value(json);
797        assert!(result.is_err());
798    }
799
800    #[test]
801    fn update_op_explicit_null_clear_captures_prior_value() {
802        let json = serde_json::json!({
803            "target_id": "00000000-0000-0000-0000-000000000001",
804            "patch": { "target": "entity", "description": null },
805            "preimage": { "target": "entity", "description": "was-set" }
806        });
807        let op: UpdateOp = serde_json::from_value(json).unwrap();
808        match op.patch {
809            UpdatePatch::Entity(p) => assert_eq!(p.description, Some(None)),
810            other => panic!("expected entity patch, got {other:?}"),
811        }
812        match op.preimage {
813            UpdatePreimage::Entity(pre) => {
814                assert_eq!(pre.description, Some(Some("was-set".to_string())))
815            }
816            other => panic!("expected entity preimage, got {other:?}"),
817        }
818    }
819
820    #[test]
821    fn update_op_accepts_congruent_preimage() {
822        let json = serde_json::json!({
823            "target_id": "00000000-0000-0000-0000-000000000001",
824            "patch": { "target": "edge", "weight": 0.5 },
825            "preimage": { "target": "edge", "weight": 0.9 }
826        });
827        let result: Result<UpdateOp, _> = serde_json::from_value(json);
828        assert!(result.is_ok());
829    }
830
831    #[test]
832    fn update_op_rejects_out_of_range_preimage_weight() {
833        let json = serde_json::json!({
834            "target_id": "00000000-0000-0000-0000-000000000001",
835            "patch": { "target": "edge", "weight": 0.5 },
836            "preimage": { "target": "edge", "weight": 1.5 }
837        });
838        let result: Result<UpdateOp, _> = serde_json::from_value(json);
839        assert!(result.is_err());
840    }
841
842    #[test]
843    fn update_op_rejects_out_of_range_preimage_salience() {
844        let json = serde_json::json!({
845            "target_id": "00000000-0000-0000-0000-000000000001",
846            "patch": { "target": "note", "salience": 0.2 },
847            "preimage": { "target": "note", "salience": 1.9 }
848        });
849        let result: Result<UpdateOp, _> = serde_json::from_value(json);
850        assert!(result.is_err());
851    }
852
853    #[test]
854    fn update_op_new_rejects_incongruent_construction() {
855        let result = UpdateOp::new(
856            Id128::from_u128(1),
857            UpdatePatch::Entity(EntityPatch {
858                name: Some("new-name".to_string()),
859                description: None,
860                properties: None,
861                tags: None,
862                entity_type: None,
863            }),
864            UpdatePreimage::Entity(EntityPreimage {
865                name: None,
866                description: None,
867                properties: None,
868                tags: None,
869                entity_type: None,
870            }),
871        );
872        assert!(result.is_err());
873    }
874}