Skip to main content

grex_core/plugin/
mod.rs

1//! Plugin system — Stage A slicing (M4-A).
2//!
3//! Introduces the [`ActionPlugin`] trait and an in-process [`Registry`] as
4//! the canonical registration path for Tier-1 actions. The 7 built-ins that
5//! M3 landed directly inside `grex-core::execute` are re-exposed here
6//! behind the trait so M4-B onwards can extend the surface without touching
7//! the executors.
8//!
9//! # Why not async yet
10//!
11//! The spec earmarks `async fn execute` for the plugin trait (M4 as a
12//! whole). Stage A deliberately keeps the trait synchronous: the wet-run
13//! executor, planner, and scheduler are all synchronous today, and the
14//! `async-trait` crate is not yet in the workspace dependency set. Adding
15//! `async fn` would force every built-in to wrap sync work in an `async`
16//! block, introduce `.await` at every call-site, and pull in a tokio
17//! runtime — all unrelated to the trait-slicing goal. The async switch
18//! belongs to the runtime work in M4-C, not the structural slice here.
19//!
20//! # Why not dispatch via `Registry` inside `FsExecutor` / `PlanExecutor`
21//!
22//! The task brief calls for the concrete executors to look up plugins via
23//! `registry.get(action.name())` instead of matching on the `Action` enum
24//! directly. Doing so requires `FsExecutor` / `PlanExecutor` to carry a
25//! `Registry` field (which is not `Copy`), which cascades into >3-line
26//! edits across ~50 existing test call sites that construct the executors
27//! as bare unit structs. That conflicts with the other explicit rule in
28//! the task brief ("If a test needs >3 line change, stop and report").
29//! Stage A therefore lands the trait + registry surface; the dispatch
30//! swap is queued for M4-B, which can reshape tests alongside the
31//! executor constructors in one pass.
32
33use std::borrow::Cow;
34use std::collections::HashMap;
35
36use crate::execute::{ExecCtx, ExecError, ExecStep};
37use crate::pack::Action;
38
39pub mod pack_type;
40
41#[cfg(feature = "plugin-inventory")]
42pub use pack_type::PackTypePluginSubmission;
43pub use pack_type::{PackTypePlugin, PackTypeRegistry};
44
45/// Uniform registration surface for every Tier-1 action.
46///
47/// Implementations MUST be `Send + Sync` so the registry can be threaded
48/// across executor threads without interior locking. `execute` takes the
49/// parsed [`Action`] (not a `serde_json::Value`): the parse step in
50/// `grex-core::pack` has already validated shape + invariants, and the
51/// executors that will consume this trait in M4-B already own a typed
52/// `&Action`. Taking the typed form keeps the trait zero-cost at the
53/// boundary and defers the "raw `Value` for external plugins" form to the
54/// dylib / WASM work in M5+.
55pub trait ActionPlugin: Send + Sync {
56    /// Short kebab-case name matching the YAML key and [`Action::name`].
57    /// Used as the key inside [`Registry`].
58    fn name(&self) -> &str;
59
60    /// Execute one [`Action`] against `ctx`.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`ExecError`] on variable-expansion failure, invalid paths,
65    /// `require` failure under `on_fail: error`, exec shape invariants, or
66    /// filesystem I/O error (wet-run plugins only).
67    fn execute(&self, action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError>;
68}
69
70/// In-process registry mapping action name → plugin.
71///
72/// The v1 discovery path is explicit: callers construct a registry via
73/// [`Registry::bootstrap`] (all 7 built-ins) or [`Registry::new`] (empty)
74/// and optionally register further plugins with [`Registry::register`].
75/// External dylib / WASM loading is deferred to v2 per the feat-grex spec.
76#[derive(Default)]
77pub struct Registry {
78    actions: HashMap<Cow<'static, str>, Box<dyn ActionPlugin>>,
79}
80
81impl std::fmt::Debug for Registry {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        // Avoid requiring `Debug` on `dyn ActionPlugin`; surface just the
84        // action-name inventory, which is what operators actually want.
85        f.debug_struct("Registry")
86            .field("actions", &self.actions.keys().collect::<Vec<_>>())
87            .finish()
88    }
89}
90
91impl Registry {
92    /// Construct an empty registry. Prefer [`Registry::bootstrap`] unless
93    /// you need a hand-picked plugin set (typical for tests).
94    #[must_use]
95    pub fn new() -> Self {
96        Self { actions: HashMap::new() }
97    }
98
99    /// Register `plugin` under its [`ActionPlugin::name`]. Later
100    /// registrations overwrite earlier ones with the same name — the
101    /// registry is last-writer-wins so higher-priority plugin collections
102    /// can shadow the built-ins after [`Registry::bootstrap`].
103    pub fn register<P: ActionPlugin + 'static>(&mut self, plugin: P) {
104        let name: Cow<'static, str> = Cow::Owned(plugin.name().to_owned());
105        self.actions.insert(name, Box::new(plugin));
106    }
107
108    /// Look up a plugin by name. Returns `None` if nothing is registered
109    /// under that name.
110    #[must_use]
111    pub fn get(&self, name: &str) -> Option<&dyn ActionPlugin> {
112        self.actions.get(name).map(std::convert::AsRef::as_ref)
113    }
114
115    /// Number of registered plugins. Handy for tests and bootstrap
116    /// assertions.
117    #[must_use]
118    pub fn len(&self) -> usize {
119        self.actions.len()
120    }
121
122    /// Whether no plugins are registered.
123    #[must_use]
124    pub fn is_empty(&self) -> bool {
125        self.actions.is_empty()
126    }
127
128    /// Build a registry pre-populated with every Tier-1 built-in
129    /// (`symlink`, `env`, `mkdir`, `rmdir`, `require`, `when`, `exec`) in
130    /// wet-run form.
131    ///
132    /// The built-ins delegate to the existing `execute::fs_executor` free
133    /// functions — there is one struct per action rather than one struct
134    /// per executor so external callers can selectively shadow a single
135    /// built-in without re-deriving all seven.
136    #[must_use]
137    pub fn bootstrap() -> Self {
138        let mut reg = Self::new();
139        register_builtins(&mut reg);
140        reg
141    }
142
143    /// Register every plugin submitted via [`inventory::submit!`] into the
144    /// `PluginSubmission` collector. Order is linker-defined; duplicate
145    /// names follow `register`'s last-writer-wins rule. Safe to call after
146    /// [`Registry::bootstrap`] — inventory entries shadow existing
147    /// registrations like any other `register` call (last-writer-wins).
148    ///
149    /// Only available when the `plugin-inventory` feature is enabled.
150    #[cfg(feature = "plugin-inventory")]
151    pub fn register_from_inventory(&mut self) {
152        for sub in inventory::iter::<PluginSubmission> {
153            let plugin = (sub.factory)();
154            let name: Cow<'static, str> = Cow::Owned(plugin.name().to_owned());
155            self.actions.insert(name, plugin);
156        }
157    }
158
159    /// Build a registry populated exclusively from
160    /// [`inventory::submit!`] entries. Equivalent to
161    /// `let mut r = Registry::new(); r.register_from_inventory(); r`.
162    ///
163    /// Only available when the `plugin-inventory` feature is enabled.
164    #[cfg(feature = "plugin-inventory")]
165    #[must_use]
166    pub fn bootstrap_from_inventory() -> Self {
167        let mut reg = Self::new();
168        reg.register_from_inventory();
169        reg
170    }
171}
172
173/// Submission record for compile-time plugin collection via `inventory`.
174///
175/// Each Tier-1 built-in ships an `inventory::submit!` block (gated by the
176/// same feature) pointing at this type, so a consumer opting into
177/// `plugin-inventory` can construct a `Registry` purely from linker-time
178/// registrations instead of calling [`register_builtins`] explicitly.
179#[cfg(feature = "plugin-inventory")]
180#[non_exhaustive]
181pub struct PluginSubmission {
182    /// Factory producing a boxed plugin instance. Invoked once per
183    /// submission during [`Registry::register_from_inventory`].
184    pub factory: fn() -> Box<dyn ActionPlugin>,
185}
186
187#[cfg(feature = "plugin-inventory")]
188impl PluginSubmission {
189    /// Construct a submission from a plugin factory. Prefer this over
190    /// struct-literal syntax so future fields can be added without
191    /// breaking downstream `inventory::submit!` sites (the type is
192    /// `#[non_exhaustive]`).
193    #[must_use]
194    pub const fn new(factory: fn() -> Box<dyn ActionPlugin>) -> Self {
195        Self { factory }
196    }
197}
198
199#[cfg(feature = "plugin-inventory")]
200inventory::collect!(PluginSubmission);
201
202/// Register all 7 Tier-1 built-in plugins in wet-run form.
203///
204/// Exposed so callers that need a partial registry can start from
205/// [`Registry::new`] and layer built-ins on top of (or under) their own
206/// plugins. [`Registry::bootstrap`] is the common-case shortcut.
207pub fn register_builtins(reg: &mut Registry) {
208    reg.register(SymlinkPlugin);
209    reg.register(UnlinkPlugin);
210    reg.register(EnvPlugin);
211    reg.register(MkdirPlugin);
212    reg.register(RmdirPlugin);
213    reg.register(RequirePlugin);
214    reg.register(WhenPlugin);
215    reg.register(ExecPlugin);
216}
217
218// ---------------------------------------------------------------- builtins
219//
220// Each plugin is a zero-sized wet-run wrapper that defers to the existing
221// `fs_*` free function in `execute::fs_executor`. Colocating them here in
222// `grex-core` avoids a circular dependency with `grex-plugins-builtin`
223// (which depends on `grex-core`); the external crate re-exports
224// [`register_builtins`] as its canonical registration path.
225
226/// Wet-run `symlink` plugin.
227#[derive(Debug, Default, Clone, Copy)]
228pub struct SymlinkPlugin;
229
230impl ActionPlugin for SymlinkPlugin {
231    fn name(&self) -> &str {
232        "symlink"
233    }
234
235    fn execute(&self, action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
236        match action {
237            Action::Symlink(s) => crate::execute::fs_executor::fs_symlink(s, ctx),
238            _ => Err(ExecError::ExecInvalid(format!(
239                "symlink plugin dispatched with non-symlink action `{}`",
240                action.name()
241            ))),
242        }
243    }
244}
245
246#[cfg(feature = "plugin-inventory")]
247inventory::submit!(PluginSubmission::new(|| Box::new(SymlinkPlugin)));
248
249/// Wet-run `unlink` plugin — synthesized inverse of `symlink` used by
250/// the declarative auto-reverse teardown path (R-M5-09). Not reachable
251/// from a YAML-authored pack: the `Action::Unlink` variant is only
252/// manufactured by `DeclarativePlugin::inverse_of` (private helper in
253/// [`crate::plugin::pack_type`]).
254#[derive(Debug, Default, Clone, Copy)]
255pub struct UnlinkPlugin;
256
257impl ActionPlugin for UnlinkPlugin {
258    fn name(&self) -> &str {
259        "unlink"
260    }
261
262    fn execute(&self, action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
263        match action {
264            Action::Unlink(u) => crate::execute::fs_executor::fs_unlink(u, ctx),
265            _ => Err(ExecError::ExecInvalid(format!(
266                "unlink plugin dispatched with non-unlink action `{}`",
267                action.name()
268            ))),
269        }
270    }
271}
272
273#[cfg(feature = "plugin-inventory")]
274inventory::submit!(PluginSubmission::new(|| Box::new(UnlinkPlugin)));
275
276/// Wet-run `env` plugin.
277#[derive(Debug, Default, Clone, Copy)]
278pub struct EnvPlugin;
279
280impl ActionPlugin for EnvPlugin {
281    fn name(&self) -> &str {
282        "env"
283    }
284
285    fn execute(&self, action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
286        match action {
287            Action::Env(e) => crate::execute::fs_executor::fs_env(e, ctx),
288            _ => Err(ExecError::ExecInvalid(format!(
289                "env plugin dispatched with non-env action `{}`",
290                action.name()
291            ))),
292        }
293    }
294}
295
296#[cfg(feature = "plugin-inventory")]
297inventory::submit!(PluginSubmission::new(|| Box::new(EnvPlugin)));
298
299/// Wet-run `mkdir` plugin.
300#[derive(Debug, Default, Clone, Copy)]
301pub struct MkdirPlugin;
302
303impl ActionPlugin for MkdirPlugin {
304    fn name(&self) -> &str {
305        "mkdir"
306    }
307
308    fn execute(&self, action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
309        match action {
310            Action::Mkdir(m) => crate::execute::fs_executor::fs_mkdir(m, ctx),
311            _ => Err(ExecError::ExecInvalid(format!(
312                "mkdir plugin dispatched with non-mkdir action `{}`",
313                action.name()
314            ))),
315        }
316    }
317}
318
319#[cfg(feature = "plugin-inventory")]
320inventory::submit!(PluginSubmission::new(|| Box::new(MkdirPlugin)));
321
322/// Wet-run `rmdir` plugin.
323#[derive(Debug, Default, Clone, Copy)]
324pub struct RmdirPlugin;
325
326impl ActionPlugin for RmdirPlugin {
327    fn name(&self) -> &str {
328        "rmdir"
329    }
330
331    fn execute(&self, action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
332        match action {
333            Action::Rmdir(r) => crate::execute::fs_executor::fs_rmdir(r, ctx),
334            _ => Err(ExecError::ExecInvalid(format!(
335                "rmdir plugin dispatched with non-rmdir action `{}`",
336                action.name()
337            ))),
338        }
339    }
340}
341
342#[cfg(feature = "plugin-inventory")]
343inventory::submit!(PluginSubmission::new(|| Box::new(RmdirPlugin)));
344
345/// `require` plugin (predicate gate; side-effect-free).
346#[derive(Debug, Default, Clone, Copy)]
347pub struct RequirePlugin;
348
349impl ActionPlugin for RequirePlugin {
350    fn name(&self) -> &str {
351        "require"
352    }
353
354    fn execute(&self, action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
355        match action {
356            Action::Require(r) => crate::execute::fs_executor::fs_require(r, ctx),
357            _ => Err(ExecError::ExecInvalid(format!(
358                "require plugin dispatched with non-require action `{}`",
359                action.name()
360            ))),
361        }
362    }
363}
364
365#[cfg(feature = "plugin-inventory")]
366inventory::submit!(PluginSubmission::new(|| Box::new(RequirePlugin)));
367
368/// `when` plugin (conditional block; wet-run).
369#[derive(Debug, Default, Clone, Copy)]
370pub struct WhenPlugin;
371
372impl ActionPlugin for WhenPlugin {
373    fn name(&self) -> &str {
374        "when"
375    }
376
377    fn execute(&self, action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
378        match action {
379            Action::When(w) => {
380                // Nested dispatch is registry-driven via the `registry`
381                // slot on `ExecCtx`. The outer `FsExecutor` attaches its
382                // own `Arc<Registry>` to the ctx before calling us, so
383                // nested actions resolve through the caller's registry
384                // (honouring shadowed or custom plugins) instead of a
385                // freshly bootstrapped set.
386                crate::execute::fs_executor::fs_when(w, ctx)
387            }
388            _ => Err(ExecError::ExecInvalid(format!(
389                "when plugin dispatched with non-when action `{}`",
390                action.name()
391            ))),
392        }
393    }
394}
395
396#[cfg(feature = "plugin-inventory")]
397inventory::submit!(PluginSubmission::new(|| Box::new(WhenPlugin)));
398
399/// Wet-run `exec` plugin.
400#[derive(Debug, Default, Clone, Copy)]
401pub struct ExecPlugin;
402
403impl ActionPlugin for ExecPlugin {
404    fn name(&self) -> &str {
405        "exec"
406    }
407
408    fn execute(&self, action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
409        match action {
410            Action::Exec(x) => crate::execute::fs_executor::fs_exec(x, ctx),
411            _ => Err(ExecError::ExecInvalid(format!(
412                "exec plugin dispatched with non-exec action `{}`",
413                action.name()
414            ))),
415        }
416    }
417}
418
419#[cfg(feature = "plugin-inventory")]
420inventory::submit!(PluginSubmission::new(|| Box::new(ExecPlugin)));
421
422// ---------------------------------------------------------------- tests
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn registry_new_is_empty() {
430        let reg = Registry::new();
431        assert!(reg.is_empty());
432        assert_eq!(reg.len(), 0);
433        assert!(reg.get("symlink").is_none());
434    }
435
436    #[test]
437    fn registry_register_is_last_writer_wins() {
438        // Re-registering a built-in under the same name overwrites the
439        // prior entry instead of stacking duplicates. The resulting
440        // registry size is unchanged.
441        let mut reg = Registry::new();
442        reg.register(SymlinkPlugin);
443        reg.register(SymlinkPlugin);
444        assert_eq!(reg.len(), 1);
445        assert!(reg.get("symlink").is_some());
446    }
447
448    #[test]
449    fn bootstrap_registers_all_eight_builtins() {
450        let reg = Registry::bootstrap();
451        assert_eq!(reg.len(), 8);
452        for name in ["symlink", "unlink", "env", "mkdir", "rmdir", "require", "when", "exec"] {
453            let plugin = reg.get(name).unwrap_or_else(|| panic!("missing built-in `{name}`"));
454            assert_eq!(plugin.name(), name);
455        }
456        assert!(reg.get("unknown").is_none());
457    }
458
459    #[cfg(feature = "plugin-inventory")]
460    #[test]
461    fn bootstrap_from_inventory_registers_all_eight_builtins() {
462        let reg = Registry::bootstrap_from_inventory();
463        assert_eq!(reg.len(), 8);
464        for name in ["symlink", "unlink", "env", "mkdir", "rmdir", "require", "when", "exec"] {
465            let plugin = reg.get(name).unwrap_or_else(|| panic!("missing built-in `{name}`"));
466            assert_eq!(plugin.name(), name);
467        }
468    }
469
470    #[cfg(feature = "plugin-inventory")]
471    #[test]
472    fn register_from_inventory_on_empty_registry_produces_eight_entries() {
473        let mut reg = Registry::new();
474        assert!(reg.is_empty());
475        reg.register_from_inventory();
476        assert_eq!(reg.len(), 8);
477        for name in ["symlink", "unlink", "env", "mkdir", "rmdir", "require", "when", "exec"] {
478            assert!(reg.get(name).is_some(), "missing built-in `{name}`");
479        }
480    }
481
482    #[cfg(feature = "plugin-inventory")]
483    #[test]
484    fn register_from_inventory_twice_dedups_to_eight() {
485        let mut reg = Registry::new();
486        reg.register_from_inventory();
487        reg.register_from_inventory();
488        assert_eq!(reg.len(), 8);
489    }
490}