Skip to main content

uptrakit_surfaces/
surface.rs

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    /// Constructs a [`SurfaceNode::Section`] with an optional title and children,
94    /// and no header action buttons.
95    ///
96    /// Use this constructor instead of the struct literal because [`SurfaceNode`] is
97    /// `#[non_exhaustive]` — external crates cannot construct variants directly.
98    #[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    /// Constructs a [`SurfaceNode::Section`] with an optional title, header action
108    /// button IDs, and children.
109    ///
110    /// `header_action_ids` refers to [`crate::InteractionId`]s that the Dashboard
111    /// renders as icon buttons in the section header row.
112    ///
113    /// Use this constructor instead of the struct literal because [`SurfaceNode`] is
114    /// `#[non_exhaustive]` — external crates cannot construct variants directly.
115    #[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/// Reference to an interaction from an action bar. Untagged two-form reader:
130/// the legacy bare-string form (method omitted — resolves only when the
131/// target ID registers exactly one method) and an object form for
132/// multi-method IDs. NOTE: adding a third form later hard-fails two-form
133/// readers on old peers (accepted, spec §2a).
134#[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    /// Creates a new column with no cell type (plain text rendering).
184    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/// Cell type for a surface table column.
204///
205/// Forward compatibility: unknown `kind` values deserialize to `None` via
206/// [`deserialize_optional_cell_type`] rather than `Other(String)`, because
207/// a completely unknown cell type has no meaningful rendering — silently
208/// treating it as a plain-text column is safer than propagating an opaque value.
209#[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/// Wire-safe entity type enum.
217///
218/// Known variants are type-safe; unknown values from newer peers become
219/// `Other(String)` for forward compatibility. Uses custom `Serialize`
220/// and `Deserialize` so that `Other(String)` emits a bare string on
221/// the wire (not `{"other":"..."}`).
222#[non_exhaustive]
223#[derive(Debug, Clone, PartialEq, Eq, Hash)]
224pub enum SurfaceEntityType {
225    Host,
226    Other(String),
227}
228
229impl SurfaceEntityType {
230    /// Returns the snake_case wire string for this entity type.
231    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/// Cell value for entity-link columns.
261///
262/// Plugins construct via [`SurfaceEntityRef::unresolved`] (`entity_id` only).
263/// The framework enriches `label` and `found` before sending the wire response.
264/// `found: None` is a transient pre-enrichment state — must not appear in the
265/// final wire response for cells whose resolver ran.
266#[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    /// Constructs an unresolved ref for use by plugin handlers.
278    /// The framework enriches `label` and `found` in the enrichment step.
279    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    /// Canonical action string (`resource:verb`) required to view/use this surface;
337    /// parsed to `Action` at admission.
338    #[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    /// Returns a zero-arg [`SurfaceDescriptorBuilder`] for constructing a [`SurfaceDescriptor`].
359    ///
360    /// # Example
361    ///
362    /// ```rust
363    /// use uptrakit_surfaces::{
364    ///     CapabilitySet, ProviderKind, Scope, SurfaceDescriptor, SurfaceId, SurfaceNode, Targeting,
365    /// };
366    ///
367    /// let descriptor = SurfaceDescriptor::builder()
368    ///     .surface_id(SurfaceId::new("provider.sample.surface").unwrap())
369    ///     .label("Sample")
370    ///     .priority(200)
371    ///     .slot("surface.page")
372    ///     .scope(Scope::Tenant)
373    ///     .targeting(Targeting::Universal)
374    ///     .provider_kind(ProviderKind::Plugin)
375    ///     .required_capabilities(CapabilitySet::default())
376    ///     .root_node(SurfaceNode::section(None::<String>, vec![]))
377    ///     .build();
378    /// ```
379    #[must_use]
380    pub fn builder() -> SurfaceDescriptorBuilder {
381        SurfaceDescriptorBuilder::default()
382    }
383}
384
385/// Builder for [`SurfaceDescriptor`].
386///
387/// Obtain an instance via [`SurfaceDescriptor::builder`] and call [`build`](Self::build) to
388/// finalise the descriptor. Optional fields ([`required_action`](Self::required_action)
389/// and [`context_selector`](Self::context_selector)) default to `None`.
390///
391/// [`build`](Self::build) panics if any required field has not been set.
392#[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    /// Sets the surface identifier.
412    #[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    /// Sets the human-readable label.
419    #[must_use]
420    pub fn label(mut self, label: impl Into<String>) -> Self {
421        self.label = Some(label.into());
422        self
423    }
424
425    /// Sets the display priority within the slot.
426    #[must_use]
427    pub fn priority(mut self, priority: i32) -> Self {
428        self.priority = Some(priority);
429        self
430    }
431
432    /// Sets the slot identifier (e.g. `"surface.page"`).
433    #[must_use]
434    pub fn slot(mut self, slot: impl Into<String>) -> Self {
435        self.slot = Some(slot.into());
436        self
437    }
438
439    /// Sets the scope (global or tenant).
440    #[must_use]
441    pub fn scope(mut self, scope: Scope) -> Self {
442        self.scope = Some(scope);
443        self
444    }
445
446    /// Sets the targeting mode.
447    #[must_use]
448    pub fn targeting(mut self, targeting: Targeting) -> Self {
449        self.targeting = Some(targeting);
450        self
451    }
452
453    /// Sets the catalog action required to view this surface (optional).
454    /// Typed at the declaration site; stored as the canonical action string
455    /// (actions never cross the service wire as a type).
456    #[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    /// Sets the provider kind.
463    #[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    /// Sets the set of capabilities this surface requires from the framework.
470    #[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    /// Sets the root [`SurfaceNode`] of the surface layout.
477    #[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    /// Attaches a context-selector dropdown descriptor to the surface (optional).
484    #[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    /// Sets the nav icon name (optional; must match a key in the frontend `SURFACE_NAV_ICONS` allowlist).
491    #[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    /// Groups this surface with others sharing `id` under one settings tab.
498    ///
499    /// If `id` matches an existing built-in tab (e.g. `"general"`), content is appended
500    /// to that tab and `label` is ignored. Otherwise a new tab labelled `label` is created.
501    #[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    /// Consumes the builder and returns the [`SurfaceDescriptor`].
509    ///
510    /// # Panics
511    ///
512    /// Panics if any required field (`surface_id`, `label`, `priority`, `slot`, `scope`,
513    /// `targeting`, `provider_kind`, `required_capabilities`, `root_node`) has not been set.
514    #[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/// Describes a context-selector dropdown rendered above a surface's content.
629///
630/// When present on a `SurfaceDescriptor`, `SurfaceReadPanel` fetches the
631/// options from `rest_api_path` and renders a `ProviderSelector` above the
632/// surface content. The selected value is merged into `baseParams` under
633/// `param_key`, driving both the table data load and optional interaction gates.
634#[non_exhaustive]
635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
636pub struct SurfaceContextSelectorDescriptor {
637    /// Param key injected into `baseParams` when a specific option is selected.
638    pub param_key: String,
639    /// Label shown above the selector dropdown.
640    pub label: String,
641    /// Label for the "show all" option (no param injected).
642    pub all_option_label: String,
643    /// REST API path returning a JSON array or paginated `items` list.
644    pub rest_api_path: String,
645    /// Field in each item used as the option value.
646    pub value_field: String,
647    /// Field in each item used as the option label.
648    pub label_field: String,
649    /// Interaction IDs disabled (with tooltip) when no specific option is selected.
650    #[serde(default, skip_serializing_if = "Vec::is_empty")]
651    pub required_for_interactions: Vec<crate::InteractionId>,
652}
653
654impl SurfaceContextSelectorDescriptor {
655    /// Constructs a new [`SurfaceContextSelectorDescriptor`].
656    ///
657    /// Required because the struct is `#[non_exhaustive]` — external crates cannot use
658    /// struct literal syntax and must call this constructor instead.
659    #[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    /// Minimal-valid descriptor for the skew-guard tests below, so they
686    /// exercise `SurfaceDescriptor`'s own serde attributes rather than a
687    /// hand-copied mirror that could drift away from them.
688    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    /// Adds the legacy key to an already-serialized descriptor payload.
708    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        // A stale-satellite payload lands in required_action; the (legacy)
721        // value then dies at the admission Action parse — never a silent None.
722        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        // serde derive: an alias shares the field's slot, so a second
733        // occurrence is duplicate_field — there is no last-wins.
734        let json = with_legacy_key(
735            serde_json::to_value(skew_guard_descriptor(Some("hosts:update"))).expect("serialize"),
736        );
737        // expect_err alone pins the semantics (dual key fails, no last-wins);
738        // do not assert serde_json's message text (upstream-behavior coupling).
739        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}