Skip to main content

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}