1use crate::config::HookCfg;
14use std::path::Path;
15use std::process::{Command, ExitStatus};
16
17pub use crate::config::{HookOs, HookWhen};
21
22#[derive(Debug, thiserror::Error)]
24pub enum HookError {
25 #[error("hook `{run}`: failed to run: {source}")]
28 Spawn {
29 run: String,
31 #[source]
33 source: std::io::Error,
34 },
35 #[error("hook `{run}` exited with {status}")]
37 Failed {
38 run: String,
40 status: ExitStatus,
42 },
43}
44
45pub fn run_hooks(hooks: &[HookCfg], trigger: HookWhen, dir: &Path) -> Result<(), HookError> {
52 let current_os = HookOs::try_from(std::env::consts::OS).ok();
53 for hook in hooks {
54 if hook.when != trigger {
55 continue;
56 }
57 if let Some(os) = hook.os
58 && Some(os) != current_os
59 {
60 continue;
61 }
62 if is_stale_free(hook, dir) {
63 continue;
64 }
65 run_one(hook, dir)?;
66 }
67 Ok(())
68}
69
70fn is_stale_free(hook: &HookCfg, dir: &Path) -> bool {
78 if hook.inputs.is_empty() || hook.outputs.is_empty() {
79 return false;
80 }
81 let Some(newest_input) = hook
82 .inputs
83 .iter()
84 .map(|p| mtime(dir, p))
85 .collect::<Option<Vec<_>>>()
86 .and_then(|v| v.into_iter().max())
87 else {
88 return false;
89 };
90 let Some(output_mtimes) = hook
91 .outputs
92 .iter()
93 .map(|p| mtime(dir, p))
94 .collect::<Option<Vec<_>>>()
95 else {
96 return false;
97 };
98 output_mtimes.into_iter().all(|out| out >= newest_input)
99}
100
101fn mtime(dir: &Path, rel: &str) -> Option<std::time::SystemTime> {
104 let p = Path::new(rel);
105 let full = if p.is_absolute() {
106 p.to_path_buf()
107 } else {
108 dir.join(p)
109 };
110 std::fs::metadata(full).ok()?.modified().ok()
111}
112
113fn run_one(hook: &HookCfg, dir: &Path) -> Result<(), HookError> {
116 let status = shell_command(&hook.run)
117 .current_dir(dir)
118 .status()
119 .map_err(|source| HookError::Spawn {
120 run: hook.run.clone(),
121 source,
122 })?;
123 if !status.success() {
124 return Err(HookError::Failed {
125 run: hook.run.clone(),
126 status,
127 });
128 }
129 Ok(())
130}
131
132#[cfg(unix)]
133fn shell_command(run: &str) -> Command {
134 let mut cmd = Command::new("sh");
135 cmd.arg("-c").arg(run);
136 cmd
137}
138
139#[cfg(windows)]
140fn shell_command(run: &str) -> Command {
141 let mut cmd = Command::new("cmd");
142 cmd.arg("/C").arg(run);
143 cmd
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use std::time::{Duration, SystemTime};
150
151 fn hook(when: HookWhen, os: Option<HookOs>, inputs: &[&str], outputs: &[&str]) -> HookCfg {
155 HookCfg {
156 when,
157 os,
158 run: "true".to_string(),
159 inputs: inputs.iter().map(|s| s.to_string()).collect(),
160 outputs: outputs.iter().map(|s| s.to_string()).collect(),
161 }
162 }
163
164 fn temp_dir(tag: &str) -> std::path::PathBuf {
165 let dir = std::env::temp_dir().join(format!(
166 "lumen_hooks_{tag}_{}_{}",
167 std::process::id(),
168 SystemTime::now()
169 .duration_since(std::time::UNIX_EPOCH)
170 .unwrap()
171 .as_nanos()
172 ));
173 std::fs::create_dir_all(&dir).unwrap();
174 dir
175 }
176
177 fn write_stamped(path: &std::path::Path, t: SystemTime) {
182 std::fs::write(path, "x").unwrap();
183 let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
184 f.set_modified(t).unwrap();
185 }
186
187 #[test]
188 fn stale_free_when_output_newer_than_input() {
189 let dir = temp_dir("stale_newer");
190 let epoch = std::time::UNIX_EPOCH;
191 write_stamped(&dir.join("in.c"), epoch + Duration::from_secs(1000));
192 write_stamped(&dir.join("out.so"), epoch + Duration::from_secs(2000));
193
194 let h = hook(HookWhen::Prebuild, None, &["in.c"], &["out.so"]);
195 assert!(is_stale_free(&h, &dir), "output newer than input must skip");
196
197 let _ = std::fs::remove_dir_all(&dir);
198 }
199
200 #[test]
201 fn stale_free_when_output_mtime_equals_input_mtime() {
202 let dir = temp_dir("stale_equal");
207 let t = std::time::UNIX_EPOCH + Duration::from_secs(1000);
208 write_stamped(&dir.join("in.c"), t);
209 write_stamped(&dir.join("out.so"), t);
210
211 let h = hook(HookWhen::Prebuild, None, &["in.c"], &["out.so"]);
212 assert!(
213 is_stale_free(&h, &dir),
214 "an output exactly as new as the input must still skip"
215 );
216
217 let _ = std::fs::remove_dir_all(&dir);
218 }
219
220 #[test]
221 fn runs_when_output_missing() {
222 let dir = temp_dir("missing_output");
223 write_stamped(
224 &dir.join("in.c"),
225 std::time::UNIX_EPOCH + Duration::from_secs(1000),
226 );
227 let h = hook(HookWhen::Prebuild, None, &["in.c"], &["out.so"]);
230 assert!(
231 !is_stale_free(&h, &dir),
232 "a missing declared output must always run"
233 );
234
235 let _ = std::fs::remove_dir_all(&dir);
236 }
237
238 #[test]
239 fn runs_when_no_inputs_or_outputs() {
240 let dir = temp_dir("no_lists");
241 let h = hook(HookWhen::Prebuild, None, &[], &[]);
242 assert!(
243 !is_stale_free(&h, &dir),
244 "a hook with no inputs/outputs always runs"
245 );
246
247 let _ = std::fs::remove_dir_all(&dir);
248 }
249
250 #[test]
251 fn runs_when_input_missing() {
252 let dir = temp_dir("missing_input");
253 write_stamped(
254 &dir.join("out.so"),
255 std::time::UNIX_EPOCH + Duration::from_secs(1000),
256 );
257 let h = hook(HookWhen::Prebuild, None, &["in.c"], &["out.so"]);
260 assert!(
261 !is_stale_free(&h, &dir),
262 "a missing declared input must always run rather than erroring"
263 );
264
265 let _ = std::fs::remove_dir_all(&dir);
266 }
267
268 #[test]
269 fn runs_when_input_newer_than_output() {
270 let dir = temp_dir("stale_older");
271 let epoch = std::time::UNIX_EPOCH;
272 write_stamped(&dir.join("out.so"), epoch + Duration::from_secs(1000));
273 write_stamped(&dir.join("in.c"), epoch + Duration::from_secs(2000));
274
275 let h = hook(HookWhen::Prebuild, None, &["in.c"], &["out.so"]);
276 assert!(
277 !is_stale_free(&h, &dir),
278 "an input newer than the output must run"
279 );
280
281 let _ = std::fs::remove_dir_all(&dir);
282 }
283
284 #[test]
285 fn when_filter_only_matches_declared_trigger() {
286 let dir = temp_dir("when_filter");
287 let prerun_only = [hook(HookWhen::Prerun, None, &[], &[])];
291 assert!(run_hooks(&prerun_only, HookWhen::Prebuild, &dir).is_ok());
292
293 let prebuild_only = [HookCfg {
294 run: "false".to_string(),
295 ..hook(HookWhen::Prebuild, None, &[], &[])
296 }];
297 assert!(run_hooks(&prebuild_only, HookWhen::Prebuild, &dir).is_err());
301 assert!(run_hooks(&prebuild_only, HookWhen::Prerun, &dir).is_ok());
302
303 let _ = std::fs::remove_dir_all(&dir);
304 }
305
306 #[test]
307 fn os_filter_only_matches_current_platform() {
308 let dir = temp_dir("os_filter");
309 let current = HookOs::try_from(std::env::consts::OS).expect(
310 "test host OS must be one of linux/macos/windows for this assertion to be meaningful",
311 );
312 let other = match current {
313 HookOs::Linux => HookOs::Macos,
314 HookOs::Macos => HookOs::Windows,
315 HookOs::Windows => HookOs::Linux,
316 };
317
318 let wrong_os = [HookCfg {
320 run: "false".to_string(),
321 ..hook(HookWhen::Prebuild, Some(other), &[], &[])
322 }];
323 assert!(run_hooks(&wrong_os, HookWhen::Prebuild, &dir).is_ok());
324
325 let right_os = [HookCfg {
328 run: "false".to_string(),
329 ..hook(HookWhen::Prebuild, Some(current), &[], &[])
330 }];
331 assert!(run_hooks(&right_os, HookWhen::Prebuild, &dir).is_err());
332
333 let any_os = [HookCfg {
335 run: "false".to_string(),
336 ..hook(HookWhen::Prebuild, None, &[], &[])
337 }];
338 assert!(run_hooks(&any_os, HookWhen::Prebuild, &dir).is_err());
339
340 let _ = std::fs::remove_dir_all(&dir);
341 }
342}