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_build(config: &HooksConfig, env: &[(&str, &str)]) -> Result<(), ReleaseError> {
56 run_commands("build", &config.build, env)
57}
58
59pub fn run_post_release(config: &HooksConfig, env: &[(&str, &str)]) -> Result<(), ReleaseError> {
61 run_commands("post_release", &config.post_release, env)
62}
63
64pub 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}