Skip to main content

grex_core/plugin/
mod.rs

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