Skip to main content

bougie_scripts/
dispatch.rs

1//! The event runner: walk a `scripts.<event>` list in order, executing each
2//! entry, aborting on the first non-zero exit (matching Composer).
3
4use std::collections::HashMap;
5use std::collections::HashSet;
6use std::process::Command;
7use std::time::Duration;
8
9use eyre::{bail, eyre, Result, WrapErr};
10use wait_timeout::ChildExt;
11
12use crate::{Entry, ScriptContext, Scripts};
13
14/// The Composer callback that disables the per-process timeout for the rest
15/// of the dispatch (`Composer\Config::disableProcessTimeout`). Recognised
16/// specially because it has to mutate dispatch-local timeout state — a
17/// registry callback (which only gets `&ScriptContext`) couldn't.
18const DISABLE_TIMEOUT_CALLBACK: &str = "Composer\\Config::disableProcessTimeout";
19
20/// What happened to one entry during a dispatch.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum EntryOutcome {
23    /// A process entry ran to a zero exit, or `@putenv` mutated the env.
24    Ran,
25    /// A PHP callback with no native handler — warned and skipped.
26    SkippedCallback(String),
27    /// A PHP callback served by a registered native handler.
28    NativeCallback,
29    /// An `@composer` subcommand bougie doesn't map — warned and skipped.
30    SkippedComposer(String),
31}
32
33/// Dispatch a named event, running its entries in order. Returns one outcome
34/// per entry (recursing into aliases inline). A non-zero process exit or an
35/// alias cycle is an `Err` that aborts the event and the surrounding command.
36///
37/// `@putenv` mutations are scoped to this dispatch; the inherited process env
38/// is untouched. Output streams straight to the user's stdout/stderr — scripts
39/// are chatty and the user opted into running them.
40pub fn dispatch(scripts: &Scripts, event: &str, ctx: &ScriptContext) -> Result<Vec<EntryOutcome>> {
41    let mut env = seed_env(ctx);
42    let mut seen = HashSet::new();
43    let mut outcomes = Vec::new();
44    // Per-process timeout, mutable across the dispatch: the
45    // `disableProcessTimeout` callback flips it off for every subsequent
46    // entry (matching Composer's process-wide ProcessExecutor::$timeout).
47    let mut timeout = ctx.timeout;
48    dispatch_inner(scripts, event, ctx, &mut env, &mut timeout, &mut seen, &mut outcomes)?;
49    Ok(outcomes)
50}
51
52fn dispatch_inner(
53    scripts: &Scripts,
54    event: &str,
55    ctx: &ScriptContext,
56    env: &mut HashMap<String, String>,
57    timeout: &mut Option<Duration>,
58    seen: &mut HashSet<String>,
59    outcomes: &mut Vec<EntryOutcome>,
60) -> Result<()> {
61    if !seen.insert(event.to_string()) {
62        bail!("script alias cycle detected at `{event}`");
63    }
64    let Some(entries) = scripts.get(event) else {
65        // An undefined event is a no-op, exactly as Composer treats a missing
66        // listener list. (Aliases to undefined scripts also no-op.)
67        seen.remove(event);
68        return Ok(());
69    };
70    for entry in entries {
71        match entry {
72            Entry::PutEnv { key, val } => {
73                env.insert(key.clone(), expand(val, env));
74                outcomes.push(EntryOutcome::Ran);
75            }
76            Entry::Alias(name) => {
77                dispatch_inner(scripts, name, ctx, env, timeout, seen, outcomes)?;
78            }
79            Entry::Php(args) => {
80                let line = format!("{} {}", shell_quote(&ctx.php_bin.display().to_string()), args);
81                run_command_line(line.trim(), ctx, env, *timeout)
82                    .wrap_err_with(|| format!("`{event}`: @php {args}"))?;
83                outcomes.push(EntryOutcome::Ran);
84            }
85            Entry::Composer(args) => match map_composer(args) {
86                ComposerMap::Noop => outcomes.push(EntryOutcome::Ran),
87                ComposerMap::Unmapped => {
88                    eprintln!(
89                        "warning: `{event}` runs `@composer {args}`, which bougie does not map; \
90                         skipping. Run it via `bougie run -- composer {args}` if required."
91                    );
92                    outcomes.push(EntryOutcome::SkippedComposer(args.clone()));
93                }
94            },
95            Entry::Shell(cmd) => {
96                run_command_line(cmd, ctx, env, *timeout)
97                    .wrap_err_with(|| format!("`{event}`: {cmd}"))?;
98                outcomes.push(EntryOutcome::Ran);
99            }
100            Entry::Callback { class, method } => {
101                // `disableProcessTimeout` is recognised here (not via the
102                // registry) because it mutates the dispatch-local timeout.
103                if normalize_callback(class, method) == DISABLE_TIMEOUT_CALLBACK {
104                    *timeout = None;
105                    outcomes.push(EntryOutcome::NativeCallback);
106                    continue;
107                }
108                if let Some(handler) = ctx.callbacks.get(class, method) {
109                    handler(ctx)
110                        .wrap_err_with(|| format!("`{event}`: native callback {class}::{method}"))?;
111                    outcomes.push(EntryOutcome::NativeCallback);
112                } else {
113                    eprintln!(
114                        "warning: `{event}` lists the PHP callback `{class}::{method}`, which \
115                         reaches into Composer internals; bougie does not run it. Express it as a \
116                         shell/`@php` entry if the behavior is required."
117                    );
118                    outcomes.push(EntryOutcome::SkippedCallback(format!("{class}::{method}")));
119                }
120            }
121        }
122    }
123    seen.remove(event);
124    Ok(())
125}
126
127/// Seed the per-dispatch env from the host's `base_env`, ensuring `bin_dir`
128/// leads `PATH`. The prepend is idempotent so it composes with a host that
129/// already folded `bin_dir` into `base_env`'s `PATH`.
130fn seed_env(ctx: &ScriptContext) -> HashMap<String, String> {
131    let mut env: HashMap<String, String> = ctx.base_env.iter().cloned().collect();
132    let bin = ctx.bin_dir.display().to_string();
133    let path = env.get("PATH").cloned().unwrap_or_default();
134    let leads = path.split(PATH_SEP).next().is_some_and(|first| first == bin);
135    if !bin.is_empty() && !leads {
136        let joined = if path.is_empty() { bin } else { format!("{bin}{PATH_SEP}{path}") };
137        env.insert("PATH".into(), joined);
138    }
139    env
140}
141
142#[cfg(unix)]
143const PATH_SEP: &str = ":";
144#[cfg(not(unix))]
145const PATH_SEP: &str = ";";
146
147/// `Class::method` with a single leading namespace `\` stripped, so
148/// `\Composer\Config::disableProcessTimeout` and the slash-less form match.
149fn normalize_callback(class: &str, method: &str) -> String {
150    format!("{}::{method}", class.strip_prefix('\\').unwrap_or(class))
151}
152
153/// Run a command line through the platform shell with the dispatch env,
154/// rooted at the project. Non-zero exit is an `Err` (aborts the event).
155///
156/// `timeout` caps the wall-clock per process (Composer's
157/// `config.process-timeout`, default 300s). On expiry the child is killed
158/// and an error aborts the event; `None` waits indefinitely.
159fn run_command_line(
160    line: &str,
161    ctx: &ScriptContext,
162    env: &HashMap<String, String>,
163    timeout: Option<Duration>,
164) -> Result<()> {
165    let mut cmd = shell_command(line);
166    cmd.current_dir(ctx.project_root);
167    for (k, v) in env {
168        cmd.env(k, v);
169    }
170    let Some(limit) = timeout else {
171        let status = cmd.status().wrap_err_with(|| format!("spawning shell for `{line}`"))?;
172        return exit_to_result(status, line);
173    };
174    // Put the script in its own process group so a timeout tears down the
175    // whole tree (the shell *and* anything it forked), not just the shell —
176    // matching what Symfony Process does for Composer. Only on the timeout
177    // path: the unlimited path keeps the shell in bougie's group so Ctrl-C
178    // reaches it normally.
179    #[cfg(unix)]
180    {
181        use std::os::unix::process::CommandExt;
182        cmd.process_group(0);
183    }
184    let mut child = cmd.spawn().wrap_err_with(|| format!("spawning shell for `{line}`"))?;
185    if let Some(status) = child.wait_timeout(limit).wrap_err_with(|| format!("waiting for `{line}`"))? {
186        return exit_to_result(status, line);
187    }
188    kill_tree(&mut child);
189    Err(eyre!(
190        "command `{line}` exceeded the {}s process timeout; raise it with \
191         `config.process-timeout` in composer.json (0 = unlimited) or call \
192         `Composer\\Config::disableProcessTimeout` earlier in the script",
193        limit.as_secs(),
194    ))
195}
196
197/// Kill a timed-out child and reap it. On Unix the child leads its own
198/// process group (set via `process_group(0)` above), so `killpg` takes down
199/// any grandchildren it forked too.
200#[cfg(unix)]
201fn kill_tree(child: &mut std::process::Child) {
202    use nix::sys::signal::{killpg, Signal};
203    use nix::unistd::Pid;
204    if let Ok(pid) = i32::try_from(child.id()) {
205        let _ = killpg(Pid::from_raw(pid), Signal::SIGKILL);
206    }
207    let _ = child.wait();
208}
209
210#[cfg(not(unix))]
211fn kill_tree(child: &mut std::process::Child) {
212    let _ = child.kill();
213    let _ = child.wait();
214}
215
216fn exit_to_result(status: std::process::ExitStatus, line: &str) -> Result<()> {
217    if status.success() {
218        Ok(())
219    } else {
220        Err(eyre!("command `{line}` exited with {status}"))
221    }
222}
223
224#[cfg(unix)]
225fn shell_command(line: &str) -> Command {
226    let mut cmd = Command::new("/bin/sh");
227    cmd.arg("-e").arg("-c").arg(line);
228    cmd
229}
230
231#[cfg(not(unix))]
232fn shell_command(line: &str) -> Command {
233    let mut cmd = Command::new("cmd");
234    cmd.arg("/C").arg(line);
235    cmd
236}
237
238/// Quote a path for the platform shell so a binary path with spaces survives.
239#[cfg(unix)]
240fn shell_quote(s: &str) -> String {
241    format!("'{}'", s.replace('\'', r"'\''"))
242}
243
244#[cfg(not(unix))]
245fn shell_quote(s: &str) -> String {
246    format!("\"{}\"", s.replace('"', "\"\""))
247}
248
249enum ComposerMap {
250    /// We're already mid-install / autoload-dump; the subcommand's effect is
251    /// either done natively or would re-enter. Treat as a no-op.
252    Noop,
253    /// Not a subcommand bougie maps.
254    Unmapped,
255}
256
257/// Map the common `@composer <sub>` calls to bougie equivalents. Only the
258/// ones that occur inside install lifecycle scripts are mapped; the rest are
259/// warn-skipped (`bougie tool composer` is the future escape hatch).
260fn map_composer(args: &str) -> ComposerMap {
261    match args.split_whitespace().next() {
262        // `install` / `update` would re-enter the operation we're already
263        // running; the native autoload dump already ran before
264        // `post-autoload-dump`. All no-ops inside the install lifecycle.
265        Some("install" | "update" | "dump-autoload" | "dumpautoload" | "dump") => ComposerMap::Noop,
266        _ => ComposerMap::Unmapped,
267    }
268}
269
270/// Expand `$VAR` / `${VAR}` against the current dispatch env (used by
271/// `@putenv` values). Unknown vars expand to empty, matching `getenv`.
272fn expand(val: &str, env: &HashMap<String, String>) -> String {
273    let mut out = String::with_capacity(val.len());
274    let mut chars = val.chars().peekable();
275    while let Some(c) = chars.next() {
276        if c != '$' {
277            out.push(c);
278            continue;
279        }
280        let braced = chars.peek() == Some(&'{');
281        if braced {
282            chars.next();
283        }
284        let mut name = String::new();
285        while let Some(&nc) = chars.peek() {
286            let ok = if braced { nc != '}' } else { nc.is_ascii_alphanumeric() || nc == '_' };
287            if !ok {
288                break;
289            }
290            name.push(nc);
291            chars.next();
292        }
293        if braced && chars.peek() == Some(&'}') {
294            chars.next();
295        }
296        if name.is_empty() {
297            out.push('$');
298        } else if let Some(v) = env.get(&name) {
299            out.push_str(v);
300        }
301    }
302    out
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::CallbackRegistry;
309    use std::path::{Path, PathBuf};
310    use std::sync::atomic::{AtomicUsize, Ordering};
311    use std::sync::Arc;
312
313    fn ctx<'a>(root: &'a Path, reg: &'a CallbackRegistry, env: Vec<(String, String)>) -> ScriptContext<'a> {
314        ScriptContext {
315            project_root: root,
316            php_bin: Path::new("/usr/bin/php"),
317            bin_dir: Path::new("/nonexistent/bin"),
318            base_env: env,
319            dev_mode: true,
320            timeout: None,
321            callbacks: reg,
322        }
323    }
324
325    #[test]
326    fn shell_entry_runs_and_aborts_on_nonzero() {
327        let tmp = tempfile::tempdir().unwrap();
328        let reg = CallbackRegistry::new();
329        let sentinel = tmp.path().join("ran");
330        // Use the `:` builtin + redirection so the test doesn't depend on a
331        // populated PATH (dispatch sets PATH from base_env, which is empty here).
332        let scripts = Scripts::parse(&serde_json::json!({
333            "scripts": { "post-install-cmd": [format!(": > {}", sentinel.display())] }
334        }));
335        let c = ctx(tmp.path(), &reg, vec![]);
336        dispatch(&scripts, "post-install-cmd", &c).unwrap();
337        assert!(sentinel.exists());
338
339        // A failing step returns Err and stops.
340        let failing = Scripts::parse(&serde_json::json!({
341            "scripts": { "x": ["false", format!(": > {}", tmp.path().join("after").display())] }
342        }));
343        assert!(dispatch(&failing, "x", &c).is_err());
344        assert!(!tmp.path().join("after").exists());
345    }
346
347    #[test]
348    fn putenv_is_scoped_and_expands() {
349        let tmp = tempfile::tempdir().unwrap();
350        let reg = CallbackRegistry::new();
351        let out = tmp.path().join("env.txt");
352        let scripts = Scripts::parse(&serde_json::json!({
353            "scripts": { "x": [
354                "@putenv GREETING=hello",
355                "@putenv MESSAGE=${GREETING}-world",
356                format!("printf '%s' \"$MESSAGE\" > {}", out.display()),
357            ] }
358        }));
359        let c = ctx(tmp.path(), &reg, vec![]);
360        dispatch(&scripts, "x", &c).unwrap();
361        assert_eq!(std::fs::read_to_string(&out).unwrap(), "hello-world");
362    }
363
364    #[test]
365    fn alias_recurses_and_detects_cycles() {
366        let tmp = tempfile::tempdir().unwrap();
367        let reg = CallbackRegistry::new();
368        let scripts = Scripts::parse(&serde_json::json!({
369            "scripts": { "a": ["@b"], "b": ["@a"] }
370        }));
371        let c = ctx(tmp.path(), &reg, vec![]);
372        assert!(dispatch(&scripts, "a", &c).is_err());
373    }
374
375    #[test]
376    fn callback_hits_registry_else_warn_skips() {
377        let tmp = tempfile::tempdir().unwrap();
378        let hits = Arc::new(AtomicUsize::new(0));
379        let h = hits.clone();
380        let mut reg = CallbackRegistry::new();
381        reg.register(
382            "Acme\\Scripts::run",
383            Box::new(move |_| {
384                h.fetch_add(1, Ordering::SeqCst);
385                Ok(())
386            }),
387        );
388        let scripts = Scripts::parse(&serde_json::json!({
389            "scripts": { "x": ["Acme\\Scripts::run", "Other\\Thing::go"] }
390        }));
391        let c = ctx(tmp.path(), &reg, vec![]);
392        let out = dispatch(&scripts, "x", &c).unwrap();
393        assert_eq!(hits.load(Ordering::SeqCst), 1);
394        assert_eq!(
395            out,
396            vec![EntryOutcome::NativeCallback, EntryOutcome::SkippedCallback("Other\\Thing::go".into())]
397        );
398    }
399
400    #[test]
401    fn composer_subcommands_map_or_skip() {
402        let tmp = tempfile::tempdir().unwrap();
403        let reg = CallbackRegistry::new();
404        let scripts = Scripts::parse(&serde_json::json!({
405            "scripts": { "x": ["@composer dump-autoload", "@composer require foo/bar"] }
406        }));
407        let c = ctx(tmp.path(), &reg, vec![]);
408        let out = dispatch(&scripts, "x", &c).unwrap();
409        assert_eq!(
410            out,
411            vec![EntryOutcome::Ran, EntryOutcome::SkippedComposer("require foo/bar".into())]
412        );
413    }
414
415    #[test]
416    fn undefined_event_is_noop() {
417        let tmp = tempfile::tempdir().unwrap();
418        let reg = CallbackRegistry::new();
419        let scripts = Scripts::parse(&serde_json::json!({ "scripts": {} }));
420        let c = ctx(tmp.path(), &reg, vec![]);
421        assert!(dispatch(&scripts, "post-install-cmd", &c).unwrap().is_empty());
422    }
423
424    #[test]
425    fn bin_dir_prepended_to_path() {
426        let reg = CallbackRegistry::new();
427        let bin = PathBuf::from("/opt/proj/vendor/bin");
428        let c = ScriptContext {
429            project_root: Path::new("/tmp"),
430            php_bin: Path::new("/usr/bin/php"),
431            bin_dir: &bin,
432            base_env: vec![("PATH".into(), "/usr/bin".into())],
433            dev_mode: true,
434            timeout: None,
435            callbacks: &reg,
436        };
437        let env = seed_env(&c);
438        assert_eq!(env.get("PATH").unwrap(), "/opt/proj/vendor/bin:/usr/bin");
439        // Idempotent: already-leading bin_dir isn't doubled.
440        let c2 = ScriptContext { base_env: vec![("PATH".into(), env["PATH"].clone())], ..c };
441        assert_eq!(seed_env(&c2).get("PATH").unwrap(), "/opt/proj/vendor/bin:/usr/bin");
442    }
443
444    /// A `ScriptContext` with the inherited `PATH` (so `sleep` resolves) and
445    /// a per-process `timeout`.
446    fn ctx_with_timeout<'a>(
447        root: &'a Path,
448        reg: &'a CallbackRegistry,
449        timeout: Option<std::time::Duration>,
450    ) -> ScriptContext<'a> {
451        ScriptContext {
452            project_root: root,
453            php_bin: Path::new("/usr/bin/php"),
454            bin_dir: Path::new("/nonexistent/bin"),
455            base_env: vec![("PATH".into(), std::env::var("PATH").unwrap_or_default())],
456            dev_mode: true,
457            timeout,
458            callbacks: reg,
459        }
460    }
461
462    #[test]
463    fn process_timeout_kills_a_slow_entry() {
464        let tmp = tempfile::tempdir().unwrap();
465        let reg = CallbackRegistry::new();
466        let c = ctx_with_timeout(tmp.path(), &reg, Some(std::time::Duration::from_millis(300)));
467        let scripts = Scripts::parse(&serde_json::json!({ "scripts": { "x": ["sleep 5"] } }));
468        let start = std::time::Instant::now();
469        let err = dispatch(&scripts, "x", &c).unwrap_err();
470        // Killed promptly, nowhere near the 5s sleep.
471        assert!(start.elapsed() < std::time::Duration::from_secs(2), "should kill promptly");
472        assert!(format!("{err:#}").contains("timeout"), "{err:#}");
473    }
474
475    #[test]
476    fn disable_process_timeout_callback_lifts_the_limit() {
477        let tmp = tempfile::tempdir().unwrap();
478        let reg = CallbackRegistry::new();
479        let done = tmp.path().join("done");
480        // A 200ms budget would kill `sleep 0.5`, but the callback lifts it
481        // for the rest of the dispatch, so the entry completes.
482        let c = ctx_with_timeout(tmp.path(), &reg, Some(std::time::Duration::from_millis(200)));
483        let scripts = Scripts::parse(&serde_json::json!({ "scripts": { "x": [
484            "Composer\\Config::disableProcessTimeout",
485            format!("sleep 0.5 && : > {}", done.display()),
486        ] } }));
487        dispatch(&scripts, "x", &c).expect("disabled timeout must let the slow entry finish");
488        assert!(done.exists());
489    }
490}