Skip to main content

uptrakit_surfaces/
interaction.rs

1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4use crate::{BuiltInApiOperationId, FormUiDescriptor, InteractionId, ProviderKind, SchemaContract};
5
6pub const MIN_INTERACTION_TIMEOUT_SECONDS: u16 = 1;
7pub const MAX_INTERACTION_TIMEOUT_SECONDS: u16 = 300;
8
9#[non_exhaustive]
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum InteractionKind {
13    MutationAction,
14    FormSubmit,
15    Workflow,
16    Navigate,
17    DataLoad,
18    ConfirmableAction,
19}
20
21#[non_exhaustive]
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case", tag = "mode")]
24pub enum InteractionTransport {
25    ControllerLocal,
26    ProviderProxied,
27    DirectBuiltInApi { operation_id: BuiltInApiOperationId },
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct WorkflowStepDescriptor {
32    pub step_id: String,
33    pub label: String,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub form_ui: Option<FormUiDescriptor>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub submit_interaction_id: Option<InteractionId>,
38    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
39    pub render_previous_response: bool,
40    pub input_schema: SchemaContract,
41    pub result_schema: SchemaContract,
42}
43
44#[non_exhaustive]
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct InteractionDescriptor {
47    pub interaction_id: InteractionId,
48    pub kind: InteractionKind,
49    pub label: String,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub required_permission: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub input_schema: Option<SchemaContract>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub result_schema: Option<SchemaContract>,
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub sensitive_fields: Vec<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub timeout_seconds: Option<u16>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub confirmation: Option<InteractionConfirmation>,
62    pub transport: InteractionTransport,
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub workflow_steps: Vec<WorkflowStepDescriptor>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub form_ui: Option<FormUiDescriptor>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub icon: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub submit_label: Option<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct InteractionConfirmation {
75    pub title: String,
76    pub message: String,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub confirm_label: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub cancel_label: Option<String>,
81    pub severity: ConfirmationSeverity,
82}
83
84#[non_exhaustive]
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum ConfirmationSeverity {
88    Info,
89    Warning,
90    Danger,
91}
92
93#[non_exhaustive]
94#[derive(Debug, Clone, PartialEq, Eq, Error)]
95pub enum InteractionValidationError {
96    #[error(
97        "provider-authored interactions cannot use direct built-in API transport (interaction `{interaction_id}`)"
98    )]
99    DirectBuiltInApiForbiddenForProvider { interaction_id: InteractionId },
100    #[error(
101        "interaction `{interaction_id}` timeout must be between {MIN_INTERACTION_TIMEOUT_SECONDS} and {MAX_INTERACTION_TIMEOUT_SECONDS} seconds"
102    )]
103    TimeoutOutOfRange { interaction_id: InteractionId },
104    #[error("workflow interaction `{interaction_id}` must declare at least one workflow step")]
105    WorkflowMissingSteps { interaction_id: InteractionId },
106    #[error("confirmable interaction `{interaction_id}` must include confirmation metadata")]
107    ConfirmableActionMissingConfirmation { interaction_id: InteractionId },
108    #[error("interaction `{interaction_id}` must include a non-empty human-authored label")]
109    BlankLabel { interaction_id: InteractionId },
110    #[error(
111        "workflow step `{step_id}` in interaction `{interaction_id}` must include a non-empty human-authored label"
112    )]
113    BlankWorkflowStepLabel {
114        interaction_id: InteractionId,
115        step_id: String,
116    },
117    #[error("interaction `{interaction_id}` has invalid icon: {reason}")]
118    IconInvalid {
119        interaction_id: InteractionId,
120        reason: crate::IconNameError,
121    },
122    #[error("interaction `{interaction_id}` has invalid submit_label: {reason}")]
123    SubmitLabelInvalid {
124        interaction_id: InteractionId,
125        reason: String,
126    },
127}
128
129impl InteractionDescriptor {
130    /// Creates a new `InteractionDescriptor` with all optional fields set to their defaults.
131    pub fn new(
132        interaction_id: InteractionId,
133        kind: InteractionKind,
134        label: impl Into<String>,
135        transport: InteractionTransport,
136    ) -> Self {
137        Self {
138            interaction_id,
139            kind,
140            label: label.into(),
141            transport,
142            required_permission: None,
143            input_schema: None,
144            result_schema: None,
145            sensitive_fields: vec![],
146            timeout_seconds: None,
147            confirmation: None,
148            workflow_steps: vec![],
149            form_ui: None,
150            icon: None,
151            submit_label: None,
152        }
153    }
154
155    /// Validates provider-specific interaction contract rules.
156    ///
157    /// # Errors
158    /// Returns
159    /// [`InteractionValidationError::DirectBuiltInApiForbiddenForProvider`]
160    /// when a non-built-in provider uses `direct_built_in_api` transport.
161    /// Returns [`InteractionValidationError::TimeoutOutOfRange`] when
162    /// `timeout_seconds` falls outside
163    /// [`MIN_INTERACTION_TIMEOUT_SECONDS`]..=[`MAX_INTERACTION_TIMEOUT_SECONDS`].
164    /// Returns [`InteractionValidationError::WorkflowMissingSteps`] when
165    /// a workflow interaction declares no steps.
166    /// Returns
167    /// [`InteractionValidationError::ConfirmableActionMissingConfirmation`]
168    /// when a confirmable interaction omits confirmation metadata.
169    /// Returns [`InteractionValidationError::BlankLabel`] when `label` is
170    /// empty or whitespace-only.
171    /// Returns [`InteractionValidationError::BlankWorkflowStepLabel`] when
172    /// any workflow step has an empty or whitespace-only label.
173    /// Returns [`InteractionValidationError::IconInvalid`] when `icon` is
174    /// `Some` but fails kebab-case validation.
175    pub fn validate_for_provider(
176        &self,
177        provider_kind: ProviderKind,
178    ) -> Result<(), InteractionValidationError> {
179        if provider_kind != ProviderKind::BuiltIn
180            && matches!(
181                self.transport,
182                InteractionTransport::DirectBuiltInApi { .. }
183            )
184        {
185            return Err(
186                InteractionValidationError::DirectBuiltInApiForbiddenForProvider {
187                    interaction_id: self.interaction_id.clone(),
188                },
189            );
190        }
191
192        if let Some(timeout_seconds) = self.timeout_seconds
193            && !(MIN_INTERACTION_TIMEOUT_SECONDS..=MAX_INTERACTION_TIMEOUT_SECONDS)
194                .contains(&timeout_seconds)
195        {
196            return Err(InteractionValidationError::TimeoutOutOfRange {
197                interaction_id: self.interaction_id.clone(),
198            });
199        }
200
201        if self.kind == InteractionKind::Workflow && self.workflow_steps.is_empty() {
202            return Err(InteractionValidationError::WorkflowMissingSteps {
203                interaction_id: self.interaction_id.clone(),
204            });
205        }
206
207        if self.kind == InteractionKind::ConfirmableAction && self.confirmation.is_none() {
208            return Err(
209                InteractionValidationError::ConfirmableActionMissingConfirmation {
210                    interaction_id: self.interaction_id.clone(),
211                },
212            );
213        }
214
215        if self.label.trim().is_empty() {
216            return Err(InteractionValidationError::BlankLabel {
217                interaction_id: self.interaction_id.clone(),
218            });
219        }
220
221        for step in &self.workflow_steps {
222            if step.label.trim().is_empty() {
223                return Err(InteractionValidationError::BlankWorkflowStepLabel {
224                    interaction_id: self.interaction_id.clone(),
225                    step_id: step.step_id.clone(),
226                });
227            }
228        }
229
230        if let Some(icon) = &self.icon {
231            crate::validate_icon_name(icon).map_err(|reason| {
232                InteractionValidationError::IconInvalid {
233                    interaction_id: self.interaction_id.clone(),
234                    reason,
235                }
236            })?;
237        }
238
239        if let Some(submit_label) = &self.submit_label {
240            if submit_label.trim().is_empty() {
241                return Err(InteractionValidationError::SubmitLabelInvalid {
242                    interaction_id: self.interaction_id.clone(),
243                    reason: "must not be empty".to_string(),
244                });
245            }
246            if submit_label.len() > 50 {
247                return Err(InteractionValidationError::SubmitLabelInvalid {
248                    interaction_id: self.interaction_id.clone(),
249                    reason: format!("exceeds max 50 characters ({} given)", submit_label.len()),
250                });
251            }
252        }
253
254        Ok(())
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn validate_for_provider_accepts_kebab_icon() {
264        let descriptor = InteractionDescriptor {
265            icon: Some("trash-2".to_string()),
266            ..InteractionDescriptor::new(
267                InteractionId::new("act").unwrap(),
268                InteractionKind::MutationAction,
269                "Action",
270                InteractionTransport::ControllerLocal,
271            )
272        };
273        descriptor
274            .validate_for_provider(ProviderKind::Plugin)
275            .unwrap();
276    }
277
278    #[test]
279    fn validate_for_provider_rejects_pascal_icon() {
280        let mut descriptor = InteractionDescriptor {
281            icon: Some("Trash2".to_string()),
282            ..InteractionDescriptor::new(
283                InteractionId::new("act").unwrap(),
284                InteractionKind::MutationAction,
285                "Action",
286                InteractionTransport::ControllerLocal,
287            )
288        };
289        let err = descriptor
290            .validate_for_provider(ProviderKind::Plugin)
291            .unwrap_err();
292        assert!(matches!(
293            err,
294            InteractionValidationError::IconInvalid { .. }
295        ));
296
297        descriptor.icon = Some(String::new());
298        let err = descriptor
299            .validate_for_provider(ProviderKind::Plugin)
300            .unwrap_err();
301        assert!(matches!(
302            err,
303            InteractionValidationError::IconInvalid { .. }
304        ));
305    }
306
307    #[test]
308    fn validate_for_provider_accepts_missing_icon() {
309        let descriptor = InteractionDescriptor::new(
310            InteractionId::new("act").unwrap(),
311            InteractionKind::MutationAction,
312            "Action",
313            InteractionTransport::ControllerLocal,
314        );
315        descriptor
316            .validate_for_provider(ProviderKind::Plugin)
317            .unwrap();
318    }
319
320    #[test]
321    fn validate_for_provider_rejects_empty_submit_label() {
322        let descriptor = InteractionDescriptor {
323            submit_label: Some("   ".to_string()),
324            ..InteractionDescriptor::new(
325                InteractionId::new("act").unwrap(),
326                InteractionKind::FormSubmit,
327                "Save Settings",
328                InteractionTransport::ProviderProxied,
329            )
330        };
331        let err = descriptor
332            .validate_for_provider(ProviderKind::Plugin)
333            .unwrap_err();
334        assert!(matches!(
335            err,
336            InteractionValidationError::SubmitLabelInvalid { .. }
337        ));
338    }
339
340    #[test]
341    fn validate_for_provider_rejects_submit_label_exceeding_50_chars() {
342        let descriptor = InteractionDescriptor {
343            submit_label: Some("a".repeat(51)),
344            ..InteractionDescriptor::new(
345                InteractionId::new("act").unwrap(),
346                InteractionKind::FormSubmit,
347                "Save",
348                InteractionTransport::ProviderProxied,
349            )
350        };
351        let err = descriptor
352            .validate_for_provider(ProviderKind::Plugin)
353            .unwrap_err();
354        assert!(matches!(
355            err,
356            InteractionValidationError::SubmitLabelInvalid { .. }
357        ));
358    }
359
360    #[test]
361    fn validate_for_provider_accepts_valid_submit_label() {
362        let descriptor = InteractionDescriptor {
363            submit_label: Some("Connect".to_string()),
364            ..InteractionDescriptor::new(
365                InteractionId::new("act").unwrap(),
366                InteractionKind::FormSubmit,
367                "Save",
368                InteractionTransport::ProviderProxied,
369            )
370        };
371        descriptor
372            .validate_for_provider(ProviderKind::Plugin)
373            .unwrap();
374    }
375}