1use crate::config::HooksConfig;
8use crate::error::ReleaseError;
9
10#[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
18pub 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
47pub fn run_pre_release(config: &HooksConfig, env: &[(&str, &str)]) -> Result<(), ReleaseError> {
49 run_commands("pre_release", &config.pre_release, env)
50}
51
52pub fn run_post_release(config: &HooksConfig, env: &[(&str, &str)]) -> Result<(), ReleaseError> {
54 run_commands("post_release", &config.post_release, env)
55}
56
57pub 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}