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}