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