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 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 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}