Skip to main content

lumen_runtime/
hooks.rs

1//! `[[hooks]]` runner: executes an app's declared build/setup commands at the
2//! `prebuild` and `prerun` trigger points.
3//!
4//! The schema and field reference live on [`crate::config::HookCfg`]; this
5//! module is the execution side: filtering by [`HookCfg::when`] and the
6//! current OS, skipping a hook whose declared outputs are already newer than
7//! its declared inputs, and running the rest in declaration order.
8//!
9//! Callers (the `lumenc` CLI's `run` / `build` / `bundle` commands) decide
10//! *when* to call [`run_hooks`]; `lumenc check` never does, so a check stays
11//! side-effect free.
12
13use crate::config::HookCfg;
14use std::path::Path;
15use std::process::{Command, ExitStatus};
16
17// Re-exported so a caller only needs `lumen_runtime::hooks::{run_hooks,
18// HookWhen, HookOs}` - the trigger-point and OS enums a caller matches
19// against - without also reaching into `lumen_runtime::config` for them.
20pub use crate::config::{HookOs, HookWhen};
21
22/// A `[[hooks]]` command failed.
23#[derive(Debug, thiserror::Error)]
24pub enum HookError {
25    /// The command could not be spawned at all (missing shell interpreter,
26    /// permission denied, ...).
27    #[error("hook `{run}`: failed to run: {source}")]
28    Spawn {
29        /// The hook's `run` command line.
30        run: String,
31        /// The underlying spawn failure.
32        #[source]
33        source: std::io::Error,
34    },
35    /// The command ran and exited non-zero.
36    #[error("hook `{run}` exited with {status}")]
37    Failed {
38        /// The hook's `run` command line.
39        run: String,
40        /// The command's exit status.
41        status: ExitStatus,
42    },
43}
44
45/// Run every hook in `hooks` whose `when` matches `trigger` and whose `os`
46/// (if set) matches [`std::env::consts::OS`], in declaration order, with the
47/// child process's cwd set to `dir` (the app directory). A hook whose
48/// declared outputs are all at least as new as its declared inputs is
49/// skipped; see [`is_stale_free`]. A hook that exits non-zero aborts the run
50/// immediately - later hooks do not run.
51pub 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
70/// True when `hook` can be skipped: both `inputs` and `outputs` are
71/// non-empty, every declared output resolves to a real file, every declared
72/// input resolves to a real file, and every output's mtime is at least as
73/// new as the newest input's mtime. A hook missing either list, or one whose
74/// declared inputs or outputs do not all exist on disk, is never skipped -
75/// it runs, and the command itself gives the better error for a genuinely
76/// missing input.
77fn 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
101/// Resolve `rel` against `dir` (unless already absolute) and return its
102/// mtime, or `None` when the path does not exist / has no readable mtime.
103fn 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
113/// Run one hook's command with `dir` as the child's cwd, stdio inherited
114/// from the parent. `sh -c` on unix, `cmd /C` on windows.
115fn 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    /// Build a bare `HookCfg` for the staleness / filter tests. `run` is
152    /// never executed by these tests - they call `is_stale_free` / the
153    /// `when`/`os` filter directly rather than shelling out.
154    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    /// Write `path` and stamp its mtime to `t`, bypassing the system clock so
178    /// staleness tests don't depend on real elapsed time or filesystem mtime
179    /// granularity (some filesystems only resolve to 1-2 seconds, which a
180    /// short `thread::sleep` between writes can land inside).
181    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        // The documented rule is "at least as new as", so an output stamped
203        // to the exact same instant as the newest input still counts as
204        // fresh - pin that boundary explicitly rather than leaving it to
205        // incidental clock behavior.
206        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        // out.so intentionally not written.
228
229        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        // in.c intentionally not written - staleness can't be judged, so run.
258
259        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        // A `prerun` hook must not fire on a `prebuild` trigger, and vice
288        // versa. `run_hooks` would try to execute `false` (exit 1) if the
289        // filter let it through, so a clean `Ok(())` proves it was skipped.
290        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        // Sanity: the same hook DOES fire (and fail, since `false` exits 1)
298        // when the trigger matches - proving the prior `Ok(())` was really a
299        // skip, not a no-op filter bug.
300        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        // A hook pinned to a different OS never runs here.
319        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        // The same hook pinned to the current OS does run (and fails, since
326        // the command is `false`) - proof the skip above was the OS filter.
327        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        // No `os` at all always matches.
334        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}