1use serde::{Deserialize, Serialize};
2use uptrakit_shared_types::access::Action;
3
4use crate::SurfaceId;
5
6#[non_exhaustive]
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum Targeting {
10 Universal,
11 Targeted,
12}
13
14#[non_exhaustive]
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Scope {
18 Global,
19 Tenant,
20}
21
22#[non_exhaustive]
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ProviderKind {
26 BuiltIn,
27 Plugin,
28 Service,
29}
30
31#[non_exhaustive]
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case", tag = "kind")]
34pub enum SurfaceNode {
35 Section {
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 title: Option<String>,
38 #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 header_action_ids: Vec<crate::InteractionId>,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 children: Vec<SurfaceNode>,
42 },
43 TextBlock {
44 text: String,
45 },
46 KeyValue {
47 data_source_id: crate::DataSourceId,
48 },
49 Table {
50 data_source_id: crate::DataSourceId,
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 columns: Vec<SurfaceTableColumn>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 row_actions: Vec<SurfaceTableRowAction>,
55 },
56 Form {
57 interaction_id: crate::InteractionId,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 http_method: Option<crate::InteractionHttpMethod>,
60 },
61 ActionBar {
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 action_ids: Vec<ActionRef>,
64 },
65 Tabs {
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 tabs: Vec<SurfaceTab>,
68 },
69 Callout {
70 level: CalloutLevel,
71 text: String,
72 },
73 EmptyState {
74 title: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 description: Option<String>,
77 },
78 ModalTrigger {
79 interaction_id: crate::InteractionId,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 http_method: Option<crate::InteractionHttpMethod>,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 modal_nodes: Vec<SurfaceNode>,
84 },
85 WorkflowTrigger {
86 interaction_id: crate::InteractionId,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 step_nodes: Vec<SurfaceNode>,
89 },
90}
91
92impl SurfaceNode {
93 #[must_use]
99 pub fn section(title: Option<impl Into<String>>, children: Vec<Self>) -> Self {
100 Self::Section {
101 title: title.map(Into::into),
102 header_action_ids: vec![],
103 children,
104 }
105 }
106
107 #[must_use]
116 pub fn section_with_header_actions(
117 title: Option<impl Into<String>>,
118 header_action_ids: Vec<crate::InteractionId>,
119 children: Vec<Self>,
120 ) -> Self {
121 Self::Section {
122 title: title.map(Into::into),
123 header_action_ids,
124 children,
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(untagged)]
136pub enum ActionRef {
137 Bare(crate::InteractionId),
138 WithMethod {
139 interaction_id: crate::InteractionId,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 http_method: Option<crate::InteractionHttpMethod>,
142 },
143}
144
145impl ActionRef {
146 #[must_use]
147 pub fn interaction_id(&self) -> &crate::InteractionId {
148 match self {
149 Self::Bare(id) => id,
150 Self::WithMethod { interaction_id, .. } => interaction_id,
151 }
152 }
153
154 #[must_use]
155 pub fn http_method(&self) -> Option<&crate::InteractionHttpMethod> {
156 match self {
157 Self::Bare(_) => None,
158 Self::WithMethod { http_method, .. } => http_method.as_ref(),
159 }
160 }
161}
162
163impl From<crate::InteractionId> for ActionRef {
164 fn from(id: crate::InteractionId) -> Self {
165 Self::Bare(id)
166 }
167}
168
169#[non_exhaustive]
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct SurfaceTableColumn {
172 pub key: String,
173 pub label: String,
174 #[serde(
175 default,
176 skip_serializing_if = "Option::is_none",
177 deserialize_with = "deserialize_optional_cell_type"
178 )]
179 pub cell_type: Option<SurfaceTableCellType>,
180}
181
182impl SurfaceTableColumn {
183 pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
185 Self {
186 key: key.into(),
187 label: label.into(),
188 cell_type: None,
189 }
190 }
191}
192
193fn deserialize_optional_cell_type<'de, D>(
194 deserializer: D,
195) -> Result<Option<SurfaceTableCellType>, D::Error>
196where
197 D: serde::Deserializer<'de>,
198{
199 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
200 Ok(value.and_then(|v| serde_json::from_value(v).ok()))
201}
202
203#[non_exhaustive]
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(tag = "kind", rename_all = "snake_case")]
212pub enum SurfaceTableCellType {
213 EntityLink { entity_type: SurfaceEntityType },
214}
215
216#[non_exhaustive]
223#[derive(Debug, Clone, PartialEq, Eq, Hash)]
224pub enum SurfaceEntityType {
225 Host,
226 Other(String),
227}
228
229impl SurfaceEntityType {
230 pub fn as_str(&self) -> &str {
232 match self {
233 Self::Host => "host",
234 Self::Other(s) => s.as_str(),
235 }
236 }
237}
238
239impl From<String> for SurfaceEntityType {
240 fn from(s: String) -> Self {
241 match s.as_str() {
242 "host" => Self::Host,
243 _ => Self::Other(s),
244 }
245 }
246}
247
248impl Serialize for SurfaceEntityType {
249 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
250 serializer.serialize_str(self.as_str())
251 }
252}
253
254impl<'de> Deserialize<'de> for SurfaceEntityType {
255 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
256 String::deserialize(deserializer).map(SurfaceEntityType::from)
257 }
258}
259
260#[non_exhaustive]
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct SurfaceEntityRef {
269 pub entity_id: uuid::Uuid,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub label: Option<String>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub found: Option<bool>,
274}
275
276impl SurfaceEntityRef {
277 pub fn unresolved(entity_id: uuid::Uuid) -> Self {
280 Self {
281 entity_id,
282 label: None,
283 found: None,
284 }
285 }
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub struct SurfaceTableRowAction {
290 pub interaction_id: crate::InteractionId,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub http_method: Option<crate::InteractionHttpMethod>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub visible_when: Option<SurfaceRowVisibleWhen>,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct SurfaceRowVisibleWhen {
299 pub field: String,
300 pub condition: SurfaceRowCondition,
301}
302
303#[non_exhaustive]
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "snake_case")]
306pub enum SurfaceRowCondition {
307 Present,
308 Absent,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct SurfaceTab {
313 pub id: crate::SurfaceTabId,
314 pub label: String,
315 pub root: SurfaceNode,
316}
317
318#[non_exhaustive]
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "snake_case")]
321pub enum CalloutLevel {
322 Info,
323 Warning,
324 Danger,
325}
326
327#[non_exhaustive]
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329pub struct SurfaceDescriptor {
330 pub surface_id: SurfaceId,
331 pub label: String,
332 pub priority: i32,
333 pub slot: String,
334 pub scope: Scope,
335 pub targeting: Targeting,
336 #[serde(
339 default,
340 alias = "required_permission",
341 skip_serializing_if = "Option::is_none"
342 )]
343 pub required_action: Option<String>,
344 pub provider_kind: ProviderKind,
345 pub required_capabilities: CapabilitySet,
346 pub root_node: SurfaceNode,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub context_selector: Option<SurfaceContextSelectorDescriptor>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub nav_icon: Option<String>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub tab_group: Option<String>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub tab_group_label: Option<String>,
355}
356
357impl SurfaceDescriptor {
358 #[must_use]
380 pub fn builder() -> SurfaceDescriptorBuilder {
381 SurfaceDescriptorBuilder::default()
382 }
383}
384
385#[derive(Debug, Clone, Default)]
393pub struct SurfaceDescriptorBuilder {
394 surface_id: Option<SurfaceId>,
395 label: Option<String>,
396 priority: Option<i32>,
397 slot: Option<String>,
398 scope: Option<Scope>,
399 targeting: Option<Targeting>,
400 required_action: Option<String>,
401 provider_kind: Option<ProviderKind>,
402 required_capabilities: Option<CapabilitySet>,
403 root_node: Option<SurfaceNode>,
404 context_selector: Option<SurfaceContextSelectorDescriptor>,
405 nav_icon: Option<String>,
406 tab_group: Option<String>,
407 tab_group_label: Option<String>,
408}
409
410impl SurfaceDescriptorBuilder {
411 #[must_use]
413 pub fn surface_id(mut self, surface_id: SurfaceId) -> Self {
414 self.surface_id = Some(surface_id);
415 self
416 }
417
418 #[must_use]
420 pub fn label(mut self, label: impl Into<String>) -> Self {
421 self.label = Some(label.into());
422 self
423 }
424
425 #[must_use]
427 pub fn priority(mut self, priority: i32) -> Self {
428 self.priority = Some(priority);
429 self
430 }
431
432 #[must_use]
434 pub fn slot(mut self, slot: impl Into<String>) -> Self {
435 self.slot = Some(slot.into());
436 self
437 }
438
439 #[must_use]
441 pub fn scope(mut self, scope: Scope) -> Self {
442 self.scope = Some(scope);
443 self
444 }
445
446 #[must_use]
448 pub fn targeting(mut self, targeting: Targeting) -> Self {
449 self.targeting = Some(targeting);
450 self
451 }
452
453 #[must_use]
457 pub fn required_action(mut self, action: Action) -> Self {
458 self.required_action = Some(action.to_string());
459 self
460 }
461
462 #[must_use]
464 pub fn provider_kind(mut self, provider_kind: ProviderKind) -> Self {
465 self.provider_kind = Some(provider_kind);
466 self
467 }
468
469 #[must_use]
471 pub fn required_capabilities(mut self, required_capabilities: CapabilitySet) -> Self {
472 self.required_capabilities = Some(required_capabilities);
473 self
474 }
475
476 #[must_use]
478 pub fn root_node(mut self, root_node: SurfaceNode) -> Self {
479 self.root_node = Some(root_node);
480 self
481 }
482
483 #[must_use]
485 pub fn context_selector(mut self, context_selector: SurfaceContextSelectorDescriptor) -> Self {
486 self.context_selector = Some(context_selector);
487 self
488 }
489
490 #[must_use]
492 pub fn nav_icon(mut self, nav_icon: impl Into<String>) -> Self {
493 self.nav_icon = Some(nav_icon.into());
494 self
495 }
496
497 #[must_use]
502 pub fn tab_group(mut self, id: impl Into<String>, label: impl Into<String>) -> Self {
503 self.tab_group = Some(id.into());
504 self.tab_group_label = Some(label.into());
505 self
506 }
507
508 #[must_use]
515 #[expect(
516 clippy::expect_used,
517 reason = "builder pattern — panicking on missing required fields is intentional and documented"
518 )]
519 pub fn build(self) -> SurfaceDescriptor {
520 SurfaceDescriptor {
521 surface_id: self
522 .surface_id
523 .expect("SurfaceDescriptorBuilder: surface_id not set"),
524 label: self.label.expect("SurfaceDescriptorBuilder: label not set"),
525 priority: self
526 .priority
527 .expect("SurfaceDescriptorBuilder: priority not set"),
528 slot: self.slot.expect("SurfaceDescriptorBuilder: slot not set"),
529 scope: self.scope.expect("SurfaceDescriptorBuilder: scope not set"),
530 targeting: self
531 .targeting
532 .expect("SurfaceDescriptorBuilder: targeting not set"),
533 required_action: self.required_action,
534 provider_kind: self
535 .provider_kind
536 .expect("SurfaceDescriptorBuilder: provider_kind not set"),
537 required_capabilities: self
538 .required_capabilities
539 .expect("SurfaceDescriptorBuilder: required_capabilities not set"),
540 root_node: self
541 .root_node
542 .expect("SurfaceDescriptorBuilder: root_node not set"),
543 context_selector: self.context_selector,
544 nav_icon: self.nav_icon,
545 tab_group: self.tab_group,
546 tab_group_label: self.tab_group_label,
547 }
548 }
549}
550
551#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
552pub struct FrameworkGeneration {
553 pub major: u16,
554 pub minor: u16,
555}
556
557impl FrameworkGeneration {
558 pub const fn new(major: u16, minor: u16) -> Self {
559 Self { major, minor }
560 }
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
564pub struct FrameworkGenerationRange {
565 pub min: FrameworkGeneration,
566 pub max: FrameworkGeneration,
567}
568
569impl FrameworkGenerationRange {
570 #[must_use]
571 pub const fn includes(&self, value: FrameworkGeneration) -> bool {
572 is_generation_le(self.min, value) && is_generation_le(value, self.max)
573 }
574}
575
576const fn is_generation_le(left: FrameworkGeneration, right: FrameworkGeneration) -> bool {
577 left.major < right.major || (left.major == right.major && left.minor <= right.minor)
578}
579
580#[non_exhaustive]
581#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
582#[serde(rename_all = "snake_case")]
583pub enum Capability {
584 SectionNode,
585 TextBlockNode,
586 KeyValueNode,
587 TableNode,
588 FormNode,
589 ActionBarNode,
590 TabsNode,
591 CalloutNode,
592 EmptyStateNode,
593 ModalTriggerNode,
594 WorkflowTriggerNode,
595 MutationAction,
596 FormSubmit,
597 Workflow,
598 Navigate,
599 DataLoad,
600 ConfirmableAction,
601 StaticDataSource,
602 ControllerQueryDataSource,
603 ProviderQueryDataSource,
604 UniversalTargeting,
605 TargetedTargeting,
606 SensitiveFields,
607 ProviderInitiatedActions,
608 ContextSelector,
609 EntityLinkColumn,
610}
611
612#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
613#[serde(transparent)]
614pub struct CapabilitySet(pub std::collections::BTreeSet<Capability>);
615
616impl CapabilitySet {
617 #[must_use]
618 pub fn from_capabilities(caps: impl IntoIterator<Item = Capability>) -> Self {
619 Self(caps.into_iter().collect())
620 }
621
622 #[must_use]
623 pub fn contains_all(&self, other: &Self) -> bool {
624 other.0.iter().all(|cap| self.0.contains(cap))
625 }
626}
627
628#[non_exhaustive]
635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
636pub struct SurfaceContextSelectorDescriptor {
637 pub param_key: String,
639 pub label: String,
641 pub all_option_label: String,
643 pub rest_api_path: String,
645 pub value_field: String,
647 pub label_field: String,
649 #[serde(default, skip_serializing_if = "Vec::is_empty")]
651 pub required_for_interactions: Vec<crate::InteractionId>,
652}
653
654impl SurfaceContextSelectorDescriptor {
655 #[must_use]
660 pub fn new(
661 param_key: impl Into<String>,
662 label: impl Into<String>,
663 all_option_label: impl Into<String>,
664 rest_api_path: impl Into<String>,
665 value_field: impl Into<String>,
666 label_field: impl Into<String>,
667 required_for_interactions: Vec<crate::InteractionId>,
668 ) -> Self {
669 Self {
670 param_key: param_key.into(),
671 label: label.into(),
672 all_option_label: all_option_label.into(),
673 rest_api_path: rest_api_path.into(),
674 value_field: value_field.into(),
675 label_field: label_field.into(),
676 required_for_interactions,
677 }
678 }
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 fn skew_guard_descriptor(required_action: Option<&str>) -> SurfaceDescriptor {
689 SurfaceDescriptor {
690 surface_id: SurfaceId::new("test.surface").unwrap(),
691 label: "Test".to_string(),
692 priority: 100,
693 slot: "surface.page".to_string(),
694 scope: Scope::Global,
695 targeting: Targeting::Universal,
696 required_action: required_action.map(str::to_string),
697 provider_kind: ProviderKind::Plugin,
698 required_capabilities: CapabilitySet::default(),
699 root_node: SurfaceNode::section(None::<String>, vec![]),
700 context_selector: None,
701 nav_icon: None,
702 tab_group: None,
703 tab_group_label: None,
704 }
705 }
706
707 fn with_legacy_key(mut json: serde_json::Value) -> serde_json::Value {
709 json.as_object_mut()
710 .expect("descriptor serializes to a JSON object")
711 .insert(
712 "required_permission".to_string(),
713 serde_json::Value::String("update_hosts".to_string()),
714 );
715 json
716 }
717
718 #[test]
719 fn required_action_accepts_legacy_key_via_alias() {
720 let json =
723 with_legacy_key(serde_json::to_value(skew_guard_descriptor(None)).expect("serialize"));
724
725 let descriptor: SurfaceDescriptor =
726 serde_json::from_value(json).expect("alias must deserialize");
727 assert_eq!(descriptor.required_action.as_deref(), Some("update_hosts"));
728 }
729
730 #[test]
731 fn required_action_rejects_dual_key_payload() {
732 let json = with_legacy_key(
735 serde_json::to_value(skew_guard_descriptor(Some("hosts:update"))).expect("serialize"),
736 );
737 serde_json::from_value::<SurfaceDescriptor>(json).expect_err("dual key must fail");
740 }
741
742 #[test]
743 fn required_action_serializes_under_the_new_key_only() {
744 let value =
745 serde_json::to_value(skew_guard_descriptor(Some("hosts:update"))).expect("serialize");
746 assert_eq!(
747 value
748 .get("required_action")
749 .and_then(serde_json::Value::as_str),
750 Some("hosts:update")
751 );
752 assert!(value.get("required_permission").is_none());
753 }
754
755 #[test]
756 fn context_selector_capability_serializes_to_snake_case() {
757 let cap = Capability::ContextSelector;
758 let serialized = serde_json::to_string(&cap).expect("serialize");
759 assert_eq!(serialized, r#""context_selector""#);
760 }
761
762 #[test]
763 fn surface_descriptor_context_selector_round_trips() {
764 let descriptor = SurfaceDescriptor {
765 surface_id: SurfaceId::new("test.surface").unwrap(),
766 label: "Test".to_string(),
767 priority: 100,
768 slot: "surface.page".to_string(),
769 scope: Scope::Global,
770 targeting: Targeting::Universal,
771 required_action: None,
772 provider_kind: ProviderKind::Plugin,
773 required_capabilities: CapabilitySet::from_capabilities([Capability::ContextSelector]),
774 root_node: SurfaceNode::section(None::<String>, vec![]),
775 context_selector: Some(SurfaceContextSelectorDescriptor {
776 param_key: "plugin_config_id".to_string(),
777 label: "Configuration".to_string(),
778 all_option_label: "All Configurations".to_string(),
779 rest_api_path: "/api/v1/plugin-configs".to_string(),
780 value_field: "id".to_string(),
781 label_field: "name".to_string(),
782 required_for_interactions: vec![crate::InteractionId::new("discover").unwrap()],
783 }),
784 nav_icon: None,
785 tab_group: None,
786 tab_group_label: None,
787 };
788
789 let json = serde_json::to_string(&descriptor).expect("serialize");
790 let deserialized: SurfaceDescriptor = serde_json::from_str(&json).expect("deserialize");
791 assert_eq!(descriptor, deserialized);
792
793 let context_selector = deserialized.context_selector.unwrap();
794 assert_eq!(context_selector.param_key, "plugin_config_id");
795 assert_eq!(
796 context_selector.required_for_interactions,
797 vec![crate::InteractionId::new("discover").unwrap()]
798 );
799 }
800
801 #[test]
802 fn surface_descriptor_without_context_selector_omits_field_in_json() {
803 let descriptor = SurfaceDescriptor {
804 surface_id: SurfaceId::new("test.surface").unwrap(),
805 label: "Test".to_string(),
806 priority: 100,
807 slot: "surface.page".to_string(),
808 scope: Scope::Global,
809 targeting: Targeting::Universal,
810 required_action: None,
811 provider_kind: ProviderKind::Plugin,
812 required_capabilities: CapabilitySet::default(),
813 root_node: SurfaceNode::section(None::<String>, vec![]),
814 context_selector: None,
815 nav_icon: None,
816 tab_group: None,
817 tab_group_label: None,
818 };
819
820 let json = serde_json::to_string(&descriptor).expect("serialize");
821 assert!(
822 !json.contains("context_selector"),
823 "absent context_selector must be omitted from JSON"
824 );
825 }
826
827 #[test]
828 fn surface_table_cell_type_entity_link_serializes_correctly() {
829 let mut col = SurfaceTableColumn::new("host", "Host");
830 col.cell_type = Some(SurfaceTableCellType::EntityLink {
831 entity_type: SurfaceEntityType::Host,
832 });
833 let json = serde_json::to_string(&col).expect("serialize");
834 let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
835 assert_eq!(parsed["cell_type"]["kind"], "entity_link");
836 assert_eq!(parsed["cell_type"]["entity_type"], "host");
837 }
838
839 #[test]
840 fn surface_table_column_without_cell_type_omits_field() {
841 let col = SurfaceTableColumn::new("name", "Name");
842 let json = serde_json::to_string(&col).expect("serialize");
843 assert!(!json.contains("cell_type"));
844 }
845
846 #[test]
847 fn unknown_cell_type_deserializes_to_none() {
848 let json =
849 r#"{"key":"host","label":"Host","cell_type":{"kind":"future_type","extra":"data"}}"#;
850 let col: SurfaceTableColumn = serde_json::from_str(json).expect("deserialize");
851 assert!(col.cell_type.is_none());
852 }
853
854 #[test]
855 fn surface_entity_type_host_serializes_to_bare_string() {
856 let t = SurfaceEntityType::Host;
857 let s = serde_json::to_string(&t).expect("serialize");
858 assert_eq!(s, r#""host""#);
859 }
860
861 #[test]
862 fn surface_entity_type_other_serializes_to_bare_string() {
863 let t = SurfaceEntityType::Other("my_future_type".to_string());
864 let s = serde_json::to_string(&t).expect("serialize");
865 assert_eq!(s, r#""my_future_type""#);
866 }
867
868 #[test]
869 fn surface_entity_type_unknown_string_deserializes_to_other() {
870 let t: SurfaceEntityType = serde_json::from_str(r#""unknown_type""#).expect("deserialize");
871 assert_eq!(t, SurfaceEntityType::Other("unknown_type".to_string()));
872 }
873
874 #[test]
875 fn surface_entity_ref_unresolved_serializes_without_label_or_found() {
876 let entity_id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
877 let r = SurfaceEntityRef::unresolved(entity_id);
878 let json = serde_json::to_string(&r).expect("serialize");
879 let val: serde_json::Value = serde_json::from_str(&json).expect("parse");
880 assert_eq!(val["entity_id"], entity_id.to_string());
881 assert!(val.get("label").is_none());
882 assert!(val.get("found").is_none());
883 }
884
885 #[test]
886 fn entity_link_column_capability_serializes_to_snake_case() {
887 let cap = Capability::EntityLinkColumn;
888 let s = serde_json::to_string(&cap).expect("serialize");
889 assert_eq!(s, r#""entity_link_column""#);
890 }
891
892 #[test]
893 fn action_ref_reads_legacy_bare_string() {
894 let refs: Vec<ActionRef> =
895 serde_json::from_value(serde_json::json!(["create", "delete"])).expect("legacy form");
896 assert_eq!(refs[0].interaction_id().as_str(), "create");
897 assert!(refs[0].http_method().is_none());
898 }
899
900 #[test]
901 fn action_ref_reads_object_form_with_method() {
902 let refs: Vec<ActionRef> = serde_json::from_value(serde_json::json!([
903 { "interaction_id": "clients", "http_method": "delete" }
904 ]))
905 .expect("object form");
906 assert_eq!(refs[0].interaction_id().as_str(), "clients");
907 assert_eq!(
908 refs[0].http_method(),
909 Some(&crate::InteractionHttpMethod::Delete)
910 );
911 }
912
913 #[test]
914 fn action_ref_bare_serializes_as_plain_string() {
915 let json = serde_json::to_value(vec![ActionRef::from(
916 crate::InteractionId::new("create").expect("id"),
917 )])
918 .expect("serialize");
919 assert_eq!(json, serde_json::json!(["create"]));
920 }
921}