1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum ActionMode {
7 #[default]
8 Direct,
9 Prepare,
10 Commit,
11 Reject,
12}
13
14pub use conversation_api::execution::ResponseDetail;
15
16#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase", deny_unknown_fields)]
18pub struct FacadeControl {
19 pub mode: ActionMode,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub prepared_action_id: Option<String>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub approval_token: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub response_detail: Option<ResponseDetail>,
26}
27
28impl FacadeControl {
29 pub fn validate(&self) -> Result<(), &'static str> {
30 match self.mode {
31 ActionMode::Direct | ActionMode::Prepare => {
32 if self.prepared_action_id.is_some() || self.approval_token.is_some() {
33 return Err("direct and prepare controls cannot carry commit credentials");
34 }
35 }
36 ActionMode::Commit => {
37 if self
38 .prepared_action_id
39 .as_deref()
40 .is_none_or(|value| value.trim().is_empty())
41 || self
42 .approval_token
43 .as_deref()
44 .is_none_or(|value| value.trim().is_empty())
45 {
46 return Err("commit control requires preparedActionId and approvalToken");
47 }
48 }
49 ActionMode::Reject => {
50 if self
51 .prepared_action_id
52 .as_deref()
53 .is_none_or(|value| value.trim().is_empty())
54 {
55 return Err("reject control requires preparedActionId");
56 }
57 if self.approval_token.is_some() {
58 return Err("reject control cannot carry approvalToken");
59 }
60 }
61 }
62 Ok(())
63 }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase", deny_unknown_fields)]
68pub struct FacadeCall {
69 pub control: FacadeControl,
70 pub input: Value,
71}
72
73impl FacadeCall {
74 #[must_use]
75 pub fn direct(input: Value) -> Self {
76 Self {
77 control: FacadeControl::default(),
78 input,
79 }
80 }
81
82 pub fn validate(&self) -> Result<(), &'static str> {
83 self.control.validate()?;
84 if self.input.get("outputFormat").is_some() {
85 return Err("outputFormat is not business input; use control.responseDetail");
86 }
87 Ok(())
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn direct_call_has_a_strict_envelope() {
97 let call: FacadeCall = serde_json::from_value(serde_json::json!({
98 "control": {"mode": "direct"},
99 "input": {"value": 1}
100 }))
101 .unwrap();
102 assert_eq!(call.control.mode, ActionMode::Direct);
103 assert_eq!(call.input["value"], 1);
104 assert!(serde_json::from_value::<FacadeCall>(serde_json::json!({
105 "mode": "direct",
106 "input": {}
107 }))
108 .is_err());
109 }
110
111 #[test]
112 fn action_credentials_are_mode_specific() {
113 let invalid = FacadeControl {
114 mode: ActionMode::Commit,
115 prepared_action_id: Some("action-1".into()),
116 ..FacadeControl::default()
117 };
118 assert!(invalid.validate().is_err());
119
120 let whitespace = FacadeControl {
121 mode: ActionMode::Reject,
122 prepared_action_id: Some(" \t".into()),
123 ..FacadeControl::default()
124 };
125 assert!(whitespace.validate().is_err());
126 }
127}