1use crate::mode::Mode;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
5pub enum Decision {
6 Allow,
7 Ask,
8 Deny,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Verdict {
14 pub decision: Decision,
15 pub reason: String,
16 pub resolved_command: Option<String>,
20}
21
22impl Verdict {
23 #[must_use]
24 pub fn allow(reason: impl Into<String>) -> Self {
25 Self {
26 decision: Decision::Allow,
27 reason: reason.into(),
28 resolved_command: None,
29 }
30 }
31
32 #[must_use]
33 pub fn ask(reason: impl Into<String>) -> Self {
34 Self {
35 decision: Decision::Ask,
36 reason: reason.into(),
37 resolved_command: None,
38 }
39 }
40
41 #[must_use]
42 pub fn deny(reason: impl Into<String>) -> Self {
43 Self {
44 decision: Decision::Deny,
45 reason: reason.into(),
46 resolved_command: None,
47 }
48 }
49
50 #[must_use]
52 pub fn with_resolution(mut self, resolved: impl Into<String>) -> Self {
53 self.resolved_command = Some(resolved.into());
54 self
55 }
56
57 #[must_use]
64 pub fn combine(verdicts: &[Self]) -> Self {
65 let mut chosen = verdicts
66 .iter()
67 .max_by_key(|v| v.decision)
68 .cloned()
69 .unwrap_or_default();
70 if chosen.resolved_command.is_none() {
71 chosen.resolved_command = verdicts.iter().find_map(|v| v.resolved_command.clone());
72 }
73 chosen
74 }
75
76 #[must_use]
78 pub fn to_json(&self, mode: Mode) -> serde_json::Value {
79 match mode {
80 Mode::Claude => serde_json::json!({
81 "hookSpecificOutput": {
82 "permissionDecision": self.decision.as_str(),
83 "permissionDecisionReason": self.reason,
84 }
85 }),
86 Mode::Gemini | Mode::Codex => serde_json::json!({
87 "decision": self.decision.as_gemini_str(),
88 "reason": self.reason,
89 }),
90 Mode::Cursor => serde_json::json!({
91 "permission": self.decision.as_str(),
92 "userMessage": self.reason,
93 "agentMessage": self.reason,
94 }),
95 }
96 }
97}
98
99impl Default for Verdict {
100 fn default() -> Self {
101 Self {
102 decision: Decision::Allow,
103 reason: String::new(),
104 resolved_command: None,
105 }
106 }
107}
108
109impl Decision {
110 pub const fn as_str(self) -> &'static str {
111 match self {
112 Self::Allow => "allow",
113 Self::Ask => "ask",
114 Self::Deny => "deny",
115 }
116 }
117
118 const fn as_gemini_str(self) -> &'static str {
120 match self {
121 Self::Allow => "allow",
122 Self::Ask | Self::Deny => "deny",
123 }
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[allow(clippy::unwrap_used)]
132 #[test]
133 fn decision_ordering() {
134 assert!(Decision::Allow < Decision::Ask);
135 assert!(Decision::Ask < Decision::Deny);
136 assert!(Decision::Allow < Decision::Deny);
137 }
138
139 #[allow(clippy::unwrap_used)]
140 #[test]
141 fn combine_takes_most_restrictive() {
142 let verdicts = vec![
143 Verdict::allow("safe"),
144 Verdict::ask("needs review"),
145 Verdict::allow("also safe"),
146 ];
147 let combined = Verdict::combine(&verdicts);
148 assert_eq!(combined.decision, Decision::Ask);
149 assert_eq!(combined.reason, "needs review");
150 }
151
152 #[allow(clippy::unwrap_used)]
153 #[test]
154 fn combine_empty_defaults_to_allow() {
155 let combined = Verdict::combine(&[]);
156 assert_eq!(combined.decision, Decision::Allow);
157 }
158
159 #[allow(clippy::unwrap_used)]
160 #[test]
161 fn claude_json_format() {
162 let v = Verdict::allow("git status is safe");
163 let json = v.to_json(Mode::Claude);
164 assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "allow");
165 assert_eq!(
166 json["hookSpecificOutput"]["permissionDecisionReason"],
167 "git status is safe"
168 );
169 }
170
171 #[allow(clippy::unwrap_used)]
172 #[test]
173 fn gemini_ask_maps_to_deny() {
174 let v = Verdict::ask("needs review");
175 let json = v.to_json(Mode::Gemini);
176 assert_eq!(json["decision"], "deny");
177 }
178
179 #[allow(clippy::unwrap_used)]
180 #[test]
181 fn cursor_json_format() {
182 let v = Verdict::deny("dangerous");
183 let json = v.to_json(Mode::Cursor);
184 assert_eq!(json["permission"], "deny");
185 assert_eq!(json["userMessage"], "dangerous");
186 assert_eq!(json["agentMessage"], "dangerous");
187 }
188
189 #[test]
190 fn with_resolution_attaches_resolved_command() {
191 let v = Verdict::allow("ls is safe").with_resolution("ls /tmp");
192 assert_eq!(v.resolved_command.as_deref(), Some("ls /tmp"));
193 assert_eq!(v.decision, Decision::Allow);
194 }
195
196 #[test]
197 fn combine_preserves_resolved_command_from_chosen() {
198 let verdicts = vec![
199 Verdict::allow("safe"),
200 Verdict::ask("review").with_resolution("rm -rf /tmp"),
201 ];
202 let combined = Verdict::combine(&verdicts);
203 assert_eq!(combined.decision, Decision::Ask);
204 assert_eq!(combined.resolved_command.as_deref(), Some("rm -rf /tmp"));
205 }
206
207 #[test]
208 fn combine_borrows_resolved_command_from_other_when_chosen_has_none() {
209 let verdicts = vec![
210 Verdict::ask("review"),
211 Verdict::allow("safe").with_resolution("ls /tmp"),
212 ];
213 let combined = Verdict::combine(&verdicts);
214 assert_eq!(combined.decision, Decision::Ask);
215 assert_eq!(combined.resolved_command.as_deref(), Some("ls /tmp"));
216 }
217
218 #[test]
219 fn json_output_unchanged_when_resolved_present() {
220 let v = Verdict::allow("ls is safe").with_resolution("ls /tmp");
222 let json = v.to_json(Mode::Claude);
223 assert!(json.get("resolved_command").is_none());
224 assert!(json["hookSpecificOutput"].get("resolved_command").is_none());
225 }
226}