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        children: Vec<SurfaceNode>,
39    },
40    TextBlock {
41        text: String,
42    },
43    KeyValue {
44        data_source_id: crate::DataSourceId,
45    },
46    Table {
47        data_source_id: crate::DataSourceId,
48        #[serde(default, skip_serializing_if = "Vec::is_empty")]
49        columns: Vec<SurfaceTableColumn>,
50        #[serde(default, skip_serializing_if = "Vec::is_empty")]
51        row_actions: Vec<SurfaceTableRowAction>,
52    },
53    Form {
54        interaction_id: crate::InteractionId,
55    },
56    ActionBar {
57        #[serde(default, skip_serializing_if = "Vec::is_empty")]
58        action_ids: Vec<crate::InteractionId>,
59    },
60    Tabs {
61        #[serde(default, skip_serializing_if = "Vec::is_empty")]
62        tabs: Vec<SurfaceTab>,
63    },
64    Callout {
65        level: CalloutLevel,
66        text: String,
67    },
68    EmptyState {
69        title: String,
70        #[serde(default, skip_serializing_if = "Option::is_none")]
71        description: Option<String>,
72    },
73    ModalTrigger {
74        interaction_id: crate::InteractionId,
75        #[serde(default, skip_serializing_if = "Vec::is_empty")]
76        modal_nodes: Vec<SurfaceNode>,
77    },
78    WorkflowTrigger {
79        interaction_id: crate::InteractionId,
80        #[serde(default, skip_serializing_if = "Vec::is_empty")]
81        step_nodes: Vec<SurfaceNode>,
82    },
83}
84
85#[non_exhaustive]
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct SurfaceTableColumn {
88    pub key: String,
89    pub label: String,
90    #[serde(
91        default,
92        skip_serializing_if = "Option::is_none",
93        deserialize_with = "deserialize_optional_cell_type"
94    )]
95    pub cell_type: Option<SurfaceTableCellType>,
96}
97
98impl SurfaceTableColumn {
99    /// Creates a new column with no cell type (plain text rendering).
100    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
101        Self {
102            key: key.into(),
103            label: label.into(),
104            cell_type: None,
105        }
106    }
107}
108
109fn deserialize_optional_cell_type<'de, D>(
110    deserializer: D,
111) -> Result<Option<SurfaceTableCellType>, D::Error>
112where
113    D: serde::Deserializer<'de>,
114{
115    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
116    Ok(value.and_then(|v| serde_json::from_value(v).ok()))
117}
118
119/// Cell type for a surface table column.
120///
121/// Forward compatibility: unknown `kind` values deserialize to `None` via
122/// [`deserialize_optional_cell_type`] rather than `Other(String)`, because
123/// a completely unknown cell type has no meaningful rendering — silently
124/// treating it as a plain-text column is safer than propagating an opaque value.
125#[non_exhaustive]
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(tag = "kind", rename_all = "snake_case")]
128pub enum SurfaceTableCellType {
129    EntityLink { entity_type: SurfaceEntityType },
130}
131
132/// Wire-safe entity type enum.
133///
134/// Known variants are type-safe; unknown values from newer peers become
135/// `Other(String)` for forward compatibility. Uses custom `Serialize`
136/// and `Deserialize` so that `Other(String)` emits a bare string on
137/// the wire (not `{"other":"..."}`).
138#[non_exhaustive]
139#[derive(Debug, Clone, PartialEq, Eq, Hash)]
140pub enum SurfaceEntityType {
141    Host,
142    Other(String),
143}
144
145impl SurfaceEntityType {
146    /// Returns the snake_case wire string for this entity type.
147    pub fn as_str(&self) -> &str {
148        match self {
149            Self::Host => "host",
150            Self::Other(s) => s.as_str(),
151        }
152    }
153}
154
155impl From<String> for SurfaceEntityType {
156    fn from(s: String) -> Self {
157        match s.as_str() {
158            "host" => Self::Host,
159            _ => Self::Other(s),
160        }
161    }
162}
163
164impl Serialize for SurfaceEntityType {
165    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
166        serializer.serialize_str(self.as_str())
167    }
168}
169
170impl<'de> Deserialize<'de> for SurfaceEntityType {
171    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
172        String::deserialize(deserializer).map(SurfaceEntityType::from)
173    }
174}
175
176/// Cell value for entity-link columns.
177///
178/// Plugins construct via [`SurfaceEntityRef::unresolved`] (`entity_id` only).
179/// The framework enriches `label` and `found` before sending the wire response.
180/// `found: None` is a transient pre-enrichment state — must not appear in the
181/// final wire response for cells whose resolver ran.
182#[non_exhaustive]
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct SurfaceEntityRef {
185    pub entity_id: uuid::Uuid,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub label: Option<String>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub found: Option<bool>,
190}
191
192impl SurfaceEntityRef {
193    /// Constructs an unresolved ref for use by plugin handlers.
194    /// The framework enriches `label` and `found` in the enrichment step.
195    pub fn unresolved(entity_id: uuid::Uuid) -> Self {
196        Self {
197            entity_id,
198            label: None,
199            found: None,
200        }
201    }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct SurfaceTableRowAction {
206    pub interaction_id: crate::InteractionId,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub visible_when: Option<SurfaceRowVisibleWhen>,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct SurfaceRowVisibleWhen {
213    pub field: String,
214    pub condition: SurfaceRowCondition,
215}
216
217#[non_exhaustive]
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum SurfaceRowCondition {
221    Present,
222    Absent,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct SurfaceTab {
227    pub id: crate::SurfaceTabId,
228    pub label: String,
229    pub root: SurfaceNode,
230}
231
232#[non_exhaustive]
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum CalloutLevel {
236    Info,
237    Warning,
238    Danger,
239}
240
241#[non_exhaustive]
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct SurfaceDescriptor {
244    pub surface_id: SurfaceId,
245    pub label: String,
246    pub priority: i32,
247    pub slot: String,
248    pub scope: Scope,
249    pub targeting: Targeting,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub required_permission: Option<String>,
252    pub provider_kind: ProviderKind,
253    pub required_capabilities: CapabilitySet,
254    pub root_node: SurfaceNode,
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub context_selector: Option<SurfaceContextSelectorDescriptor>,
257}
258
259impl SurfaceDescriptor {
260    /// Returns a zero-arg [`SurfaceDescriptorBuilder`] for constructing a [`SurfaceDescriptor`].
261    ///
262    /// # Example
263    ///
264    /// ```rust
265    /// use uptrakit_surfaces::{
266    ///     CapabilitySet, ProviderKind, Scope, SurfaceDescriptor, SurfaceId, SurfaceNode, Targeting,
267    /// };
268    ///
269    /// let descriptor = SurfaceDescriptor::builder()
270    ///     .surface_id(SurfaceId::new("provider.sample.surface").unwrap())
271    ///     .label("Sample")
272    ///     .priority(200)
273    ///     .slot("surface.page")
274    ///     .scope(Scope::Tenant)
275    ///     .targeting(Targeting::Universal)
276    ///     .provider_kind(ProviderKind::Plugin)
277    ///     .required_capabilities(CapabilitySet::default())
278    ///     .root_node(SurfaceNode::Section { title: None, children: vec![] })
279    ///     .build();
280    /// ```
281    #[must_use]
282    pub fn builder() -> SurfaceDescriptorBuilder {
283        SurfaceDescriptorBuilder::default()
284    }
285}
286
287/// Builder for [`SurfaceDescriptor`].
288///
289/// Obtain an instance via [`SurfaceDescriptor::builder`] and call [`build`](Self::build) to
290/// finalise the descriptor. Optional fields ([`required_permission`](Self::required_permission)
291/// and [`context_selector`](Self::context_selector)) default to `None`.
292///
293/// [`build`](Self::build) panics if any required field has not been set.
294#[derive(Debug, Clone, Default)]
295pub struct SurfaceDescriptorBuilder {
296    surface_id: Option<SurfaceId>,
297    label: Option<String>,
298    priority: Option<i32>,
299    slot: Option<String>,
300    scope: Option<Scope>,
301    targeting: Option<Targeting>,
302    required_permission: Option<String>,
303    provider_kind: Option<ProviderKind>,
304    required_capabilities: Option<CapabilitySet>,
305    root_node: Option<SurfaceNode>,
306    context_selector: Option<SurfaceContextSelectorDescriptor>,
307}
308
309impl SurfaceDescriptorBuilder {
310    /// Sets the surface identifier.
311    #[must_use]
312    pub fn surface_id(mut self, surface_id: SurfaceId) -> Self {
313        self.surface_id = Some(surface_id);
314        self
315    }
316
317    /// Sets the human-readable label.
318    #[must_use]
319    pub fn label(mut self, label: impl Into<String>) -> Self {
320        self.label = Some(label.into());
321        self
322    }
323
324    /// Sets the display priority within the slot.
325    #[must_use]
326    pub fn priority(mut self, priority: i32) -> Self {
327        self.priority = Some(priority);
328        self
329    }
330
331    /// Sets the slot identifier (e.g. `"surface.page"`).
332    #[must_use]
333    pub fn slot(mut self, slot: impl Into<String>) -> Self {
334        self.slot = Some(slot.into());
335        self
336    }
337
338    /// Sets the scope (global or tenant).
339    #[must_use]
340    pub fn scope(mut self, scope: Scope) -> Self {
341        self.scope = Some(scope);
342        self
343    }
344
345    /// Sets the targeting mode.
346    #[must_use]
347    pub fn targeting(mut self, targeting: Targeting) -> Self {
348        self.targeting = Some(targeting);
349        self
350    }
351
352    /// Sets the permission string required to view this surface (optional).
353    #[must_use]
354    pub fn required_permission(mut self, permission: impl Into<String>) -> Self {
355        self.required_permission = Some(permission.into());
356        self
357    }
358
359    /// Sets the provider kind.
360    #[must_use]
361    pub fn provider_kind(mut self, provider_kind: ProviderKind) -> Self {
362        self.provider_kind = Some(provider_kind);
363        self
364    }
365
366    /// Sets the set of capabilities this surface requires from the framework.
367    #[must_use]
368    pub fn required_capabilities(mut self, required_capabilities: CapabilitySet) -> Self {
369        self.required_capabilities = Some(required_capabilities);
370        self
371    }
372
373    /// Sets the root [`SurfaceNode`] of the surface layout.
374    #[must_use]
375    pub fn root_node(mut self, root_node: SurfaceNode) -> Self {
376        self.root_node = Some(root_node);
377        self
378    }
379
380    /// Attaches a context-selector dropdown descriptor to the surface (optional).
381    #[must_use]
382    pub fn context_selector(mut self, context_selector: SurfaceContextSelectorDescriptor) -> Self {
383        self.context_selector = Some(context_selector);
384        self
385    }
386
387    /// Consumes the builder and returns the [`SurfaceDescriptor`].
388    ///
389    /// # Panics
390    ///
391    /// Panics if any required field (`surface_id`, `label`, `priority`, `slot`, `scope`,
392    /// `targeting`, `provider_kind`, `required_capabilities`, `root_node`) has not been set.
393    #[must_use]
394    pub fn build(self) -> SurfaceDescriptor {
395        SurfaceDescriptor {
396            surface_id: self
397                .surface_id
398                .expect("SurfaceDescriptorBuilder: surface_id not set"),
399            label: self.label.expect("SurfaceDescriptorBuilder: label not set"),
400            priority: self
401                .priority
402                .expect("SurfaceDescriptorBuilder: priority not set"),
403            slot: self.slot.expect("SurfaceDescriptorBuilder: slot not set"),
404            scope: self.scope.expect("SurfaceDescriptorBuilder: scope not set"),
405            targeting: self
406                .targeting
407                .expect("SurfaceDescriptorBuilder: targeting not set"),
408            required_permission: self.required_permission,
409            provider_kind: self
410                .provider_kind
411                .expect("SurfaceDescriptorBuilder: provider_kind not set"),
412            required_capabilities: self
413                .required_capabilities
414                .expect("SurfaceDescriptorBuilder: required_capabilities not set"),
415            root_node: self
416                .root_node
417                .expect("SurfaceDescriptorBuilder: root_node not set"),
418            context_selector: self.context_selector,
419        }
420    }
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
424pub struct FrameworkGeneration {
425    pub major: u16,
426    pub minor: u16,
427}
428
429impl FrameworkGeneration {
430    pub const fn new(major: u16, minor: u16) -> Self {
431        Self { major, minor }
432    }
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct FrameworkGenerationRange {
437    pub min: FrameworkGeneration,
438    pub max: FrameworkGeneration,
439}
440
441impl FrameworkGenerationRange {
442    #[must_use]
443    pub const fn includes(&self, value: FrameworkGeneration) -> bool {
444        is_generation_le(self.min, value) && is_generation_le(value, self.max)
445    }
446}
447
448const fn is_generation_le(left: FrameworkGeneration, right: FrameworkGeneration) -> bool {
449    left.major < right.major || (left.major == right.major && left.minor <= right.minor)
450}
451
452#[non_exhaustive]
453#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
454#[serde(rename_all = "snake_case")]
455pub enum Capability {
456    SectionNode,
457    TextBlockNode,
458    KeyValueNode,
459    TableNode,
460    FormNode,
461    ActionBarNode,
462    TabsNode,
463    CalloutNode,
464    EmptyStateNode,
465    ModalTriggerNode,
466    WorkflowTriggerNode,
467    MutationAction,
468    FormSubmit,
469    Workflow,
470    Navigate,
471    DataLoad,
472    ConfirmableAction,
473    StaticDataSource,
474    ControllerQueryDataSource,
475    ProviderQueryDataSource,
476    UniversalTargeting,
477    TargetedTargeting,
478    SensitiveFields,
479    ProviderInitiatedActions,
480    ContextSelector,
481    EntityLinkColumn,
482}
483
484#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
485#[serde(transparent)]
486pub struct CapabilitySet(pub std::collections::BTreeSet<Capability>);
487
488impl CapabilitySet {
489    #[must_use]
490    pub fn from_capabilities(caps: impl IntoIterator<Item = Capability>) -> Self {
491        Self(caps.into_iter().collect())
492    }
493
494    #[must_use]
495    pub fn contains_all(&self, other: &Self) -> bool {
496        other.0.iter().all(|cap| self.0.contains(cap))
497    }
498}
499
500/// Describes a context-selector dropdown rendered above a surface's content.
501///
502/// When present on a `SurfaceDescriptor`, `SurfaceReadPanel` fetches the
503/// options from `rest_api_path` and renders a `ProviderSelector` above the
504/// surface content. The selected value is merged into `baseParams` under
505/// `param_key`, driving both the table data load and optional interaction gates.
506#[non_exhaustive]
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
508pub struct SurfaceContextSelectorDescriptor {
509    /// Param key injected into `baseParams` when a specific option is selected.
510    pub param_key: String,
511    /// Label shown above the selector dropdown.
512    pub label: String,
513    /// Label for the "show all" option (no param injected).
514    pub all_option_label: String,
515    /// REST API path returning a JSON array or paginated `items` list.
516    pub rest_api_path: String,
517    /// Field in each item used as the option value.
518    pub value_field: String,
519    /// Field in each item used as the option label.
520    pub label_field: String,
521    /// Interaction IDs disabled (with tooltip) when no specific option is selected.
522    #[serde(default, skip_serializing_if = "Vec::is_empty")]
523    pub required_for_interactions: Vec<crate::InteractionId>,
524}
525
526impl SurfaceContextSelectorDescriptor {
527    /// Constructs a new [`SurfaceContextSelectorDescriptor`].
528    ///
529    /// Required because the struct is `#[non_exhaustive]` — external crates cannot use
530    /// struct literal syntax and must call this constructor instead.
531    #[must_use]
532    pub fn new(
533        param_key: impl Into<String>,
534        label: impl Into<String>,
535        all_option_label: impl Into<String>,
536        rest_api_path: impl Into<String>,
537        value_field: impl Into<String>,
538        label_field: impl Into<String>,
539        required_for_interactions: Vec<crate::InteractionId>,
540    ) -> Self {
541        Self {
542            param_key: param_key.into(),
543            label: label.into(),
544            all_option_label: all_option_label.into(),
545            rest_api_path: rest_api_path.into(),
546            value_field: value_field.into(),
547            label_field: label_field.into(),
548            required_for_interactions,
549        }
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn context_selector_capability_serializes_to_snake_case() {
559        let cap = Capability::ContextSelector;
560        let serialized = serde_json::to_string(&cap).expect("serialize");
561        assert_eq!(serialized, r#""context_selector""#);
562    }
563
564    #[test]
565    fn surface_descriptor_context_selector_round_trips() {
566        let descriptor = SurfaceDescriptor {
567            surface_id: SurfaceId::new("test.surface").unwrap(),
568            label: "Test".to_string(),
569            priority: 100,
570            slot: "surface.page".to_string(),
571            scope: Scope::Global,
572            targeting: Targeting::Universal,
573            required_permission: None,
574            provider_kind: ProviderKind::Plugin,
575            required_capabilities: CapabilitySet::from_capabilities([Capability::ContextSelector]),
576            root_node: SurfaceNode::Section {
577                title: None,
578                children: vec![],
579            },
580            context_selector: Some(SurfaceContextSelectorDescriptor {
581                param_key: "plugin_config_id".to_string(),
582                label: "Configuration".to_string(),
583                all_option_label: "All Configurations".to_string(),
584                rest_api_path: "/api/v1/plugin-configs".to_string(),
585                value_field: "id".to_string(),
586                label_field: "name".to_string(),
587                required_for_interactions: vec![crate::InteractionId::new("discover").unwrap()],
588            }),
589        };
590
591        let json = serde_json::to_string(&descriptor).expect("serialize");
592        let deserialized: SurfaceDescriptor = serde_json::from_str(&json).expect("deserialize");
593        assert_eq!(descriptor, deserialized);
594
595        let context_selector = deserialized.context_selector.unwrap();
596        assert_eq!(context_selector.param_key, "plugin_config_id");
597        assert_eq!(
598            context_selector.required_for_interactions,
599            vec![crate::InteractionId::new("discover").unwrap()]
600        );
601    }
602
603    #[test]
604    fn surface_descriptor_without_context_selector_omits_field_in_json() {
605        let descriptor = SurfaceDescriptor {
606            surface_id: SurfaceId::new("test.surface").unwrap(),
607            label: "Test".to_string(),
608            priority: 100,
609            slot: "surface.page".to_string(),
610            scope: Scope::Global,
611            targeting: Targeting::Universal,
612            required_permission: None,
613            provider_kind: ProviderKind::Plugin,
614            required_capabilities: CapabilitySet::default(),
615            root_node: SurfaceNode::Section {
616                title: None,
617                children: vec![],
618            },
619            context_selector: None,
620        };
621
622        let json = serde_json::to_string(&descriptor).expect("serialize");
623        assert!(
624            !json.contains("context_selector"),
625            "absent context_selector must be omitted from JSON"
626        );
627    }
628
629    #[test]
630    fn surface_table_cell_type_entity_link_serializes_correctly() {
631        let mut col = SurfaceTableColumn::new("host", "Host");
632        col.cell_type = Some(SurfaceTableCellType::EntityLink {
633            entity_type: SurfaceEntityType::Host,
634        });
635        let json = serde_json::to_string(&col).expect("serialize");
636        let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
637        assert_eq!(parsed["cell_type"]["kind"], "entity_link");
638        assert_eq!(parsed["cell_type"]["entity_type"], "host");
639    }
640
641    #[test]
642    fn surface_table_column_without_cell_type_omits_field() {
643        let col = SurfaceTableColumn::new("name", "Name");
644        let json = serde_json::to_string(&col).expect("serialize");
645        assert!(!json.contains("cell_type"));
646    }
647
648    #[test]
649    fn unknown_cell_type_deserializes_to_none() {
650        let json =
651            r#"{"key":"host","label":"Host","cell_type":{"kind":"future_type","extra":"data"}}"#;
652        let col: SurfaceTableColumn = serde_json::from_str(json).expect("deserialize");
653        assert!(col.cell_type.is_none());
654    }
655
656    #[test]
657    fn surface_entity_type_host_serializes_to_bare_string() {
658        let t = SurfaceEntityType::Host;
659        let s = serde_json::to_string(&t).expect("serialize");
660        assert_eq!(s, r#""host""#);
661    }
662
663    #[test]
664    fn surface_entity_type_other_serializes_to_bare_string() {
665        let t = SurfaceEntityType::Other("my_future_type".to_string());
666        let s = serde_json::to_string(&t).expect("serialize");
667        assert_eq!(s, r#""my_future_type""#);
668    }
669
670    #[test]
671    fn surface_entity_type_unknown_string_deserializes_to_other() {
672        let t: SurfaceEntityType = serde_json::from_str(r#""unknown_type""#).expect("deserialize");
673        assert_eq!(t, SurfaceEntityType::Other("unknown_type".to_string()));
674    }
675
676    #[test]
677    fn surface_entity_ref_unresolved_serializes_without_label_or_found() {
678        let entity_id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
679        let r = SurfaceEntityRef::unresolved(entity_id);
680        let json = serde_json::to_string(&r).expect("serialize");
681        let val: serde_json::Value = serde_json::from_str(&json).expect("parse");
682        assert_eq!(val["entity_id"], entity_id.to_string());
683        assert!(val.get("label").is_none());
684        assert!(val.get("found").is_none());
685    }
686
687    #[test]
688    fn entity_link_column_capability_serializes_to_snake_case() {
689        let cap = Capability::EntityLinkColumn;
690        let s = serde_json::to_string(&cap).expect("serialize");
691        assert_eq!(s, r#""entity_link_column""#);
692    }
693}