1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3use uptrakit_shared_macros::wire_safe_enum;
4
5use crate::{FormUiDescriptor, InteractionId, ParamFieldDescriptor, ProviderKind, SchemaContract};
6
7pub const MIN_INTERACTION_TIMEOUT_SECONDS: u16 = 1;
8pub const MAX_INTERACTION_TIMEOUT_SECONDS: u16 = 300;
9
10#[non_exhaustive]
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum InteractionKind {
14 MutationAction,
15 FormSubmit,
16 Workflow,
17 Navigate,
18 DataLoad,
19 ConfirmableAction,
20}
21
22#[non_exhaustive]
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case", tag = "mode")]
25pub enum InteractionTransport {
26 ControllerLocal,
27 ProviderProxied,
28}
29
30wire_safe_enum! {
31 #[derive(Debug, Clone, PartialEq, Eq, Default)]
33 pub enum InteractionHttpMethod {
34 Get => "get",
35 #[default]
39 Post => "post",
40 Put => "put",
41 Delete => "delete",
42 }
43 parse_error = ParseInteractionHttpMethodError("invalid interaction http method");
44}
45
46pub const KNOWN_INTERACTION_HTTP_METHODS: &[InteractionHttpMethod] = &[
49 InteractionHttpMethod::Get,
50 InteractionHttpMethod::Post,
51 InteractionHttpMethod::Put,
52 InteractionHttpMethod::Delete,
53];
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct WorkflowStepDescriptor {
57 pub step_id: String,
58 pub label: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub form_ui: Option<FormUiDescriptor>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub submit_interaction_id: Option<InteractionId>,
63 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
64 pub render_previous_response: bool,
65 pub input_schema: SchemaContract,
66 pub result_schema: SchemaContract,
67}
68
69#[non_exhaustive]
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct InteractionDescriptor {
72 pub interaction_id: InteractionId,
73 pub kind: InteractionKind,
74 #[serde(default)]
78 pub http_method: InteractionHttpMethod,
79 pub label: String,
80 #[serde(
83 default,
84 alias = "required_permission",
85 skip_serializing_if = "Option::is_none"
86 )]
87 pub required_action: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub input_schema: Option<SchemaContract>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub result_schema: Option<SchemaContract>,
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub sensitive_fields: Vec<String>,
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub params: Vec<ParamFieldDescriptor>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub timeout_seconds: Option<u16>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub confirmation: Option<InteractionConfirmation>,
101 pub transport: InteractionTransport,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub workflow_steps: Vec<WorkflowStepDescriptor>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub form_ui: Option<FormUiDescriptor>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub icon: Option<String>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub submit_label: Option<String>,
110 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
119 pub provider_invocable: bool,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct InteractionConfirmation {
124 pub title: String,
125 pub message: String,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub confirm_label: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub cancel_label: Option<String>,
130 pub severity: ConfirmationSeverity,
131}
132
133#[non_exhaustive]
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum ConfirmationSeverity {
137 Info,
138 Warning,
139 Danger,
140}
141
142#[non_exhaustive]
143#[derive(Debug, Clone, PartialEq, Eq, Error)]
144pub enum InteractionValidationError {
145 #[error(
146 "interaction `{interaction_id}` timeout must be between {MIN_INTERACTION_TIMEOUT_SECONDS} and {MAX_INTERACTION_TIMEOUT_SECONDS} seconds"
147 )]
148 TimeoutOutOfRange { interaction_id: InteractionId },
149 #[error("workflow interaction `{interaction_id}` must declare at least one workflow step")]
150 WorkflowMissingSteps { interaction_id: InteractionId },
151 #[error("confirmable interaction `{interaction_id}` must include confirmation metadata")]
152 ConfirmableActionMissingConfirmation { interaction_id: InteractionId },
153 #[error("interaction `{interaction_id}` must include a non-empty human-authored label")]
154 BlankLabel { interaction_id: InteractionId },
155 #[error(
156 "workflow step `{step_id}` in interaction `{interaction_id}` must include a non-empty human-authored label"
157 )]
158 BlankWorkflowStepLabel {
159 interaction_id: InteractionId,
160 step_id: String,
161 },
162 #[error("interaction `{interaction_id}` has invalid icon: {reason}")]
163 IconInvalid {
164 interaction_id: InteractionId,
165 reason: crate::IconNameError,
166 },
167 #[error("interaction `{interaction_id}` has invalid submit_label: {reason}")]
168 SubmitLabelInvalid {
169 interaction_id: InteractionId,
170 reason: String,
171 },
172 #[error(
173 "interaction `{interaction_id}` sets provider_invocable with a required_action — not allowed for service-registered surfaces"
174 )]
175 ProviderInvocableForbiddenForServiceProviders { interaction_id: InteractionId },
176}
177
178impl InteractionDescriptor {
179 pub fn new(
181 interaction_id: InteractionId,
182 kind: InteractionKind,
183 label: impl Into<String>,
184 transport: InteractionTransport,
185 ) -> Self {
186 Self {
187 interaction_id,
188 kind,
189 http_method: InteractionHttpMethod::default(),
190 label: label.into(),
191 transport,
192 required_action: None,
193 input_schema: None,
194 result_schema: None,
195 sensitive_fields: vec![],
196 params: Vec::new(),
197 timeout_seconds: None,
198 confirmation: None,
199 workflow_steps: vec![],
200 form_ui: None,
201 icon: None,
202 submit_label: None,
203 provider_invocable: false,
204 }
205 }
206
207 #[must_use]
209 pub fn with_http_method(mut self, http_method: InteractionHttpMethod) -> Self {
210 self.http_method = http_method;
211 self
212 }
213
214 #[must_use]
216 pub fn with_params(mut self, params: Vec<ParamFieldDescriptor>) -> Self {
217 self.params = params;
218 self
219 }
220
221 pub fn effective_http_method(&self) -> InteractionHttpMethod {
224 if self.kind == InteractionKind::DataLoad {
225 InteractionHttpMethod::Get
226 } else {
227 self.http_method.clone()
228 }
229 }
230
231 pub fn validate_for_provider(
257 &self,
258 provider_kind: ProviderKind,
259 ) -> Result<(), InteractionValidationError> {
260 if self.provider_invocable
261 && self.required_action.is_some()
262 && provider_kind == ProviderKind::Service
263 {
264 return Err(
265 InteractionValidationError::ProviderInvocableForbiddenForServiceProviders {
266 interaction_id: self.interaction_id.clone(),
267 },
268 );
269 }
270
271 if let Some(timeout_seconds) = self.timeout_seconds
272 && !(MIN_INTERACTION_TIMEOUT_SECONDS..=MAX_INTERACTION_TIMEOUT_SECONDS)
273 .contains(&timeout_seconds)
274 {
275 return Err(InteractionValidationError::TimeoutOutOfRange {
276 interaction_id: self.interaction_id.clone(),
277 });
278 }
279
280 if self.kind == InteractionKind::Workflow && self.workflow_steps.is_empty() {
281 return Err(InteractionValidationError::WorkflowMissingSteps {
282 interaction_id: self.interaction_id.clone(),
283 });
284 }
285
286 if self.kind == InteractionKind::ConfirmableAction && self.confirmation.is_none() {
287 return Err(
288 InteractionValidationError::ConfirmableActionMissingConfirmation {
289 interaction_id: self.interaction_id.clone(),
290 },
291 );
292 }
293
294 if self.label.trim().is_empty() {
295 return Err(InteractionValidationError::BlankLabel {
296 interaction_id: self.interaction_id.clone(),
297 });
298 }
299
300 for step in &self.workflow_steps {
301 if step.label.trim().is_empty() {
302 return Err(InteractionValidationError::BlankWorkflowStepLabel {
303 interaction_id: self.interaction_id.clone(),
304 step_id: step.step_id.clone(),
305 });
306 }
307 }
308
309 if let Some(icon) = &self.icon {
310 crate::validate_icon_name(icon).map_err(|reason| {
311 InteractionValidationError::IconInvalid {
312 interaction_id: self.interaction_id.clone(),
313 reason,
314 }
315 })?;
316 }
317
318 if let Some(submit_label) = &self.submit_label {
319 if submit_label.trim().is_empty() {
320 return Err(InteractionValidationError::SubmitLabelInvalid {
321 interaction_id: self.interaction_id.clone(),
322 reason: "must not be empty".to_string(),
323 });
324 }
325 if submit_label.len() > 50 {
326 return Err(InteractionValidationError::SubmitLabelInvalid {
327 interaction_id: self.interaction_id.clone(),
328 reason: format!("exceeds max 50 characters ({} given)", submit_label.len()),
329 });
330 }
331 }
332
333 Ok(())
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn validate_for_provider_accepts_kebab_icon() {
343 let descriptor = InteractionDescriptor {
344 icon: Some("trash-2".to_string()),
345 ..InteractionDescriptor::new(
346 InteractionId::new("act").unwrap(),
347 InteractionKind::MutationAction,
348 "Action",
349 InteractionTransport::ControllerLocal,
350 )
351 };
352 descriptor
353 .validate_for_provider(ProviderKind::Plugin)
354 .unwrap();
355 }
356
357 #[test]
358 fn validate_for_provider_rejects_pascal_icon() {
359 let mut descriptor = InteractionDescriptor {
360 icon: Some("Trash2".to_string()),
361 ..InteractionDescriptor::new(
362 InteractionId::new("act").unwrap(),
363 InteractionKind::MutationAction,
364 "Action",
365 InteractionTransport::ControllerLocal,
366 )
367 };
368 let err = descriptor
369 .validate_for_provider(ProviderKind::Plugin)
370 .unwrap_err();
371 assert!(matches!(
372 err,
373 InteractionValidationError::IconInvalid { .. }
374 ));
375
376 descriptor.icon = Some(String::new());
377 let err = descriptor
378 .validate_for_provider(ProviderKind::Plugin)
379 .unwrap_err();
380 assert!(matches!(
381 err,
382 InteractionValidationError::IconInvalid { .. }
383 ));
384 }
385
386 #[test]
387 fn validate_for_provider_accepts_missing_icon() {
388 let descriptor = InteractionDescriptor::new(
389 InteractionId::new("act").unwrap(),
390 InteractionKind::MutationAction,
391 "Action",
392 InteractionTransport::ControllerLocal,
393 );
394 descriptor
395 .validate_for_provider(ProviderKind::Plugin)
396 .unwrap();
397 }
398
399 #[test]
400 fn validate_for_provider_rejects_empty_submit_label() {
401 let descriptor = InteractionDescriptor {
402 submit_label: Some(" ".to_string()),
403 ..InteractionDescriptor::new(
404 InteractionId::new("act").unwrap(),
405 InteractionKind::FormSubmit,
406 "Save Settings",
407 InteractionTransport::ProviderProxied,
408 )
409 };
410 let err = descriptor
411 .validate_for_provider(ProviderKind::Plugin)
412 .unwrap_err();
413 assert!(matches!(
414 err,
415 InteractionValidationError::SubmitLabelInvalid { .. }
416 ));
417 }
418
419 #[test]
420 fn validate_for_provider_rejects_submit_label_exceeding_50_chars() {
421 let descriptor = InteractionDescriptor {
422 submit_label: Some("a".repeat(51)),
423 ..InteractionDescriptor::new(
424 InteractionId::new("act").unwrap(),
425 InteractionKind::FormSubmit,
426 "Save",
427 InteractionTransport::ProviderProxied,
428 )
429 };
430 let err = descriptor
431 .validate_for_provider(ProviderKind::Plugin)
432 .unwrap_err();
433 assert!(matches!(
434 err,
435 InteractionValidationError::SubmitLabelInvalid { .. }
436 ));
437 }
438
439 #[test]
440 fn validate_for_provider_accepts_valid_submit_label() {
441 let descriptor = InteractionDescriptor {
442 submit_label: Some("Connect".to_string()),
443 ..InteractionDescriptor::new(
444 InteractionId::new("act").unwrap(),
445 InteractionKind::FormSubmit,
446 "Save",
447 InteractionTransport::ProviderProxied,
448 )
449 };
450 descriptor
451 .validate_for_provider(ProviderKind::Plugin)
452 .unwrap();
453 }
454
455 #[test]
456 fn provider_invocable_defaults_false_when_absent_on_wire() {
457 let json = serde_json::json!({
458 "interaction_id": "act",
459 "kind": "data_load",
460 "label": "Act",
461 "transport": { "mode": "controller_local" }
462 });
463 let descriptor: InteractionDescriptor =
464 serde_json::from_value(json).expect("deserialize without provider_invocable");
465 assert!(!descriptor.provider_invocable);
466 let value = serde_json::to_value(&descriptor).unwrap();
468 assert!(value.get("provider_invocable").is_none());
469 }
470
471 #[test]
472 fn validate_for_provider_rejects_provider_invocable_permissioned_service_interaction() {
473 let mut descriptor = InteractionDescriptor::new(
474 InteractionId::new("act").unwrap(),
475 InteractionKind::DataLoad,
476 "Act",
477 InteractionTransport::ProviderProxied,
478 );
479 descriptor.required_action = Some("update_hosts".to_string());
480 descriptor.provider_invocable = true;
481 let result = descriptor.validate_for_provider(ProviderKind::Service);
482 assert!(matches!(
483 result,
484 Err(InteractionValidationError::ProviderInvocableForbiddenForServiceProviders { .. })
485 ));
486 }
487
488 #[test]
489 fn http_method_defaults_to_post_when_absent_on_wire() {
490 let json = serde_json::json!({
492 "interaction_id": "save",
493 "kind": "mutation_action",
494 "label": "Save",
495 "transport": { "mode": "controller_local" }
496 });
497 let descriptor: InteractionDescriptor = serde_json::from_value(json).expect("deserialize");
498 assert_eq!(descriptor.http_method, InteractionHttpMethod::Post);
499 }
500
501 #[test]
502 fn effective_http_method_normalizes_dataload_to_get() {
503 let json = serde_json::json!({
504 "interaction_id": "list",
505 "kind": "data_load",
506 "label": "List",
507 "transport": { "mode": "provider_proxied" }
508 });
509 let descriptor: InteractionDescriptor = serde_json::from_value(json).expect("deserialize");
510 assert_eq!(descriptor.http_method, InteractionHttpMethod::Post); assert_eq!(
512 descriptor.effective_http_method(),
513 InteractionHttpMethod::Get
514 );
515 }
516
517 #[cfg(feature = "schema")]
518 mod schema_tests {
519 use super::*;
520
521 fn assert_open_string_schema<T: schemars::JsonSchema>(known: &[&str]) {
522 let schema = schemars::schema_for!(T);
523 let value = serde_json::to_value(&schema).expect("schema to JSON");
524 assert_eq!(value["type"], "string");
525 assert!(
526 value.get("enum").is_none(),
527 "must be an open string schema, found closed enum list: {value}"
528 );
529 let desc = value["description"].as_str().expect("description present");
530 for k in known {
531 assert!(
532 desc.contains(k),
533 "known value {k} missing from description: {desc}"
534 );
535 }
536 }
537
538 #[test]
541 fn interaction_http_method_schema_is_open_string_with_known_values() {
542 assert_open_string_schema::<InteractionHttpMethod>(&["get", "post"]);
543 }
544 }
545
546 #[test]
547 fn http_method_round_trips_wire_string() {
548 assert_eq!(
549 InteractionHttpMethod::from("put".to_string()),
550 InteractionHttpMethod::Put
551 );
552 assert_eq!(InteractionHttpMethod::Put.as_str(), "put");
553 assert!(matches!(
554 InteractionHttpMethod::from("patch".to_string()),
555 InteractionHttpMethod::Other(_)
556 ));
557 "patch".parse::<InteractionHttpMethod>().unwrap_err(); }
559
560 #[test]
561 fn validate_for_provider_accepts_provider_invocable_for_plugin_and_unpermissioned_service() {
562 let mut plugin_owned = InteractionDescriptor::new(
563 InteractionId::new("act").unwrap(),
564 InteractionKind::DataLoad,
565 "Act",
566 InteractionTransport::ControllerLocal,
567 );
568 plugin_owned.required_action = Some("update_hosts".to_string());
569 plugin_owned.provider_invocable = true;
570 assert!(matches!(
573 plugin_owned.validate_for_provider(ProviderKind::Plugin),
574 Ok(())
575 ));
576
577 let mut unpermissioned_service = InteractionDescriptor::new(
578 InteractionId::new("act2").unwrap(),
579 InteractionKind::DataLoad,
580 "Act2",
581 InteractionTransport::ProviderProxied,
582 );
583 unpermissioned_service.provider_invocable = true;
584 assert!(matches!(
585 unpermissioned_service.validate_for_provider(ProviderKind::Service),
586 Ok(())
587 ));
588 }
589
590 #[test]
591 fn validate_for_provider_accepts_provider_invocable_for_builtin() {
592 let mut builtin_owned = InteractionDescriptor::new(
593 InteractionId::new("act3").unwrap(),
594 InteractionKind::DataLoad,
595 "Act3",
596 InteractionTransport::ControllerLocal,
597 );
598 builtin_owned.required_action = Some("update_hosts".to_string());
599 builtin_owned.provider_invocable = true;
600 assert!(matches!(
604 builtin_owned.validate_for_provider(ProviderKind::BuiltIn),
605 Ok(())
606 ));
607 }
608
609 #[test]
610 fn required_action_accepts_legacy_key_via_alias() {
611 let json = serde_json::json!({
614 "interaction_id": "act",
615 "kind": "data_load",
616 "label": "Act",
617 "transport": { "mode": "controller_local" },
618 "required_permission": "update_hosts",
619 });
620 let descriptor: InteractionDescriptor =
621 serde_json::from_value(json).expect("alias must deserialize");
622 assert_eq!(descriptor.required_action.as_deref(), Some("update_hosts"));
623 }
624
625 #[test]
626 fn required_action_rejects_dual_key_payload() {
627 let json = r#"{
630 "interaction_id": "act",
631 "kind": "data_load",
632 "label": "Act",
633 "transport": { "mode": "controller_local" },
634 "required_action": "hosts:update",
635 "required_permission": "update_hosts"
636 }"#;
637 serde_json::from_str::<InteractionDescriptor>(json).expect_err("dual key must fail");
640 }
641
642 #[test]
643 fn required_action_serializes_under_the_new_key_only() {
644 let mut descriptor = InteractionDescriptor::new(
645 InteractionId::new("act").unwrap(),
646 InteractionKind::DataLoad,
647 "Act",
648 InteractionTransport::ControllerLocal,
649 );
650 descriptor.required_action = Some("hosts:update".to_string());
651
652 let value = serde_json::to_value(&descriptor).expect("serialize");
653 assert!(value.get("required_action").is_some());
654 assert!(value.get("required_permission").is_none());
655 }
656}