1use 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
14const DISABLE_TIMEOUT_CALLBACK: &str = "Composer\\Config::disableProcessTimeout";
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum EntryOutcome {
23 Ran,
25 SkippedCallback(String),
27 NativeCallback,
29 SkippedComposer(String),
31}
32
33pub 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 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 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 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
127fn 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
147fn normalize_callback(class: &str, method: &str) -> String {
150 format!("{}::{method}", class.strip_prefix('\\').unwrap_or(class))
151}
152
153fn 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 #[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#[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#[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 Noop,
253 Unmapped,
255}
256
257fn map_composer(args: &str) -> ComposerMap {
261 match args.split_whitespace().next() {
262 Some("install" | "update" | "dump-autoload" | "dumpautoload" | "dump") => ComposerMap::Noop,
266 _ => ComposerMap::Unmapped,
267 }
268}
269
270fn 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 let scripts = Scripts::parse(&serde_json::json!({
333 "scripts": { "post-install-cmd": [format!(": > {}", sentinel.display())] }
334 }));
335 let c = ctx(tmp.path(), ®, vec![]);
336 dispatch(&scripts, "post-install-cmd", &c).unwrap();
337 assert!(sentinel.exists());
338
339 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(), ®, 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(), ®, 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(), ®, 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(), ®, 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(), ®, 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: ®,
436 };
437 let env = seed_env(&c);
438 assert_eq!(env.get("PATH").unwrap(), "/opt/proj/vendor/bin:/usr/bin");
439 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 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(), ®, 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 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 let c = ctx_with_timeout(tmp.path(), ®, 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}