Skip to main content

sr_core/
hooks.rs

1//! Hook execution for sr lifecycle events.
2//!
3//! Runs configured shell commands at sr lifecycle boundaries (pre/post for
4//! each command). Hook context is passed as JSON via stdin so commands can
5//! act on structured data (event name, version, files, etc).
6
7use crate::config::{HookEvent, HooksConfig};
8use crate::error::ReleaseError;
9
10/// Context passed to hook commands as JSON on stdin.
11#[derive(Debug, serde::Serialize)]
12pub struct HookContext<'a> {
13    /// The lifecycle event being fired.
14    pub event: &'a str,
15    /// Environment variables set for this hook (flattened as key-value pairs).
16    #[serde(flatten)]
17    pub env: std::collections::BTreeMap<&'a str, &'a str>,
18}
19
20/// Run all commands for a lifecycle event.
21///
22/// Each command receives:
23/// - JSON context on stdin (event name + env vars as structured data)
24/// - Environment variables (e.g. SR_VERSION, SR_TAG for release hooks)
25pub fn run_event(
26    config: &HooksConfig,
27    event: HookEvent,
28    env: &[(&str, &str)],
29) -> Result<(), ReleaseError> {
30    let commands = match config.hooks.get(&event) {
31        Some(cmds) if !cmds.is_empty() => cmds,
32        _ => return Ok(()),
33    };
34
35    let label = format!("{event:?}");
36
37    // Build JSON context for stdin
38    let mut env_map = std::collections::BTreeMap::new();
39    for &(k, v) in env {
40        env_map.insert(k, v);
41    }
42    let context = HookContext {
43        event: &label,
44        env: env_map,
45    };
46    let json = serde_json::to_string(&context)
47        .map_err(|e| ReleaseError::Hook(format!("failed to serialize hook context: {e}")))?;
48
49    for cmd in commands {
50        eprintln!("hook [{label}]: {cmd}");
51        run_shell(cmd, Some(&json), env)?;
52    }
53
54    Ok(())
55}
56
57/// Run a shell command (`sh -c`), optionally piping data to stdin and/or
58/// injecting environment variables. Returns an error if the command exits
59/// non-zero.
60pub fn run_shell(
61    cmd: &str,
62    stdin_data: Option<&str>,
63    env: &[(&str, &str)],
64) -> Result<(), ReleaseError> {
65    let mut child = {
66        let mut builder = std::process::Command::new("sh");
67        builder.args(["-c", cmd]);
68        for &(k, v) in env {
69            builder.env(k, v);
70        }
71        if stdin_data.is_some() {
72            builder.stdin(std::process::Stdio::piped());
73        } else {
74            builder.stdin(std::process::Stdio::inherit());
75        }
76        builder
77            .spawn()
78            .map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?
79    };
80
81    if let Some(data) = stdin_data
82        && let Some(ref mut stdin) = child.stdin
83    {
84        use std::io::Write;
85        let _ = stdin.write_all(data.as_bytes());
86    }
87
88    let status = child
89        .wait()
90        .map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?;
91
92    if !status.success() {
93        let code = status.code().unwrap_or(1);
94        return Err(ReleaseError::Hook(format!("{cmd} exited with code {code}")));
95    }
96
97    Ok(())
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn run_shell_success() {
106        run_shell("true", None, &[]).unwrap();
107    }
108
109    #[test]
110    fn run_shell_failure() {
111        let result = run_shell("false", None, &[]);
112        assert!(result.is_err());
113    }
114
115    #[test]
116    fn run_shell_with_env() {
117        run_shell("test \"$MY_VAR\" = hello", None, &[("MY_VAR", "hello")]).unwrap();
118    }
119
120    #[test]
121    fn run_event_empty_config() {
122        let config = HooksConfig::default();
123        run_event(&config, HookEvent::PreRelease, &[]).unwrap();
124    }
125
126    #[test]
127    fn run_event_simple_command() {
128        use std::collections::BTreeMap;
129        let mut hooks = BTreeMap::new();
130        hooks.insert(HookEvent::PreRelease, vec!["true".to_string()]);
131        let config = HooksConfig { hooks };
132        run_event(&config, HookEvent::PreRelease, &[]).unwrap();
133    }
134
135    #[test]
136    fn run_event_failure_aborts() {
137        use std::collections::BTreeMap;
138        let mut hooks = BTreeMap::new();
139        hooks.insert(HookEvent::PreRelease, vec!["false".to_string()]);
140        let config = HooksConfig { hooks };
141        let result = run_event(&config, HookEvent::PreRelease, &[]);
142        assert!(result.is_err());
143    }
144
145    #[test]
146    fn run_event_passes_env() {
147        use std::collections::BTreeMap;
148        let mut hooks = BTreeMap::new();
149        hooks.insert(
150            HookEvent::PostRelease,
151            vec!["test \"$SR_VERSION\" = 1.2.3".to_string()],
152        );
153        let config = HooksConfig { hooks };
154        run_event(&config, HookEvent::PostRelease, &[("SR_VERSION", "1.2.3")]).unwrap();
155    }
156
157    #[test]
158    fn run_event_passes_json_stdin() {
159        use std::collections::BTreeMap;
160        let mut hooks = BTreeMap::new();
161        // Read stdin JSON and verify it contains the event name
162        hooks.insert(
163            HookEvent::PreCommit,
164            vec!["cat | grep -q PreCommit".to_string()],
165        );
166        let config = HooksConfig { hooks };
167        run_event(&config, HookEvent::PreCommit, &[]).unwrap();
168    }
169}