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