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