1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::process::ExitStatus;
7
8fn default_order() -> i32 {
10 100
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct Hook {
17 #[serde(default = "default_order")]
19 pub order: i32,
20 #[serde(default)]
22 pub propagate: bool,
23 pub command: String,
25 #[serde(default)]
27 pub args: Vec<String>,
28 #[serde(default)]
30 pub dir: Option<String>,
31 #[serde(default)]
33 pub inputs: Vec<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub source: Option<bool>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct HookResult {
42 pub hook: Hook,
44 pub success: bool,
46 pub exit_status: Option<i32>,
48 pub stdout: String,
50 pub stderr: String,
52 pub duration_ms: u64,
54 pub error: Option<String>,
56}
57
58impl HookResult {
59 #[must_use]
61 pub fn success(
62 hook: Hook,
63 exit_status: ExitStatus,
64 stdout: String,
65 stderr: String,
66 duration_ms: u64,
67 ) -> Self {
68 Self {
69 hook,
70 success: true,
71 exit_status: exit_status.code(),
72 stdout,
73 stderr,
74 duration_ms,
75 error: None,
76 }
77 }
78
79 #[allow(clippy::too_many_arguments)] #[must_use]
82 pub fn failure(
83 hook: Hook,
84 exit_status: Option<ExitStatus>,
85 stdout: String,
86 stderr: String,
87 duration_ms: u64,
88 error: String,
89 ) -> Self {
90 Self {
91 hook,
92 success: false,
93 exit_status: exit_status.and_then(|s| s.code()),
94 stdout,
95 stderr,
96 duration_ms,
97 error: Some(error),
98 }
99 }
100
101 #[must_use]
103 pub fn timeout(hook: Hook, stdout: String, stderr: String, timeout_seconds: u64) -> Self {
104 Self {
105 hook,
106 success: false,
107 exit_status: None,
108 stdout,
109 stderr,
110 duration_ms: timeout_seconds * 1000,
111 error: Some(format!(
112 "Command timed out after {} seconds",
113 timeout_seconds
114 )),
115 }
116 }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct HookExecutionConfig {
122 pub default_timeout_seconds: u64,
124 pub fail_fast: bool,
126 pub state_dir: Option<PathBuf>,
128}
129
130impl Default for HookExecutionConfig {
131 fn default() -> Self {
132 Self {
133 default_timeout_seconds: 300, fail_fast: true,
135 state_dir: None, }
137 }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub enum ExecutionStatus {
143 Running,
145 Completed,
147 Failed,
149 Cancelled,
151}
152
153impl std::fmt::Display for ExecutionStatus {
154 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 match self {
156 Self::Running => write!(f, "Running"),
157 Self::Completed => write!(f, "Completed"),
158 Self::Failed => write!(f, "Failed"),
159 Self::Cancelled => write!(f, "Cancelled"),
160 }
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
166pub struct Hooks {
167 #[serde(skip_serializing_if = "Option::is_none")]
169 #[serde(rename = "onEnter")]
170 pub on_enter: Option<HashMap<String, Hook>>,
171
172 #[serde(skip_serializing_if = "Option::is_none")]
174 #[serde(rename = "onExit")]
175 pub on_exit: Option<HashMap<String, Hook>>,
176
177 #[serde(skip_serializing_if = "Option::is_none")]
179 #[serde(rename = "prePush")]
180 pub pre_push: Option<HashMap<String, Hook>>,
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn test_hook_serialization() {
189 let hook = Hook {
190 order: 50,
191 propagate: false,
192 command: "npm".to_string(),
193 args: vec!["install".to_string()],
194 dir: Some("/tmp".to_string()),
195 inputs: vec![],
196 source: Some(false),
197 };
198
199 let json = serde_json::to_string(&hook).unwrap();
200 let deserialized: Hook = serde_json::from_str(&json).unwrap();
201
202 assert_eq!(hook, deserialized);
203 }
204
205 #[test]
206 fn test_hook_defaults() {
207 let json = r#"{"command": "echo", "args": ["hello"]}"#;
208 let hook: Hook = serde_json::from_str(json).unwrap();
209
210 assert_eq!(hook.order, 100); assert_eq!(hook.command, "echo");
212 assert_eq!(hook.args, vec!["hello"]);
213 assert_eq!(hook.dir, None);
214 assert!(hook.inputs.is_empty());
215 assert_eq!(hook.source, None); }
217
218 #[test]
219 fn test_hook_result_success() {
220 let hook = Hook {
221 order: 100,
222 propagate: false,
223 command: "echo".to_string(),
224 args: vec!["test".to_string()],
225 dir: None,
226 inputs: vec![],
227 source: None,
228 };
229
230 let exit_status = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
232 .args(if cfg!(windows) {
233 vec!["/C", "exit 0"]
234 } else {
235 vec![]
236 })
237 .output()
238 .unwrap()
239 .status;
240
241 let result = HookResult::success(
242 hook.clone(),
243 exit_status,
244 "test\n".to_string(),
245 String::new(),
246 100,
247 );
248
249 assert!(result.success);
250 assert_eq!(result.hook, hook);
251 assert_eq!(result.exit_status, Some(0));
252 assert_eq!(result.stdout, "test\n");
253 assert_eq!(result.stderr, "");
254 assert_eq!(result.duration_ms, 100);
255 assert!(result.error.is_none());
256 }
257
258 #[test]
259 fn test_hook_result_failure() {
260 let hook = Hook {
261 order: 100,
262 propagate: false,
263 command: "false".to_string(),
264 args: vec![],
265 dir: None,
266 inputs: vec![],
267 source: None,
268 };
269
270 let exit_status = Some(
272 std::process::Command::new(if cfg!(windows) { "cmd" } else { "false" })
273 .args(if cfg!(windows) {
274 vec!["/C", "exit 1"]
275 } else {
276 vec![]
277 })
278 .output()
279 .unwrap()
280 .status,
281 );
282
283 let result = HookResult::failure(
284 hook.clone(),
285 exit_status,
286 String::new(),
287 "command failed".to_string(),
288 50,
289 "Process exited with non-zero status".to_string(),
290 );
291
292 assert!(!result.success);
293 assert_eq!(result.hook, hook);
294 assert_eq!(result.exit_status, Some(1));
295 assert_eq!(result.stderr, "command failed");
296 assert_eq!(result.duration_ms, 50);
297 assert_eq!(
298 result.error,
299 Some("Process exited with non-zero status".to_string())
300 );
301 }
302
303 #[test]
304 fn test_hook_result_timeout() {
305 let hook = Hook {
306 order: 100,
307 propagate: false,
308 command: "sleep".to_string(),
309 args: vec!["1000".to_string()],
310 dir: None,
311 inputs: vec![],
312 source: None,
313 };
314
315 let result = HookResult::timeout(hook.clone(), String::new(), String::new(), 10);
316
317 assert!(!result.success);
318 assert_eq!(result.hook, hook);
319 assert!(result.exit_status.is_none());
320 assert_eq!(result.duration_ms, 10000);
321 assert!(result.error.as_ref().unwrap().contains("timed out"));
322 }
323
324 #[test]
325 fn test_execution_config_default() {
326 let config = HookExecutionConfig::default();
327
328 assert_eq!(config.default_timeout_seconds, 300);
329 assert!(config.fail_fast);
330 assert!(config.state_dir.is_none());
331 }
332
333 #[test]
334 fn test_execution_status_display() {
335 assert_eq!(ExecutionStatus::Running.to_string(), "Running");
336 assert_eq!(ExecutionStatus::Completed.to_string(), "Completed");
337 assert_eq!(ExecutionStatus::Failed.to_string(), "Failed");
338 assert_eq!(ExecutionStatus::Cancelled.to_string(), "Cancelled");
339 }
340}