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