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