1use 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#[derive(Clone, Debug, Serialize, Deserialize)]
13#[serde(tag = "op", rename_all = "snake_case")]
14pub enum Op {
15 Create(CreateOp),
16 Link(LinkOp),
17 Update(UpdateOp),
18 Delete(DeleteOp),
19 Merge(MergeOp),
20}
21
22#[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#[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct EntityCreateFields {
45 pub entity_kind: EntityKind,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub entity_type: Option<String>,
48 pub name: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub description: Option<String>,
51 #[serde(default)]
52 pub properties: BTreeMap<String, PropertyValue>,
53 #[serde(default)]
54 pub tags: Vec<String>,
55}
56
57#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct NoteCreateFields {
60 pub note_kind: String,
62 pub content: String,
63 #[serde(default)]
64 pub properties: BTreeMap<String, PropertyValue>,
65 #[serde(default)]
66 pub tags: Vec<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub salience: Option<f64>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub decay_factor: Option<f64>,
71}
72
73#[derive(Clone, Debug, PartialEq, Serialize)]
79#[serde(into = "LinkOpRaw")]
80pub struct LinkOp {
81 pub id: Id128,
82 pub namespace: Namespace,
83 pub source: Id128,
84 pub target: Id128,
85 pub relation: EdgeRelation,
86 pub weight: f64,
87 pub properties: BTreeMap<String, PropertyValue>,
88}
89
90#[derive(Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92struct LinkOpRaw {
93 id: Id128,
94 namespace: Namespace,
95 source: Id128,
96 target: Id128,
97 relation: EdgeRelation,
98 weight: f64,
99 #[serde(default)]
100 properties: BTreeMap<String, PropertyValue>,
101}
102
103impl From<LinkOp> for LinkOpRaw {
104 fn from(l: LinkOp) -> Self {
105 Self {
106 id: l.id,
107 namespace: l.namespace,
108 source: l.source,
109 target: l.target,
110 relation: l.relation,
111 weight: l.weight,
112 properties: l.properties,
113 }
114 }
115}
116
117impl TryFrom<LinkOpRaw> for LinkOp {
118 type Error = String;
119
120 fn try_from(raw: LinkOpRaw) -> Result<Self, Self::Error> {
121 if !raw.weight.is_finite() {
122 return Err(format!("LinkOp weight must be finite, got {}", raw.weight));
123 }
124 if !(0.0..=1.0).contains(&raw.weight) {
125 return Err(format!(
126 "LinkOp weight must be in [0.0, 1.0], got {}",
127 raw.weight
128 ));
129 }
130 Ok(LinkOp {
131 id: raw.id,
132 namespace: raw.namespace,
133 source: raw.source,
134 target: raw.target,
135 relation: raw.relation,
136 weight: raw.weight,
137 properties: raw.properties,
138 })
139 }
140}
141
142impl<'de> Deserialize<'de> for LinkOp {
143 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
144 where
145 D: serde::Deserializer<'de>,
146 {
147 let raw = LinkOpRaw::deserialize(deserializer)?;
148 LinkOp::try_from(raw).map_err(serde::de::Error::custom)
149 }
150}
151
152#[derive(Clone, Debug, PartialEq, Serialize)]
165pub struct UpdateOp {
166 target_id: Id128,
167 patch: UpdatePatch,
168 preimage: UpdatePreimage,
169}
170
171impl UpdateOp {
172 pub fn new(
178 target_id: Id128,
179 patch: UpdatePatch,
180 preimage: UpdatePreimage,
181 ) -> Result<Self, String> {
182 validate_update_congruence(&patch, &preimage)?;
183 Ok(UpdateOp {
184 target_id,
185 patch,
186 preimage,
187 })
188 }
189
190 pub fn target_id(&self) -> Id128 {
192 self.target_id
193 }
194
195 pub fn patch(&self) -> &UpdatePatch {
197 &self.patch
198 }
199
200 pub fn preimage(&self) -> &UpdatePreimage {
202 &self.preimage
203 }
204}
205
206#[derive(Deserialize)]
207#[serde(deny_unknown_fields)]
208struct UpdateOpRaw {
209 target_id: Id128,
210 patch: UpdatePatch,
211 preimage: UpdatePreimage,
212}
213
214impl<'de> Deserialize<'de> for UpdateOp {
215 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
216 where
217 D: Deserializer<'de>,
218 {
219 let raw = UpdateOpRaw::deserialize(deserializer)?;
220 UpdateOp::new(raw.target_id, raw.patch, raw.preimage).map_err(serde::de::Error::custom)
221 }
222}
223
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#[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}
246
247#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct NotePatch {
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub content: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
253 pub salience: Option<Option<f64>>,
254 #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
255 pub decay_factor: Option<Option<f64>>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub properties: Option<BTreeMap<String, PropertyValue>>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub tags: Option<Vec<String>>,
260}
261
262#[derive(Clone, Debug, PartialEq, Serialize)]
266#[serde(into = "EdgePatchRaw")]
267pub struct EdgePatch {
268 pub relation: Option<EdgeRelation>,
269 pub weight: Option<f64>,
270}
271
272#[derive(Serialize, Deserialize)]
273#[serde(deny_unknown_fields)]
274struct EdgePatchRaw {
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 relation: Option<EdgeRelation>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 weight: Option<f64>,
279}
280
281impl From<EdgePatch> for EdgePatchRaw {
282 fn from(p: EdgePatch) -> Self {
283 Self {
284 relation: p.relation,
285 weight: p.weight,
286 }
287 }
288}
289
290impl TryFrom<EdgePatchRaw> for EdgePatch {
291 type Error = String;
292
293 fn try_from(raw: EdgePatchRaw) -> Result<Self, Self::Error> {
294 if let Some(w) = raw.weight {
295 if !w.is_finite() {
296 return Err(format!("EdgePatch weight must be finite, got {w}"));
297 }
298 if !(0.0..=1.0).contains(&w) {
299 return Err(format!("EdgePatch weight must be in [0.0, 1.0], got {w}"));
300 }
301 }
302 Ok(EdgePatch {
303 relation: raw.relation,
304 weight: raw.weight,
305 })
306 }
307}
308
309impl<'de> Deserialize<'de> for EdgePatch {
310 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
311 where
312 D: Deserializer<'de>,
313 {
314 let raw = EdgePatchRaw::deserialize(deserializer)?;
315 EdgePatch::try_from(raw).map_err(serde::de::Error::custom)
316 }
317}
318
319mod 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
355#[serde(deny_unknown_fields)]
356pub struct EntityPreimage {
357 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub name: Option<String>,
359 #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
360 pub description: Option<Option<String>>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub properties: Option<BTreeMap<String, PropertyValue>>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub tags: Option<Vec<String>>,
365}
366
367#[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
394#[serde(deny_unknown_fields)]
395pub struct EdgePreimage {
396 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub relation: Option<EdgeRelation>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub weight: Option<f64>,
400}
401
402#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
406#[serde(tag = "target", rename_all = "snake_case")]
407pub enum UpdatePreimage {
408 Entity(EntityPreimage),
409 Note(NotePreimage),
410 Edge(EdgePreimage),
411}
412
413fn validate_update_congruence(
422 patch: &UpdatePatch,
423 preimage: &UpdatePreimage,
424) -> Result<(), String> {
425 match (patch, preimage) {
426 (UpdatePatch::Entity(p), UpdatePreimage::Entity(pre)) => {
427 check_touched("name", p.name.is_some(), pre.name.is_some())?;
428 check_touched(
429 "description",
430 p.description.is_some(),
431 pre.description.is_some(),
432 )?;
433 check_touched(
434 "properties",
435 p.properties.is_some(),
436 pre.properties.is_some(),
437 )?;
438 check_touched("tags", p.tags.is_some(), pre.tags.is_some())?;
439 Ok(())
440 }
441 (UpdatePatch::Note(p), UpdatePreimage::Note(pre)) => {
442 check_touched("content", p.content.is_some(), pre.content.is_some())?;
443 check_touched("salience", p.salience.is_some(), pre.salience.is_some())?;
444 check_touched(
445 "decay_factor",
446 p.decay_factor.is_some(),
447 pre.decay_factor.is_some(),
448 )?;
449 check_touched(
450 "properties",
451 p.properties.is_some(),
452 pre.properties.is_some(),
453 )?;
454 check_touched("tags", p.tags.is_some(), pre.tags.is_some())?;
455 if let Some(Some(s)) = pre.salience {
456 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
457 return Err(format!(
458 "UpdateOp note preimage salience must be finite and in [0.0, 1.0], got {s}"
459 ));
460 }
461 }
462 if let Some(Some(d)) = pre.decay_factor {
463 if !d.is_finite() || d < 0.0 {
464 return Err(format!(
465 "UpdateOp note preimage decay_factor must be finite and non-negative, got {d}"
466 ));
467 }
468 }
469 Ok(())
470 }
471 (UpdatePatch::Edge(p), UpdatePreimage::Edge(pre)) => {
472 check_touched("relation", p.relation.is_some(), pre.relation.is_some())?;
473 check_touched("weight", p.weight.is_some(), pre.weight.is_some())?;
474 if let Some(w) = pre.weight {
475 if !w.is_finite() || !(0.0..=1.0).contains(&w) {
476 return Err(format!(
477 "UpdateOp edge preimage weight must be finite and in [0.0, 1.0], got {w}"
478 ));
479 }
480 }
481 Ok(())
482 }
483 _ => Err(format!(
484 "UpdateOp patch target ({}) does not match preimage target ({})",
485 patch_target_name(patch),
486 preimage_target_name(preimage)
487 )),
488 }
489}
490
491fn check_touched(field: &str, patch_touched: bool, preimage_present: bool) -> Result<(), String> {
492 match (patch_touched, preimage_present) {
493 (true, false) => Err(format!(
494 "UpdateOp preimage is missing `{field}`, which the patch sets or clears"
495 )),
496 (false, true) => Err(format!(
497 "UpdateOp preimage carries `{field}`, which the patch leaves unchanged"
498 )),
499 _ => Ok(()),
500 }
501}
502
503fn patch_target_name(patch: &UpdatePatch) -> &'static str {
504 match patch {
505 UpdatePatch::Entity(_) => "entity",
506 UpdatePatch::Note(_) => "note",
507 UpdatePatch::Edge(_) => "edge",
508 }
509}
510
511fn preimage_target_name(preimage: &UpdatePreimage) -> &'static str {
512 match preimage {
513 UpdatePreimage::Entity(_) => "entity",
514 UpdatePreimage::Note(_) => "note",
515 UpdatePreimage::Edge(_) => "edge",
516 }
517}
518
519#[derive(Clone, Debug, Serialize)]
527pub struct DeleteOp {
528 pub target_id: Id128,
529 pub hard: bool,
530 pub preimage: DeletePreimage,
531}
532
533#[derive(Deserialize)]
534#[serde(deny_unknown_fields)]
535struct DeleteOpRaw {
536 target_id: Id128,
537 hard: bool,
538 preimage: DeletePreimage,
539}
540
541impl<'de> Deserialize<'de> for DeleteOp {
542 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
543 where
544 D: Deserializer<'de>,
545 {
546 let raw = DeleteOpRaw::deserialize(deserializer)?;
547 let preimage_id = raw.preimage.record_id();
548 if raw.target_id != preimage_id {
549 return Err(serde::de::Error::custom(format!(
550 "DeleteOp target_id {} does not match preimage record id {preimage_id}",
551 raw.target_id
552 )));
553 }
554 Ok(DeleteOp {
555 target_id: raw.target_id,
556 hard: raw.hard,
557 preimage: raw.preimage,
558 })
559 }
560}
561
562#[derive(Clone, Debug, Serialize)]
567#[serde(tag = "substrate", rename_all = "snake_case")]
568pub enum DeletePreimage {
569 Entity(Box<Entity>),
570 Note(Box<Note>),
571 Edge(Box<Link>),
572}
573
574impl DeletePreimage {
575 fn record_id(&self) -> Id128 {
577 match self {
578 DeletePreimage::Entity(e) => e.header.id,
579 DeletePreimage::Note(n) => n.header.id,
580 DeletePreimage::Edge(l) => l.id,
581 }
582 }
583}
584
585#[derive(Deserialize)]
589#[serde(tag = "substrate", rename_all = "snake_case")]
590enum DeletePreimageRaw {
591 Entity(StrictEntity),
592 Note(StrictNote),
593 Edge(StrictLink),
594}
595
596impl<'de> Deserialize<'de> for DeletePreimage {
597 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
598 where
599 D: Deserializer<'de>,
600 {
601 let raw = DeletePreimageRaw::deserialize(deserializer)?;
602 Ok(match raw {
603 DeletePreimageRaw::Entity(e) => DeletePreimage::Entity(Box::new(e.into())),
604 DeletePreimageRaw::Note(n) => {
605 DeletePreimage::Note(Box::new(n.try_into().map_err(serde::de::Error::custom)?))
606 }
607 DeletePreimageRaw::Edge(l) => {
608 DeletePreimage::Edge(Box::new(l.try_into().map_err(serde::de::Error::custom)?))
609 }
610 })
611 }
612}
613
614#[derive(Clone, Debug, Serialize)]
623pub struct MergeOp {
624 pub into_id: Id128,
625 pub from_id: Id128,
626 pub preimage: MergePreimage,
627}
628
629#[derive(Deserialize)]
630#[serde(deny_unknown_fields)]
631struct MergeOpRaw {
632 into_id: Id128,
633 from_id: Id128,
634 preimage: MergePreimage,
635}
636
637impl<'de> Deserialize<'de> for MergeOp {
638 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
639 where
640 D: Deserializer<'de>,
641 {
642 let raw = MergeOpRaw::deserialize(deserializer)?;
643 if raw.into_id != raw.preimage.into.header.id {
644 return Err(serde::de::Error::custom(format!(
645 "MergeOp into_id {} does not match preimage.into record id {}",
646 raw.into_id, raw.preimage.into.header.id
647 )));
648 }
649 if raw.from_id != raw.preimage.from.header.id {
650 return Err(serde::de::Error::custom(format!(
651 "MergeOp from_id {} does not match preimage.from record id {}",
652 raw.from_id, raw.preimage.from.header.id
653 )));
654 }
655 for edge in &raw.preimage.incident_edges {
656 let touches_into = edge.source == raw.into_id || edge.target == raw.into_id;
657 let touches_from = edge.source == raw.from_id || edge.target == raw.from_id;
658 if !(touches_into || touches_from) {
659 return Err(serde::de::Error::custom(format!(
660 "MergeOp incident edge {} references neither into_id {} nor from_id {}",
661 edge.id, raw.into_id, raw.from_id
662 )));
663 }
664 }
665 Ok(MergeOp {
666 into_id: raw.into_id,
667 from_id: raw.from_id,
668 preimage: raw.preimage,
669 })
670 }
671}
672
673#[derive(Clone, Debug, Serialize)]
674pub struct MergePreimage {
675 pub into: Box<Entity>,
676 pub from: Box<Entity>,
677 pub incident_edges: Vec<Link>,
678}
679
680#[derive(Deserialize)]
681#[serde(deny_unknown_fields)]
682struct MergePreimageRaw {
683 into: StrictEntity,
684 from: StrictEntity,
685 incident_edges: Vec<StrictLink>,
686}
687
688impl<'de> Deserialize<'de> for MergePreimage {
689 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
690 where
691 D: Deserializer<'de>,
692 {
693 let raw = MergePreimageRaw::deserialize(deserializer)?;
694 let incident_edges = raw
695 .incident_edges
696 .into_iter()
697 .map(|l| l.try_into().map_err(serde::de::Error::custom))
698 .collect::<Result<Vec<Link>, D::Error>>()?;
699 Ok(MergePreimage {
700 into: Box::new(raw.into.into()),
701 from: Box::new(raw.from.into()),
702 incident_edges,
703 })
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 #[test]
712 fn link_op_rejects_out_of_range_weight() {
713 let json = serde_json::json!({
714 "id": "00000000-0000-0000-0000-000000000001",
715 "namespace": "local",
716 "source": "00000000-0000-0000-0000-000000000002",
717 "target": "00000000-0000-0000-0000-000000000003",
718 "relation": "extends",
719 "weight": 1.5,
720 "properties": {}
721 });
722 let result: Result<LinkOp, _> = serde_json::from_value(json);
723 assert!(result.is_err());
724 }
725
726 #[test]
727 fn create_op_rejects_unknown_field() {
728 let json = serde_json::json!({
729 "id": "00000000-0000-0000-0000-000000000001",
730 "namespace": "local",
731 "target": {
732 "kind": "entity",
733 "entity_kind": "concept",
734 "name": "X",
735 "surprise": true
736 }
737 });
738 let result: Result<CreateOp, _> = serde_json::from_value(json);
739 assert!(result.is_err());
740 }
741
742 #[test]
743 fn entity_patch_distinguishes_absent_and_null_description() {
744 let absent: EntityPatch = serde_json::from_value(serde_json::json!({})).unwrap();
745 assert_eq!(absent.description, None);
746
747 let cleared: EntityPatch =
748 serde_json::from_value(serde_json::json!({ "description": null })).unwrap();
749 assert_eq!(cleared.description, Some(None));
750
751 let set: EntityPatch =
752 serde_json::from_value(serde_json::json!({ "description": "new" })).unwrap();
753 assert_eq!(set.description, Some(Some("new".to_string())));
754 }
755
756 #[test]
757 fn delete_op_requires_preimage() {
758 let json = serde_json::json!({
759 "target_id": "00000000-0000-0000-0000-000000000001",
760 "hard": false
761 });
762 let result: Result<DeleteOp, _> = serde_json::from_value(json);
763 assert!(result.is_err());
764 }
765
766 #[test]
767 fn merge_op_requires_preimage() {
768 let json = serde_json::json!({
769 "into_id": "00000000-0000-0000-0000-000000000001",
770 "from_id": "00000000-0000-0000-0000-000000000002"
771 });
772 let result: Result<MergeOp, _> = serde_json::from_value(json);
773 assert!(result.is_err());
774 }
775
776 #[test]
777 fn update_op_rejects_preimage_missing_touched_field() {
778 let json = serde_json::json!({
780 "target_id": "00000000-0000-0000-0000-000000000001",
781 "patch": { "target": "entity", "name": "new-name" },
782 "preimage": { "target": "entity" }
783 });
784 let result: Result<UpdateOp, _> = serde_json::from_value(json);
785 assert!(result.is_err());
786 }
787
788 #[test]
789 fn update_op_rejects_preimage_with_extra_untouched_field() {
790 let json = serde_json::json!({
792 "target_id": "00000000-0000-0000-0000-000000000001",
793 "patch": { "target": "entity", "tags": ["a"] },
794 "preimage": {
795 "target": "entity",
796 "name": "stale-prior-name",
797 "tags": ["prior-a"]
798 }
799 });
800 let result: Result<UpdateOp, _> = serde_json::from_value(json);
801 assert!(result.is_err());
802 }
803
804 #[test]
805 fn update_op_rejects_mismatched_target_variant() {
806 let json = serde_json::json!({
807 "target_id": "00000000-0000-0000-0000-000000000001",
808 "patch": { "target": "entity", "name": "n" },
809 "preimage": { "target": "note", "content": "prior" }
810 });
811 let result: Result<UpdateOp, _> = serde_json::from_value(json);
812 assert!(result.is_err());
813 }
814
815 #[test]
816 fn update_op_explicit_null_clear_captures_prior_value() {
817 let json = serde_json::json!({
818 "target_id": "00000000-0000-0000-0000-000000000001",
819 "patch": { "target": "entity", "description": null },
820 "preimage": { "target": "entity", "description": "was-set" }
821 });
822 let op: UpdateOp = serde_json::from_value(json).unwrap();
823 match op.patch {
824 UpdatePatch::Entity(p) => assert_eq!(p.description, Some(None)),
825 other => panic!("expected entity patch, got {other:?}"),
826 }
827 match op.preimage {
828 UpdatePreimage::Entity(pre) => {
829 assert_eq!(pre.description, Some(Some("was-set".to_string())))
830 }
831 other => panic!("expected entity preimage, got {other:?}"),
832 }
833 }
834
835 #[test]
836 fn update_op_accepts_congruent_preimage() {
837 let json = serde_json::json!({
838 "target_id": "00000000-0000-0000-0000-000000000001",
839 "patch": { "target": "edge", "weight": 0.5 },
840 "preimage": { "target": "edge", "weight": 0.9 }
841 });
842 let result: Result<UpdateOp, _> = serde_json::from_value(json);
843 assert!(result.is_ok());
844 }
845
846 #[test]
847 fn update_op_rejects_out_of_range_preimage_weight() {
848 let json = serde_json::json!({
849 "target_id": "00000000-0000-0000-0000-000000000001",
850 "patch": { "target": "edge", "weight": 0.5 },
851 "preimage": { "target": "edge", "weight": 1.5 }
852 });
853 let result: Result<UpdateOp, _> = serde_json::from_value(json);
854 assert!(result.is_err());
855 }
856
857 #[test]
858 fn update_op_rejects_out_of_range_preimage_salience() {
859 let json = serde_json::json!({
860 "target_id": "00000000-0000-0000-0000-000000000001",
861 "patch": { "target": "note", "salience": 0.2 },
862 "preimage": { "target": "note", "salience": 1.9 }
863 });
864 let result: Result<UpdateOp, _> = serde_json::from_value(json);
865 assert!(result.is_err());
866 }
867
868 #[test]
869 fn update_op_new_rejects_incongruent_construction() {
870 let result = UpdateOp::new(
875 Id128::from_u128(1),
876 UpdatePatch::Entity(EntityPatch {
877 name: Some("new-name".to_string()),
878 description: None,
879 properties: None,
880 tags: None,
881 }),
882 UpdatePreimage::Entity(EntityPreimage {
883 name: None,
884 description: None,
885 properties: None,
886 tags: None,
887 }),
888 );
889 assert!(result.is_err());
890 }
891}