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 build hooks from a HooksConfig.
53/// Runs after version bump, before commit — output artifacts must match
54/// the declared `artifacts` globs (sr validates this before tagging).
55pub fn run_build(config: &HooksConfig, env: &[(&str, &str)]) -> Result<(), ReleaseError> {
56    run_commands("build", &config.build, env)
57}
58
59/// Run post_release hooks from a HooksConfig.
60pub fn run_post_release(config: &HooksConfig, env: &[(&str, &str)]) -> Result<(), ReleaseError> {
61    run_commands("post_release", &config.post_release, env)
62}
63
64/// Run a shell command (`sh -c`), optionally piping data to stdin and/or
65/// injecting environment variables.
66pub fn run_shell(
67    cmd: &str,
68    stdin_data: Option<&str>,
69    env: &[(&str, &str)],
70) -> Result<(), ReleaseError> {
71    let mut child = {
72        let mut builder = std::process::Command::new("sh");
73        builder.args(["-c", cmd]);
74        for &(k, v) in env {
75            builder.env(k, v);
76        }
77        if stdin_data.is_some() {
78            builder.stdin(std::process::Stdio::piped());
79        } else {
80            builder.stdin(std::process::Stdio::inherit());
81        }
82        builder
83            .spawn()
84            .map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?
85    };
86
87    if let Some(data) = stdin_data
88        && let Some(ref mut stdin) = child.stdin
89    {
90        use std::io::Write;
91        let _ = stdin.write_all(data.as_bytes());
92    }
93
94    let status = child
95        .wait()
96        .map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?;
97
98    if !status.success() {
99        let code = status.code().unwrap_or(1);
100        return Err(ReleaseError::Hook(format!("{cmd} exited with code {code}")));
101    }
102
103    Ok(())
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn run_shell_success() {
112        run_shell("true", None, &[]).unwrap();
113    }
114
115    #[test]
116    fn run_shell_failure() {
117        let result = run_shell("false", None, &[]);
118        assert!(result.is_err());
119    }
120
121    #[test]
122    fn run_shell_with_env() {
123        run_shell("test \"$MY_VAR\" = hello", None, &[("MY_VAR", "hello")]).unwrap();
124    }
125
126    #[test]
127    fn run_commands_empty() {
128        run_commands("test", &[], &[]).unwrap();
129    }
130
131    #[test]
132    fn run_commands_success() {
133        run_commands("test", &["true".into()], &[]).unwrap();
134    }
135
136    #[test]
137    fn run_commands_failure_aborts() {
138        let result = run_commands("test", &["false".into()], &[]);
139        assert!(result.is_err());
140    }
141
142    #[test]
143    fn run_commands_passes_env() {
144        run_commands(
145            "test",
146            &["test \"$SR_VERSION\" = 1.2.3".into()],
147            &[("SR_VERSION", "1.2.3")],
148        )
149        .unwrap();
150    }
151
152    #[test]
153    fn run_pre_release_empty() {
154        let config = HooksConfig::default();
155        run_pre_release(&config, &[]).unwrap();
156    }
157
158    #[test]
159    fn run_post_release_empty() {
160        let config = HooksConfig::default();
161        run_post_release(&config, &[]).unwrap();
162    }
163}