Skip to main content

uptrakit_surfaces/
surface.rs

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    /// Constructs a [`SurfaceNode::Section`] with an optional title and children,
89    /// and no header action buttons.
90    ///
91    /// Use this constructor instead of the struct literal because [`SurfaceNode`] is
92    /// `#[non_exhaustive]` — external crates cannot construct variants directly.
93    #[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    /// Constructs a [`SurfaceNode::Section`] with an optional title, header action
103    /// button IDs, and children.
104    ///
105    /// `header_action_ids` refers to [`crate::InteractionId`]s that the Dashboard
106    /// renders as icon buttons in the section header row.
107    ///
108    /// Use this constructor instead of the struct literal because [`SurfaceNode`] is
109    /// `#[non_exhaustive]` — external crates cannot construct variants directly.
110    #[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    /// Creates a new column with no cell type (plain text rendering).
139    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/// Cell type for a surface table column.
159///
160/// Forward compatibility: unknown `kind` values deserialize to `None` via
161/// [`deserialize_optional_cell_type`] rather than `Other(String)`, because
162/// a completely unknown cell type has no meaningful rendering — silently
163/// treating it as a plain-text column is safer than propagating an opaque value.
164#[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/// Wire-safe entity type enum.
172///
173/// Known variants are type-safe; unknown values from newer peers become
174/// `Other(String)` for forward compatibility. Uses custom `Serialize`
175/// and `Deserialize` so that `Other(String)` emits a bare string on
176/// the wire (not `{"other":"..."}`).
177#[non_exhaustive]
178#[derive(Debug, Clone, PartialEq, Eq, Hash)]
179pub enum SurfaceEntityType {
180    Host,
181    Other(String),
182}
183
184impl SurfaceEntityType {
185    /// Returns the snake_case wire string for this entity type.
186    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/// Cell value for entity-link columns.
216///
217/// Plugins construct via [`SurfaceEntityRef::unresolved`] (`entity_id` only).
218/// The framework enriches `label` and `found` before sending the wire response.
219/// `found: None` is a transient pre-enrichment state — must not appear in the
220/// final wire response for cells whose resolver ran.
221#[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    /// Constructs an unresolved ref for use by plugin handlers.
233    /// The framework enriches `label` and `found` in the enrichment step.
234    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    /// Returns a zero-arg [`SurfaceDescriptorBuilder`] for constructing a [`SurfaceDescriptor`].
306    ///
307    /// # Example
308    ///
309    /// ```rust
310    /// use uptrakit_surfaces::{
311    ///     CapabilitySet, ProviderKind, Scope, SurfaceDescriptor, SurfaceId, SurfaceNode, Targeting,
312    /// };
313    ///
314    /// let descriptor = SurfaceDescriptor::builder()
315    ///     .surface_id(SurfaceId::new("provider.sample.surface").unwrap())
316    ///     .label("Sample")
317    ///     .priority(200)
318    ///     .slot("surface.page")
319    ///     .scope(Scope::Tenant)
320    ///     .targeting(Targeting::Universal)
321    ///     .provider_kind(ProviderKind::Plugin)
322    ///     .required_capabilities(CapabilitySet::default())
323    ///     .root_node(SurfaceNode::section(None::<String>, vec![]))
324    ///     .build();
325    /// ```
326    #[must_use]
327    pub fn builder() -> SurfaceDescriptorBuilder {
328        SurfaceDescriptorBuilder::default()
329    }
330}
331
332/// Builder for [`SurfaceDescriptor`].
333///
334/// Obtain an instance via [`SurfaceDescriptor::builder`] and call [`build`](Self::build) to
335/// finalise the descriptor. Optional fields ([`required_permission`](Self::required_permission)
336/// and [`context_selector`](Self::context_selector)) default to `None`.
337///
338/// [`build`](Self::build) panics if any required field has not been set.
339#[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    /// Sets the surface identifier.
359    #[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    /// Sets the human-readable label.
366    #[must_use]
367    pub fn label(mut self, label: impl Into<String>) -> Self {
368        self.label = Some(label.into());
369        self
370    }
371
372    /// Sets the display priority within the slot.
373    #[must_use]
374    pub fn priority(mut self, priority: i32) -> Self {
375        self.priority = Some(priority);
376        self
377    }
378
379    /// Sets the slot identifier (e.g. `"surface.page"`).
380    #[must_use]
381    pub fn slot(mut self, slot: impl Into<String>) -> Self {
382        self.slot = Some(slot.into());
383        self
384    }
385
386    /// Sets the scope (global or tenant).
387    #[must_use]
388    pub fn scope(mut self, scope: Scope) -> Self {
389        self.scope = Some(scope);
390        self
391    }
392
393    /// Sets the targeting mode.
394    #[must_use]
395    pub fn targeting(mut self, targeting: Targeting) -> Self {
396        self.targeting = Some(targeting);
397        self
398    }
399
400    /// Sets the permission string required to view this surface (optional).
401    #[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    /// Sets the provider kind.
408    #[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    /// Sets the set of capabilities this surface requires from the framework.
415    #[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    /// Sets the root [`SurfaceNode`] of the surface layout.
422    #[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    /// Attaches a context-selector dropdown descriptor to the surface (optional).
429    #[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    /// Sets the nav icon name (optional; must match a key in the frontend `SURFACE_NAV_ICONS` allowlist).
436    #[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    /// Groups this surface with others sharing `id` under one settings tab.
443    ///
444    /// If `id` matches an existing built-in tab (e.g. `"general"`), content is appended
445    /// to that tab and `label` is ignored. Otherwise a new tab labelled `label` is created.
446    #[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    /// Consumes the builder and returns the [`SurfaceDescriptor`].
454    ///
455    /// # Panics
456    ///
457    /// Panics if any required field (`surface_id`, `label`, `priority`, `slot`, `scope`,
458    /// `targeting`, `provider_kind`, `required_capabilities`, `root_node`) has not been set.
459    #[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/// Describes a context-selector dropdown rendered above a surface's content.
574///
575/// When present on a `SurfaceDescriptor`, `SurfaceReadPanel` fetches the
576/// options from `rest_api_path` and renders a `ProviderSelector` above the
577/// surface content. The selected value is merged into `baseParams` under
578/// `param_key`, driving both the table data load and optional interaction gates.
579#[non_exhaustive]
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581pub struct SurfaceContextSelectorDescriptor {
582    /// Param key injected into `baseParams` when a specific option is selected.
583    pub param_key: String,
584    /// Label shown above the selector dropdown.
585    pub label: String,
586    /// Label for the "show all" option (no param injected).
587    pub all_option_label: String,
588    /// REST API path returning a JSON array or paginated `items` list.
589    pub rest_api_path: String,
590    /// Field in each item used as the option value.
591    pub value_field: String,
592    /// Field in each item used as the option label.
593    pub label_field: String,
594    /// Interaction IDs disabled (with tooltip) when no specific option is selected.
595    #[serde(default, skip_serializing_if = "Vec::is_empty")]
596    pub required_for_interactions: Vec<crate::InteractionId>,
597}
598
599impl SurfaceContextSelectorDescriptor {
600    /// Constructs a new [`SurfaceContextSelectorDescriptor`].
601    ///
602    /// Required because the struct is `#[non_exhaustive]` — external crates cannot use
603    /// struct literal syntax and must call this constructor instead.
604    #[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}