bougie_scripts/context.rs
1//! Host-injected execution context and the native callback registry.
2
3use std::collections::HashMap;
4use std::path::Path;
5use std::time::Duration;
6
7/// Everything [`dispatch`](crate::dispatch) needs from the host, injected by
8/// the caller so the crate stays FS/PHP-agnostic and testable.
9pub struct ScriptContext<'a> {
10 /// Project root; scripts run with this as their working directory.
11 pub project_root: &'a Path,
12 /// The project's resolved PHP binary, used for `@php` entries.
13 pub php_bin: &'a Path,
14 /// `vendor/bin` (or `config.bin-dir`). Prepended onto `PATH` for the
15 /// dispatch so scripts find installed CLIs (`phpunit`, `pint`, …). The
16 /// host may already have folded this into `base_env`'s `PATH`; the
17 /// prepend is idempotent (skipped if `PATH` already leads with it).
18 pub bin_dir: &'a Path,
19 /// Base environment overrides layered on top of the inherited process
20 /// env: `PATH`, `COMPOSER_DEV_MODE`, `COMPOSER_BINARY`, `BOUGIE_*`, and
21 /// any per-tenant `BOUGIE_SERVICE_*` vars.
22 pub base_env: Vec<(String, String)>,
23 /// Whether dev dependencies are in scope (`COMPOSER_DEV_MODE`).
24 pub dev_mode: bool,
25 /// Per-process wall-clock timeout (Composer's `config.process-timeout`,
26 /// default 300s). Each spawned entry gets its own budget; on expiry the
27 /// child is killed and the event aborts. The
28 /// `Composer\Config::disableProcessTimeout` script callback flips it off
29 /// for the rest of the dispatch. `None` = unlimited.
30 pub timeout: Option<Duration>,
31 /// Native handlers for the callbacks bougie reproduces (keyed by
32 /// `"Class::method"`). A hit runs the handler instead of warn-skipping.
33 pub callbacks: &'a CallbackRegistry,
34}
35
36impl std::fmt::Debug for ScriptContext<'_> {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 f.debug_struct("ScriptContext")
39 .field("project_root", &self.project_root)
40 .field("php_bin", &self.php_bin)
41 .field("bin_dir", &self.bin_dir)
42 .field("dev_mode", &self.dev_mode)
43 .field("timeout", &self.timeout)
44 .field("callbacks", &self.callbacks)
45 .finish_non_exhaustive()
46 }
47}
48
49/// A native handler standing in for a PHP-callback entry. Returns `Err` to
50/// abort the event (same as a non-zero process exit).
51pub type CallbackHandler = Box<dyn Fn(&ScriptContext) -> eyre::Result<()> + Send + Sync>;
52
53/// A curated allowlist of PHP callbacks bougie reproduces natively, mapping
54/// `"Class::method"` → handler. This is **not** a general callback runner:
55/// only the host-registered entries run; every other callback warn-skips.
56#[derive(Default)]
57pub struct CallbackRegistry(HashMap<String, CallbackHandler>);
58
59impl CallbackRegistry {
60 #[must_use]
61 pub fn new() -> Self {
62 Self(HashMap::new())
63 }
64
65 /// Register a handler under a `"Class::method"` key. The key is
66 /// normalised (a single leading `\` on the class is stripped) to match
67 /// how Composer entries may or may not carry the root-namespace slash.
68 pub fn register(&mut self, key: &str, handler: CallbackHandler) {
69 self.0.insert(normalize_key(key), handler);
70 }
71
72 /// Look up a handler for a classified `Class::method` callback.
73 #[must_use]
74 pub fn get(&self, class: &str, method: &str) -> Option<&CallbackHandler> {
75 self.0.get(&normalize_key(&format!("{class}::{method}")))
76 }
77
78 #[must_use]
79 pub fn is_empty(&self) -> bool {
80 self.0.is_empty()
81 }
82}
83
84impl std::fmt::Debug for CallbackRegistry {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 f.debug_struct("CallbackRegistry").field("keys", &self.0.keys()).finish()
87 }
88}
89
90/// Normalise a `Class::method` key: strip one leading namespace `\` so
91/// `\Foo\Bar::baz` and `Foo\Bar::baz` collide.
92fn normalize_key(key: &str) -> String {
93 key.strip_prefix('\\').unwrap_or(key).to_string()
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn registry_lookup_is_leading_slash_insensitive() {
102 let mut reg = CallbackRegistry::new();
103 reg.register("\\Foo\\Bar::baz", Box::new(|_| Ok(())));
104 assert!(reg.get("Foo\\Bar", "baz").is_some());
105 assert!(reg.get("\\Foo\\Bar", "baz").is_some());
106 assert!(reg.get("Foo\\Bar", "other").is_none());
107 }
108}