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.
4//! Hook context is passed as JSON via stdin so commands can
5//! act on structured data.
6
7use crate::config::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    pub event: &'a str,
14    #[serde(flatten)]
15    pub env: std::collections::BTreeMap<&'a str, &'a str>,
16}
17
18/// Run a list of shell commands with environment variables.
19pub fn run_commands(
20    label: &str,
21    commands: &[String],
22    env: &[(&str, &str)],
23) -> Result<(), ReleaseError> {
24    if commands.is_empty() {
25        return Ok(());
26    }
27
28    let mut env_map = std::collections::BTreeMap::new();
29    for &(k, v) in env {
30        env_map.insert(k, v);
31    }
32    let context = HookContext {
33        event: label,
34        env: env_map,
35    };
36    let json = serde_json::to_string(&context)
37        .map_err(|e| ReleaseError::Hook(format!("failed to serialize hook context: {e}")))?;
38
39    for cmd in commands {
40        eprintln!("hook [{label}]: {cmd}");
41        run_shell(cmd, Some(&json), env)?;
42    }
43
44    Ok(())
45}
46
47/// Run pre_release hooks from a HooksConfig.
48pub fn run_pre_release(config: &HooksConfig, env: &[(&str, &str)]) -> Result<(), ReleaseError> {
49    run_commands("pre_release", &config.pre_release, env)
50}
51
52/// Run post_release hooks from a HooksConfig.
53pub fn run_post_release(config: &HooksConfig, env: &[(&str, &str)]) -> Result<(), ReleaseError> {
54    run_commands("post_release", &config.post_release, env)
55}
56
57/// Run a shell command (`sh -c`), optionally piping data to stdin and/or
58/// injecting environment variables.
59pub fn run_shell(
60    cmd: &str,
61    stdin_data: Option<&str>,
62    env: &[(&str, &str)],
63) -> Result<(), ReleaseError> {
64    let mut child = {
65        let mut builder = std::process::Command::new("sh");
66        builder.args(["-c", cmd]);
67        for &(k, v) in env {
68            builder.env(k, v);
69        }
70        if stdin_data.is_some() {
71            builder.stdin(std::process::Stdio::piped());
72        } else {
73            builder.stdin(std::process::Stdio::inherit());
74        }
75        builder
76            .spawn()
77            .map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?
78    };
79
80    if let Some(data) = stdin_data
81        && let Some(ref mut stdin) = child.stdin
82    {
83        use std::io::Write;
84        let _ = stdin.write_all(data.as_bytes());
85    }
86
87    let status = child
88        .wait()
89        .map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?;
90
91    if !status.success() {
92        let code = status.code().unwrap_or(1);
93        return Err(ReleaseError::Hook(format!("{cmd} exited with code {code}")));
94    }
95
96    Ok(())
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn run_shell_success() {
105        run_shell("true", None, &[]).unwrap();
106    }
107
108    #[test]
109    fn run_shell_failure() {
110        let result = run_shell("false", None, &[]);
111        assert!(result.is_err());
112    }
113
114    #[test]
115    fn run_shell_with_env() {
116        run_shell("test \"$MY_VAR\" = hello", None, &[("MY_VAR", "hello")]).unwrap();
117    }
118
119    #[test]
120    fn run_commands_empty() {
121        run_commands("test", &[], &[]).unwrap();
122    }
123
124    #[test]
125    fn run_commands_success() {
126        run_commands("test", &["true".into()], &[]).unwrap();
127    }
128
129    #[test]
130    fn run_commands_failure_aborts() {
131        let result = run_commands("test", &["false".into()], &[]);
132        assert!(result.is_err());
133    }
134
135    #[test]
136    fn run_commands_passes_env() {
137        run_commands(
138            "test",
139            &["test \"$SR_VERSION\" = 1.2.3".into()],
140            &[("SR_VERSION", "1.2.3")],
141        )
142        .unwrap();
143    }
144
145    #[test]
146    fn run_pre_release_empty() {
147        let config = HooksConfig::default();
148        run_pre_release(&config, &[]).unwrap();
149    }
150
151    #[test]
152    fn run_post_release_empty() {
153        let config = HooksConfig::default();
154        run_post_release(&config, &[]).unwrap();
155    }
156}