1use crate::config::{HookEvent, HooksConfig};
8use crate::error::ReleaseError;
9
10#[derive(Debug, serde::Serialize)]
12pub struct HookContext<'a> {
13 pub event: &'a str,
15 #[serde(flatten)]
17 pub env: std::collections::BTreeMap<&'a str, &'a str>,
18}
19
20pub 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 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
57pub 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 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}