1use serde::{Deserialize, Serialize};
2
3use crate::SurfaceId;
4
5#[non_exhaustive]
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum Targeting {
9 Universal,
10 Targeted,
11}
12
13#[non_exhaustive]
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum Scope {
17 Global,
18 Tenant,
19}
20
21#[non_exhaustive]
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum ProviderKind {
25 BuiltIn,
26 Plugin,
27 Service,
28}
29
30#[non_exhaustive]
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case", tag = "kind")]
33pub enum SurfaceNode {
34 Section {
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 title: Option<String>,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 header_action_ids: Vec<crate::InteractionId>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 children: Vec<SurfaceNode>,
41 },
42 TextBlock {
43 text: String,
44 },
45 KeyValue {
46 data_source_id: crate::DataSourceId,
47 },
48 Table {
49 data_source_id: crate::DataSourceId,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 columns: Vec<SurfaceTableColumn>,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 row_actions: Vec<SurfaceTableRowAction>,
54 },
55 Form {
56 interaction_id: crate::InteractionId,
57 },
58 ActionBar {
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 action_ids: Vec<crate::InteractionId>,
61 },
62 Tabs {
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 tabs: Vec<SurfaceTab>,
65 },
66 Callout {
67 level: CalloutLevel,
68 text: String,
69 },
70 EmptyState {
71 title: String,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 description: Option<String>,
74 },
75 ModalTrigger {
76 interaction_id: crate::InteractionId,
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
78 modal_nodes: Vec<SurfaceNode>,
79 },
80 WorkflowTrigger {
81 interaction_id: crate::InteractionId,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 step_nodes: Vec<SurfaceNode>,
84 },
85}
86
87impl SurfaceNode {
88 #[must_use]
94 pub fn section(title: Option<impl Into<String>>, children: Vec<Self>) -> Self {
95 Self::Section {
96 title: title.map(Into::into),
97 header_action_ids: vec![],
98 children,
99 }
100 }
101
102 #[must_use]
111 pub fn section_with_header_actions(
112 title: Option<impl Into<String>>,
113 header_action_ids: Vec<crate::InteractionId>,
114 children: Vec<Self>,
115 ) -> Self {
116 Self::Section {
117 title: title.map(Into::into),
118 header_action_ids,
119 children,
120 }
121 }
122}
123
124#[non_exhaustive]
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct SurfaceTableColumn {
127 pub key: String,
128 pub label: String,
129 #[serde(
130 default,
131 skip_serializing_if = "Option::is_none",
132 deserialize_with = "deserialize_optional_cell_type"
133 )]
134 pub cell_type: Option<SurfaceTableCellType>,
135}
136
137impl SurfaceTableColumn {
138 pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
140 Self {
141 key: key.into(),
142 label: label.into(),
143 cell_type: None,
144 }
145 }
146}
147
148fn deserialize_optional_cell_type<'de, D>(
149 deserializer: D,
150) -> Result<Option<SurfaceTableCellType>, D::Error>
151where
152 D: serde::Deserializer<'de>,
153{
154 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
155 Ok(value.and_then(|v| serde_json::from_value(v).ok()))
156}
157
158#[non_exhaustive]
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(tag = "kind", rename_all = "snake_case")]
167pub enum SurfaceTableCellType {
168 EntityLink { entity_type: SurfaceEntityType },
169}
170
171#[non_exhaustive]
178#[derive(Debug, Clone, PartialEq, Eq, Hash)]
179pub enum SurfaceEntityType {
180 Host,
181 Other(String),
182}
183
184impl SurfaceEntityType {
185 pub fn as_str(&self) -> &str {
187 match self {
188 Self::Host => "host",
189 Self::Other(s) => s.as_str(),
190 }
191 }
192}
193
194impl From<String> for SurfaceEntityType {
195 fn from(s: String) -> Self {
196 match s.as_str() {
197 "host" => Self::Host,
198 _ => Self::Other(s),
199 }
200 }
201}
202
203impl Serialize for SurfaceEntityType {
204 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
205 serializer.serialize_str(self.as_str())
206 }
207}
208
209impl<'de> Deserialize<'de> for SurfaceEntityType {
210 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
211 String::deserialize(deserializer).map(SurfaceEntityType::from)
212 }
213}
214
215#[non_exhaustive]
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct SurfaceEntityRef {
224 pub entity_id: uuid::Uuid,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub label: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub found: Option<bool>,
229}
230
231impl SurfaceEntityRef {
232 pub fn unresolved(entity_id: uuid::Uuid) -> Self {
235 Self {
236 entity_id,
237 label: None,
238 found: None,
239 }
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct SurfaceTableRowAction {
245 pub interaction_id: crate::InteractionId,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub visible_when: Option<SurfaceRowVisibleWhen>,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct SurfaceRowVisibleWhen {
252 pub field: String,
253 pub condition: SurfaceRowCondition,
254}
255
256#[non_exhaustive]
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "snake_case")]
259pub enum SurfaceRowCondition {
260 Present,
261 Absent,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct SurfaceTab {
266 pub id: crate::SurfaceTabId,
267 pub label: String,
268 pub root: SurfaceNode,
269}
270
271#[non_exhaustive]
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "snake_case")]
274pub enum CalloutLevel {
275 Info,
276 Warning,
277 Danger,
278}
279
280#[non_exhaustive]
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub struct SurfaceDescriptor {
283 pub surface_id: SurfaceId,
284 pub label: String,
285 pub priority: i32,
286 pub slot: String,
287 pub scope: Scope,
288 pub targeting: Targeting,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub required_permission: Option<String>,
291 pub provider_kind: ProviderKind,
292 pub required_capabilities: CapabilitySet,
293 pub root_node: SurfaceNode,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub context_selector: Option<SurfaceContextSelectorDescriptor>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub nav_icon: Option<String>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub tab_group: Option<String>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub tab_group_label: Option<String>,
302}
303
304impl SurfaceDescriptor {
305 #[must_use]
327 pub fn builder() -> SurfaceDescriptorBuilder {
328 SurfaceDescriptorBuilder::default()
329 }
330}
331
332#[derive(Debug, Clone, Default)]
340pub struct SurfaceDescriptorBuilder {
341 surface_id: Option<SurfaceId>,
342 label: Option<String>,
343 priority: Option<i32>,
344 slot: Option<String>,
345 scope: Option<Scope>,
346 targeting: Option<Targeting>,
347 required_permission: Option<String>,
348 provider_kind: Option<ProviderKind>,
349 required_capabilities: Option<CapabilitySet>,
350 root_node: Option<SurfaceNode>,
351 context_selector: Option<SurfaceContextSelectorDescriptor>,
352 nav_icon: Option<String>,
353 tab_group: Option<String>,
354 tab_group_label: Option<String>,
355}
356
357impl SurfaceDescriptorBuilder {
358 #[must_use]
360 pub fn surface_id(mut self, surface_id: SurfaceId) -> Self {
361 self.surface_id = Some(surface_id);
362 self
363 }
364
365 #[must_use]
367 pub fn label(mut self, label: impl Into<String>) -> Self {
368 self.label = Some(label.into());
369 self
370 }
371
372 #[must_use]
374 pub fn priority(mut self, priority: i32) -> Self {
375 self.priority = Some(priority);
376 self
377 }
378
379 #[must_use]
381 pub fn slot(mut self, slot: impl Into<String>) -> Self {
382 self.slot = Some(slot.into());
383 self
384 }
385
386 #[must_use]
388 pub fn scope(mut self, scope: Scope) -> Self {
389 self.scope = Some(scope);
390 self
391 }
392
393 #[must_use]
395 pub fn targeting(mut self, targeting: Targeting) -> Self {
396 self.targeting = Some(targeting);
397 self
398 }
399
400 #[must_use]
402 pub fn required_permission(mut self, permission: impl Into<String>) -> Self {
403 self.required_permission = Some(permission.into());
404 self
405 }
406
407 #[must_use]
409 pub fn provider_kind(mut self, provider_kind: ProviderKind) -> Self {
410 self.provider_kind = Some(provider_kind);
411 self
412 }
413
414 #[must_use]
416 pub fn required_capabilities(mut self, required_capabilities: CapabilitySet) -> Self {
417 self.required_capabilities = Some(required_capabilities);
418 self
419 }
420
421 #[must_use]
423 pub fn root_node(mut self, root_node: SurfaceNode) -> Self {
424 self.root_node = Some(root_node);
425 self
426 }
427
428 #[must_use]
430 pub fn context_selector(mut self, context_selector: SurfaceContextSelectorDescriptor) -> Self {
431 self.context_selector = Some(context_selector);
432 self
433 }
434
435 #[must_use]
437 pub fn nav_icon(mut self, nav_icon: impl Into<String>) -> Self {
438 self.nav_icon = Some(nav_icon.into());
439 self
440 }
441
442 #[must_use]
447 pub fn tab_group(mut self, id: impl Into<String>, label: impl Into<String>) -> Self {
448 self.tab_group = Some(id.into());
449 self.tab_group_label = Some(label.into());
450 self
451 }
452
453 #[must_use]
460 #[expect(
461 clippy::expect_used,
462 reason = "builder pattern — panicking on missing required fields is intentional and documented"
463 )]
464 pub fn build(self) -> SurfaceDescriptor {
465 SurfaceDescriptor {
466 surface_id: self
467 .surface_id
468 .expect("SurfaceDescriptorBuilder: surface_id not set"),
469 label: self.label.expect("SurfaceDescriptorBuilder: label not set"),
470 priority: self
471 .priority
472 .expect("SurfaceDescriptorBuilder: priority not set"),
473 slot: self.slot.expect("SurfaceDescriptorBuilder: slot not set"),
474 scope: self.scope.expect("SurfaceDescriptorBuilder: scope not set"),
475 targeting: self
476 .targeting
477 .expect("SurfaceDescriptorBuilder: targeting not set"),
478 required_permission: self.required_permission,
479 provider_kind: self
480 .provider_kind
481 .expect("SurfaceDescriptorBuilder: provider_kind not set"),
482 required_capabilities: self
483 .required_capabilities
484 .expect("SurfaceDescriptorBuilder: required_capabilities not set"),
485 root_node: self
486 .root_node
487 .expect("SurfaceDescriptorBuilder: root_node not set"),
488 context_selector: self.context_selector,
489 nav_icon: self.nav_icon,
490 tab_group: self.tab_group,
491 tab_group_label: self.tab_group_label,
492 }
493 }
494}
495
496#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
497pub struct FrameworkGeneration {
498 pub major: u16,
499 pub minor: u16,
500}
501
502impl FrameworkGeneration {
503 pub const fn new(major: u16, minor: u16) -> Self {
504 Self { major, minor }
505 }
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
509pub struct FrameworkGenerationRange {
510 pub min: FrameworkGeneration,
511 pub max: FrameworkGeneration,
512}
513
514impl FrameworkGenerationRange {
515 #[must_use]
516 pub const fn includes(&self, value: FrameworkGeneration) -> bool {
517 is_generation_le(self.min, value) && is_generation_le(value, self.max)
518 }
519}
520
521const fn is_generation_le(left: FrameworkGeneration, right: FrameworkGeneration) -> bool {
522 left.major < right.major || (left.major == right.major && left.minor <= right.minor)
523}
524
525#[non_exhaustive]
526#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
527#[serde(rename_all = "snake_case")]
528pub enum Capability {
529 SectionNode,
530 TextBlockNode,
531 KeyValueNode,
532 TableNode,
533 FormNode,
534 ActionBarNode,
535 TabsNode,
536 CalloutNode,
537 EmptyStateNode,
538 ModalTriggerNode,
539 WorkflowTriggerNode,
540 MutationAction,
541 FormSubmit,
542 Workflow,
543 Navigate,
544 DataLoad,
545 ConfirmableAction,
546 StaticDataSource,
547 ControllerQueryDataSource,
548 ProviderQueryDataSource,
549 UniversalTargeting,
550 TargetedTargeting,
551 SensitiveFields,
552 ProviderInitiatedActions,
553 ContextSelector,
554 EntityLinkColumn,
555}
556
557#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
558#[serde(transparent)]
559pub struct CapabilitySet(pub std::collections::BTreeSet<Capability>);
560
561impl CapabilitySet {
562 #[must_use]
563 pub fn from_capabilities(caps: impl IntoIterator<Item = Capability>) -> Self {
564 Self(caps.into_iter().collect())
565 }
566
567 #[must_use]
568 pub fn contains_all(&self, other: &Self) -> bool {
569 other.0.iter().all(|cap| self.0.contains(cap))
570 }
571}
572
573#[non_exhaustive]
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581pub struct SurfaceContextSelectorDescriptor {
582 pub param_key: String,
584 pub label: String,
586 pub all_option_label: String,
588 pub rest_api_path: String,
590 pub value_field: String,
592 pub label_field: String,
594 #[serde(default, skip_serializing_if = "Vec::is_empty")]
596 pub required_for_interactions: Vec<crate::InteractionId>,
597}
598
599impl SurfaceContextSelectorDescriptor {
600 #[must_use]
605 pub fn new(
606 param_key: impl Into<String>,
607 label: impl Into<String>,
608 all_option_label: impl Into<String>,
609 rest_api_path: impl Into<String>,
610 value_field: impl Into<String>,
611 label_field: impl Into<String>,
612 required_for_interactions: Vec<crate::InteractionId>,
613 ) -> Self {
614 Self {
615 param_key: param_key.into(),
616 label: label.into(),
617 all_option_label: all_option_label.into(),
618 rest_api_path: rest_api_path.into(),
619 value_field: value_field.into(),
620 label_field: label_field.into(),
621 required_for_interactions,
622 }
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629
630 #[test]
631 fn context_selector_capability_serializes_to_snake_case() {
632 let cap = Capability::ContextSelector;
633 let serialized = serde_json::to_string(&cap).expect("serialize");
634 assert_eq!(serialized, r#""context_selector""#);
635 }
636
637 #[test]
638 fn surface_descriptor_context_selector_round_trips() {
639 let descriptor = SurfaceDescriptor {
640 surface_id: SurfaceId::new("test.surface").unwrap(),
641 label: "Test".to_string(),
642 priority: 100,
643 slot: "surface.page".to_string(),
644 scope: Scope::Global,
645 targeting: Targeting::Universal,
646 required_permission: None,
647 provider_kind: ProviderKind::Plugin,
648 required_capabilities: CapabilitySet::from_capabilities([Capability::ContextSelector]),
649 root_node: SurfaceNode::section(None::<String>, vec![]),
650 context_selector: Some(SurfaceContextSelectorDescriptor {
651 param_key: "plugin_config_id".to_string(),
652 label: "Configuration".to_string(),
653 all_option_label: "All Configurations".to_string(),
654 rest_api_path: "/api/v1/plugin-configs".to_string(),
655 value_field: "id".to_string(),
656 label_field: "name".to_string(),
657 required_for_interactions: vec![crate::InteractionId::new("discover").unwrap()],
658 }),
659 nav_icon: None,
660 tab_group: None,
661 tab_group_label: None,
662 };
663
664 let json = serde_json::to_string(&descriptor).expect("serialize");
665 let deserialized: SurfaceDescriptor = serde_json::from_str(&json).expect("deserialize");
666 assert_eq!(descriptor, deserialized);
667
668 let context_selector = deserialized.context_selector.unwrap();
669 assert_eq!(context_selector.param_key, "plugin_config_id");
670 assert_eq!(
671 context_selector.required_for_interactions,
672 vec![crate::InteractionId::new("discover").unwrap()]
673 );
674 }
675
676 #[test]
677 fn surface_descriptor_without_context_selector_omits_field_in_json() {
678 let descriptor = SurfaceDescriptor {
679 surface_id: SurfaceId::new("test.surface").unwrap(),
680 label: "Test".to_string(),
681 priority: 100,
682 slot: "surface.page".to_string(),
683 scope: Scope::Global,
684 targeting: Targeting::Universal,
685 required_permission: None,
686 provider_kind: ProviderKind::Plugin,
687 required_capabilities: CapabilitySet::default(),
688 root_node: SurfaceNode::section(None::<String>, vec![]),
689 context_selector: None,
690 nav_icon: None,
691 tab_group: None,
692 tab_group_label: None,
693 };
694
695 let json = serde_json::to_string(&descriptor).expect("serialize");
696 assert!(
697 !json.contains("context_selector"),
698 "absent context_selector must be omitted from JSON"
699 );
700 }
701
702 #[test]
703 fn surface_table_cell_type_entity_link_serializes_correctly() {
704 let mut col = SurfaceTableColumn::new("host", "Host");
705 col.cell_type = Some(SurfaceTableCellType::EntityLink {
706 entity_type: SurfaceEntityType::Host,
707 });
708 let json = serde_json::to_string(&col).expect("serialize");
709 let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
710 assert_eq!(parsed["cell_type"]["kind"], "entity_link");
711 assert_eq!(parsed["cell_type"]["entity_type"], "host");
712 }
713
714 #[test]
715 fn surface_table_column_without_cell_type_omits_field() {
716 let col = SurfaceTableColumn::new("name", "Name");
717 let json = serde_json::to_string(&col).expect("serialize");
718 assert!(!json.contains("cell_type"));
719 }
720
721 #[test]
722 fn unknown_cell_type_deserializes_to_none() {
723 let json =
724 r#"{"key":"host","label":"Host","cell_type":{"kind":"future_type","extra":"data"}}"#;
725 let col: SurfaceTableColumn = serde_json::from_str(json).expect("deserialize");
726 assert!(col.cell_type.is_none());
727 }
728
729 #[test]
730 fn surface_entity_type_host_serializes_to_bare_string() {
731 let t = SurfaceEntityType::Host;
732 let s = serde_json::to_string(&t).expect("serialize");
733 assert_eq!(s, r#""host""#);
734 }
735
736 #[test]
737 fn surface_entity_type_other_serializes_to_bare_string() {
738 let t = SurfaceEntityType::Other("my_future_type".to_string());
739 let s = serde_json::to_string(&t).expect("serialize");
740 assert_eq!(s, r#""my_future_type""#);
741 }
742
743 #[test]
744 fn surface_entity_type_unknown_string_deserializes_to_other() {
745 let t: SurfaceEntityType = serde_json::from_str(r#""unknown_type""#).expect("deserialize");
746 assert_eq!(t, SurfaceEntityType::Other("unknown_type".to_string()));
747 }
748
749 #[test]
750 fn surface_entity_ref_unresolved_serializes_without_label_or_found() {
751 let entity_id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
752 let r = SurfaceEntityRef::unresolved(entity_id);
753 let json = serde_json::to_string(&r).expect("serialize");
754 let val: serde_json::Value = serde_json::from_str(&json).expect("parse");
755 assert_eq!(val["entity_id"], entity_id.to_string());
756 assert!(val.get("label").is_none());
757 assert!(val.get("found").is_none());
758 }
759
760 #[test]
761 fn entity_link_column_capability_serializes_to_snake_case() {
762 let cap = Capability::EntityLinkColumn;
763 let s = serde_json::to_string(&cap).expect("serialize");
764 assert_eq!(s, r#""entity_link_column""#);
765 }
766}