Skip to main content

cuenv_hooks/
types.rs

1//! Type definitions for hooks and hook execution
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::process::ExitStatus;
7
8/// Default order for hooks (100)
9fn default_order() -> i32 {
10    100
11}
12
13/// A hook represents a command that can be executed when entering or exiting environments
14/// Based on schema/hooks.cue #ExecHook definition
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct Hook {
17    /// Execution order within a single env.cue (lower runs first, default 100)
18    #[serde(default = "default_order")]
19    pub order: i32,
20    /// Whether this hook propagates to child directories (default false)
21    #[serde(default)]
22    pub propagate: bool,
23    /// The command to execute
24    pub command: String,
25    /// Arguments to pass to the command
26    #[serde(default)]
27    pub args: Vec<String>,
28    /// Working directory for command execution (defaults to ".")
29    #[serde(default)]
30    pub dir: Option<String>,
31    /// Input files that trigger re-execution when changed
32    #[serde(default)]
33    pub inputs: Vec<String>,
34    /// Whether to source the command output as shell script to capture environment changes
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub source: Option<bool>,
37}
38
39/// Result of executing a single hook
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct HookResult {
42    /// The hook that was executed
43    pub hook: Hook,
44    /// Whether the execution was successful
45    pub success: bool,
46    /// Exit status of the command
47    pub exit_status: Option<i32>,
48    /// Standard output captured from the command
49    pub stdout: String,
50    /// Standard error captured from the command
51    pub stderr: String,
52    /// Duration of execution in milliseconds
53    pub duration_ms: u64,
54    /// Error message if execution failed
55    pub error: Option<String>,
56}
57
58impl HookResult {
59    /// Create a successful hook result
60    #[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    /// Create a failed hook result
80    #[allow(clippy::too_many_arguments)] // Hook result requires full execution context
81    #[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    /// Create a timeout hook result
102    #[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/// Configuration for hook execution
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct HookExecutionConfig {
122    /// Default timeout for hooks that don't specify one
123    pub default_timeout_seconds: u64,
124    /// Whether to stop executing remaining hooks if one fails
125    pub fail_fast: bool,
126    /// Directory to store execution state
127    pub state_dir: Option<PathBuf>,
128}
129
130impl Default for HookExecutionConfig {
131    fn default() -> Self {
132        Self {
133            default_timeout_seconds: 300, // 5 minutes
134            fail_fast: true,
135            state_dir: None, // Will use default state dir
136        }
137    }
138}
139
140/// Status of hook execution
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub enum ExecutionStatus {
143    /// Hooks are currently being executed
144    Running,
145    /// All hooks completed successfully
146    Completed,
147    /// Hook execution failed
148    Failed,
149    /// Hook execution was cancelled
150    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/// Collection of hooks that can be executed
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
166pub struct Hooks {
167    /// Named hooks to execute when entering an environment (map of name -> hook)
168    #[serde(skip_serializing_if = "Option::is_none")]
169    #[serde(rename = "onEnter")]
170    pub on_enter: Option<HashMap<String, Hook>>,
171
172    /// Named hooks to execute when exiting an environment (map of name -> hook)
173    #[serde(skip_serializing_if = "Option::is_none")]
174    #[serde(rename = "onExit")]
175    pub on_exit: Option<HashMap<String, Hook>>,
176
177    /// Named hooks to execute before git push (map of name -> hook)
178    #[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); // default order
211        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); // default
216    }
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        // Use Command::new to create a platform-compatible successful exit status
231        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        // Use Command::new to create a platform-compatible failed exit status
271        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}