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)]
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#[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)]
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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct NoteCreateFields {
62 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#[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#[derive(Clone, Debug, PartialEq, Serialize)]
160pub struct UpdateOp {
161 target_id: Id128,
162 patch: UpdatePatch,
163 preimage: UpdatePreimage,
164}
165
166impl UpdateOp {
167 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 pub fn target_id(&self) -> Id128 {
188 self.target_id
189 }
190
191 pub fn patch(&self) -> &UpdatePatch {
193 &self.patch
194 }
195
196 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#[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#[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#[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#[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
316mod 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#[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#[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#[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#[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
394fn 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#[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#[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 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#[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#[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#[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}