Skip to main content

grex_core/execute/
fs_executor.rs

1//! Wet-run executor — slice 5b.
2//!
3//! [`FsExecutor`] is the concrete counterpart to
4//! [`super::plan::PlanExecutor`]: same trait surface, real side effects. The
5//! `execute` method stays a thin dispatcher (one arm per action variant) so
6//! cyclomatic complexity lives in the per-action helpers rather than the
7//! vtable entry point.
8//!
9//! # Platform gating
10//!
11//! * Symlink creation uses `std::os::unix::fs::symlink` on Unix and
12//!   `std::os::windows::fs::{symlink_file, symlink_dir}` on Windows.
13//! * Persistent env writes use `winreg` on Windows; Unix returns
14//!   [`ExecError::EnvPersistenceNotSupported`] for `user` / `machine` scopes
15//!   (shell-rc editing is out of scope for this slice).
16//! * Mode bits are applied on Unix only; Windows ignores them.
17//!
18//! # Error propagation
19//!
20//! Every filesystem op routes through a small internal `io_to_fs` helper so
21//! the resulting [`ExecError::FsIo`] carries the op tag and the offending
22//! path. Blanket `From<std::io::Error>` is deliberately avoided so unrelated
23//! call sites cannot silently leak a context-free io error.
24
25use std::borrow::Cow;
26use std::path::{Path, PathBuf};
27use std::process::Command;
28use std::sync::Arc;
29
30use crate::pack::{
31    Action, EnvArgs, EnvScope, ExecOnFail, ExecSpec, MkdirArgs, RequireOnFail, RequireSpec,
32    RmdirArgs, SymlinkArgs, SymlinkKind, UnlinkArgs, WhenSpec,
33};
34use crate::plugin::Registry;
35use crate::vars::{expand, VarEnv};
36
37use super::ctx::ExecCtx;
38use super::error::{io_to_fs, ExecError, EXEC_STDERR_CAPTURE_MAX};
39use super::predicate::{evaluate, evaluate_when_gate};
40use super::step::{
41    ExecResult, ExecStep, PredicateOutcome, StepKind, ACTION_ENV, ACTION_EXEC, ACTION_MKDIR,
42    ACTION_REQUIRE, ACTION_RMDIR, ACTION_SYMLINK, ACTION_UNLINK, ACTION_WHEN,
43};
44use super::ActionExecutor;
45
46/// Wet-run [`ActionExecutor`] — performs real filesystem and process work.
47///
48/// Dispatch is registry-driven (M4-B S1): every action is resolved to an
49/// [`crate::plugin::ActionPlugin`] via the embedded [`Registry`] and the
50/// plugin's `execute` method is invoked. The registry is wrapped in an
51/// [`Arc`] so the executor stays `Clone` and cheap to share across
52/// threads; cloning the executor bumps a refcount rather than duplicating
53/// plugin state.
54///
55/// Callers are responsible for driving the sequence (plan-phase validators,
56/// ordering, rollback on failure); `FsExecutor` operates on one action at a
57/// time and never looks at peers.
58#[derive(Debug, Clone)]
59pub struct FsExecutor {
60    registry: Arc<Registry>,
61}
62
63impl Default for FsExecutor {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69impl FsExecutor {
70    /// Construct a fresh wet-run executor backed by the full Tier-1
71    /// built-in registry ([`Registry::bootstrap`]). Equivalent to the
72    /// pre-M4-B signature; existing test sites continue to compile.
73    #[must_use]
74    pub fn new() -> Self {
75        Self { registry: Arc::new(Registry::bootstrap()) }
76    }
77
78    /// Construct a wet-run executor backed by an explicit registry.
79    ///
80    /// Used by the sync driver (which builds one registry at CLI entry
81    /// and shares it across executors) and by tests that need to exercise
82    /// the [`ExecError::UnknownAction`] path or shadow a built-in. For
83    /// typical call sites the bootstrapped [`FsExecutor::new`] is the
84    /// right default.
85    #[must_use]
86    pub fn with_registry(registry: Arc<Registry>) -> Self {
87        Self { registry }
88    }
89}
90
91impl ActionExecutor for FsExecutor {
92    fn name(&self) -> &'static str {
93        "fs"
94    }
95
96    fn execute(&self, action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
97        let name = action.name();
98        let plugin =
99            self.registry.get(name).ok_or_else(|| ExecError::UnknownAction(name.to_string()))?;
100        // Attach our registry to the ctx so plugins that recurse (today:
101        // `when`) can dispatch nested actions through the same registry
102        // the caller handed us — preventing a fresh bootstrap that would
103        // shadow caller-registered custom plugins.
104        let nested_ctx = ExecCtx {
105            vars: ctx.vars,
106            pack_root: ctx.pack_root,
107            workspace: ctx.workspace,
108            platform: ctx.platform,
109            registry: Some(&self.registry),
110            pack_type_registry: ctx.pack_type_registry,
111            visited_meta: ctx.visited_meta,
112            // feat-m6-1: propagate the scheduler handle unchanged.
113            scheduler: ctx.scheduler,
114        };
115        plugin.execute(action, &nested_ctx)
116    }
117}
118
119// ---------------------------------------------------------------- shared
120
121fn expand_field(raw: &str, env: &VarEnv, field: &'static str) -> Result<String, ExecError> {
122    expand(raw, env).map_err(|source| ExecError::VarExpand { field, source })
123}
124
125fn require_path(expanded: String) -> Result<PathBuf, ExecError> {
126    if expanded.is_empty() {
127        return Err(ExecError::InvalidPath(expanded));
128    }
129    Ok(PathBuf::from(expanded))
130}
131
132// ---------------------------------------------------------------- symlink
133
134pub(crate) fn fs_symlink(args: &SymlinkArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
135    let src = require_path(expand_field(&args.src, ctx.vars, "symlink.src")?)?;
136    let dst = require_path(expand_field(&args.dst, ctx.vars, "symlink.dst")?)?;
137
138    let result = match classify_symlink_dst(&src, &dst) {
139        SymlinkState::AlreadyCorrect => ExecResult::AlreadySatisfied,
140        SymlinkState::Missing => {
141            create_symlink(&src, &dst, args.kind)?;
142            ExecResult::PerformedChange
143        }
144        SymlinkState::OccupiedByOther => {
145            if !args.backup {
146                return Err(ExecError::SymlinkDestOccupied { dst: dst.clone() });
147            }
148            // NOTE (PR E): logging backup intent into the event log before
149            // the rename belongs to halt-state persistence and is tracked
150            // separately; the in-executor rollback below is the minimum
151            // needed to avoid a "backup orphan" when create fails.
152            let backup = backup_path(&dst)?;
153            match create_symlink(&src, &dst, args.kind) {
154                Ok(()) => ExecResult::PerformedChange,
155                Err(create_err) => {
156                    return Err(rollback_or_orphan(&dst, &backup, create_err));
157                }
158            }
159        }
160    };
161
162    Ok(ExecStep {
163        action_name: Cow::Borrowed(ACTION_SYMLINK),
164        result,
165        details: StepKind::Symlink {
166            src,
167            dst,
168            kind: args.kind,
169            backup: args.backup,
170            normalize: args.normalize,
171        },
172    })
173}
174
175enum SymlinkState {
176    AlreadyCorrect,
177    Missing,
178    OccupiedByOther,
179}
180
181fn classify_symlink_dst(src: &Path, dst: &Path) -> SymlinkState {
182    match std::fs::symlink_metadata(dst) {
183        Err(_) => SymlinkState::Missing,
184        Ok(meta) if meta.file_type().is_symlink() => match std::fs::read_link(dst) {
185            Ok(target) if target == src => SymlinkState::AlreadyCorrect,
186            _ => SymlinkState::OccupiedByOther,
187        },
188        Ok(_) => SymlinkState::OccupiedByOther,
189    }
190}
191
192/// Rename `dst` to `<dst>.grex.bak`, overwriting any prior backup.
193///
194/// Returns the backup path on success so the caller can attempt a rollback
195/// if the next step (e.g. symlink creation) fails.
196///
197/// This is a deliberately simple convention — one canonical backup slot per
198/// path. More elaborate tombstones (timestamped, rotated) belong in the
199/// future teardown runner.
200fn backup_path(dst: &Path) -> Result<PathBuf, ExecError> {
201    let mut backup = dst.as_os_str().to_owned();
202    backup.push(".grex.bak");
203    let backup = PathBuf::from(backup);
204    // Best-effort remove of an existing backup before rename — if it fails
205    // we let the rename surface a clean error rather than masking it.
206    let _ = std::fs::remove_file(&backup);
207    let _ = std::fs::remove_dir_all(&backup);
208    std::fs::rename(dst, &backup).map_err(|e| io_to_fs("rename", dst.to_path_buf(), e))?;
209    Ok(backup)
210}
211
212/// After a backup-then-create sequence where create failed, attempt to
213/// rename the backup back to `dst`. Maps the outcome to an appropriate
214/// [`ExecError`]:
215///
216/// * restore succeeds → [`ExecError::FsIo`] with op `"symlink"` (the
217///   original create failure, dst restored — user sees a clean symlink
218///   error and the prior file is back where it was).
219/// * restore fails → [`ExecError::SymlinkCreateAfterBackupFailed`] carrying
220///   both error strings so the operator knows the backup is the only
221///   remaining artifact.
222fn rollback_or_orphan(dst: &Path, backup: &Path, create_err: ExecError) -> ExecError {
223    let create_detail = create_err.to_string();
224    match std::fs::rename(backup, dst) {
225        Ok(()) => {
226            // Backup is restored; surface the original create failure so the
227            // caller knows the action did not complete.
228            create_err
229        }
230        Err(restore_err) => ExecError::SymlinkCreateAfterBackupFailed {
231            dst: dst.to_path_buf(),
232            backup: backup.to_path_buf(),
233            create_error: create_detail,
234            restore_error: Some(restore_err.to_string()),
235        },
236    }
237}
238
239#[cfg(unix)]
240fn create_symlink(src: &Path, dst: &Path, _kind: SymlinkKind) -> Result<(), ExecError> {
241    std::os::unix::fs::symlink(src, dst).map_err(|e| io_to_fs("symlink", dst.to_path_buf(), e))
242}
243
244#[cfg(windows)]
245fn create_symlink(src: &Path, dst: &Path, kind: SymlinkKind) -> Result<(), ExecError> {
246    let resolved = resolve_windows_symlink_kind(src, kind)?;
247    let result = match resolved {
248        SymlinkKind::Directory => std::os::windows::fs::symlink_dir(src, dst),
249        // `Auto` is resolved to `File` or `Directory` by the helper above;
250        // seeing it here would be a logic bug, so fall back to File
251        // defensively rather than panicking.
252        SymlinkKind::File | SymlinkKind::Auto => std::os::windows::fs::symlink_file(src, dst),
253    };
254    result.map_err(|e| map_windows_symlink_error(dst, e))
255}
256
257/// Resolve a `kind: auto` symlink declaration to `File` or `Directory` by
258/// stat-ing `src`. Explicit kinds pass through unchanged.
259///
260/// When `kind: auto` is set and `src` does not exist, the Win32 file vs.
261/// directory distinction cannot be inferred; returns
262/// [`ExecError::SymlinkAutoKindUnresolvable`] with an actionable message
263/// rather than silently picking `File` and producing a broken reparse
264/// point.
265#[cfg(windows)]
266fn resolve_windows_symlink_kind(src: &Path, kind: SymlinkKind) -> Result<SymlinkKind, ExecError> {
267    match kind {
268        SymlinkKind::File | SymlinkKind::Directory => Ok(kind),
269        SymlinkKind::Auto => match std::fs::symlink_metadata(src) {
270            Ok(meta) if meta.file_type().is_dir() => Ok(SymlinkKind::Directory),
271            Ok(_) => Ok(SymlinkKind::File),
272            Err(e) => Err(ExecError::SymlinkAutoKindUnresolvable {
273                src: src.to_path_buf(),
274                detail: e.to_string(),
275            }),
276        },
277    }
278}
279
280#[cfg(windows)]
281fn map_windows_symlink_error(dst: &Path, err: std::io::Error) -> ExecError {
282    // Windows raw OS error 1314 = ERROR_PRIVILEGE_NOT_HELD.
283    if err.raw_os_error() == Some(1314) {
284        return ExecError::SymlinkPrivilegeDenied { detail: err.to_string() };
285    }
286    io_to_fs("symlink", dst.to_path_buf(), err)
287}
288
289// ---------------------------------------------------------------- unlink
290
291/// Wet-run `unlink` — synthesized inverse of [`fs_symlink`] for
292/// auto-reverse teardown (R-M5-09).
293///
294/// Only removes the file at `dst` when [`std::fs::symlink_metadata`]
295/// reports it IS a symlink. Non-symlink targets (regular files,
296/// directories, nonexistent paths) return
297/// [`ExecResult::AlreadySatisfied`] so a misdirected teardown cannot
298/// clobber operator-managed content.
299pub(crate) fn fs_unlink(args: &UnlinkArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
300    let dst = require_path(expand_field(&args.dst, ctx.vars, "unlink.dst")?)?;
301    let result = match std::fs::symlink_metadata(&dst) {
302        Ok(meta) if meta.file_type().is_symlink() => {
303            std::fs::remove_file(&dst).map_err(|e| io_to_fs("unlink", dst.clone(), e))?;
304            ExecResult::PerformedChange
305        }
306        _ => ExecResult::AlreadySatisfied,
307    };
308    Ok(ExecStep {
309        action_name: Cow::Borrowed(ACTION_UNLINK),
310        result,
311        details: StepKind::Unlink { dst },
312    })
313}
314
315// ---------------------------------------------------------------- env
316
317pub(crate) fn fs_env(args: &EnvArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
318    let value = expand_field(&args.value, ctx.vars, "env.value")?;
319    apply_env(&args.name, &value, args.scope)?;
320    Ok(ExecStep {
321        action_name: Cow::Borrowed(ACTION_ENV),
322        result: ExecResult::PerformedChange,
323        details: StepKind::Env { name: args.name.clone(), value, scope: args.scope },
324    })
325}
326
327fn apply_env(name: &str, value: &str, scope: EnvScope) -> Result<(), ExecError> {
328    match scope {
329        EnvScope::Session => {
330            // SAFETY: `set_var` is unsafe in nightly editions; on stable it's
331            // still safe. Process-scoped env is transient — the wet-run docs
332            // note this.
333            std::env::set_var(name, value);
334            Ok(())
335        }
336        EnvScope::User => apply_env_user(name, value),
337        EnvScope::Machine => apply_env_machine(name, value),
338    }
339}
340
341#[cfg(windows)]
342fn apply_env_user(name: &str, value: &str) -> Result<(), ExecError> {
343    use winreg::enums::{HKEY_CURRENT_USER, KEY_SET_VALUE};
344    use winreg::RegKey;
345    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
346    let env = hkcu.open_subkey_with_flags("Environment", KEY_SET_VALUE).map_err(|e| {
347        ExecError::EnvPersistenceDenied { scope: "user".to_string(), detail: e.to_string() }
348    })?;
349    env.set_value(name, &value.to_string()).map_err(|e| ExecError::EnvPersistenceDenied {
350        scope: "user".to_string(),
351        detail: e.to_string(),
352    })
353}
354
355#[cfg(not(windows))]
356fn apply_env_user(_name: &str, _value: &str) -> Result<(), ExecError> {
357    Err(ExecError::EnvPersistenceNotSupported {
358        scope: "user".to_string(),
359        platform: std::env::consts::OS,
360    })
361}
362
363#[cfg(windows)]
364fn apply_env_machine(name: &str, value: &str) -> Result<(), ExecError> {
365    use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_SET_VALUE};
366    use winreg::RegKey;
367    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
368    let env = hklm
369        .open_subkey_with_flags(
370            r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
371            KEY_SET_VALUE,
372        )
373        .map_err(|e| ExecError::EnvPersistenceDenied {
374            scope: "machine".to_string(),
375            detail: e.to_string(),
376        })?;
377    env.set_value(name, &value.to_string()).map_err(|e| ExecError::EnvPersistenceDenied {
378        scope: "machine".to_string(),
379        detail: e.to_string(),
380    })
381}
382
383#[cfg(not(windows))]
384fn apply_env_machine(_name: &str, _value: &str) -> Result<(), ExecError> {
385    Err(ExecError::EnvPersistenceNotSupported {
386        scope: "machine".to_string(),
387        platform: std::env::consts::OS,
388    })
389}
390
391// ---------------------------------------------------------------- mkdir
392
393pub(crate) fn fs_mkdir(args: &MkdirArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
394    let path = require_path(expand_field(&args.path, ctx.vars, "mkdir.path")?)?;
395    let result = apply_mkdir(&path, args.mode.as_deref())?;
396    Ok(ExecStep {
397        action_name: Cow::Borrowed(ACTION_MKDIR),
398        result,
399        details: StepKind::Mkdir { path, mode: args.mode.clone() },
400    })
401}
402
403fn apply_mkdir(path: &Path, mode: Option<&str>) -> Result<ExecResult, ExecError> {
404    match std::fs::symlink_metadata(path) {
405        Ok(meta) if meta.file_type().is_dir() => return Ok(ExecResult::AlreadySatisfied),
406        Ok(_) => {
407            return Err(ExecError::PathConflict {
408                path: path.to_path_buf(),
409                reason: "exists as file",
410            });
411        }
412        Err(_) => {}
413    }
414    std::fs::create_dir_all(path).map_err(|e| io_to_fs("create_dir", path.to_path_buf(), e))?;
415    apply_mode(path, mode)?;
416    Ok(ExecResult::PerformedChange)
417}
418
419#[cfg(unix)]
420fn apply_mode(path: &Path, mode: Option<&str>) -> Result<(), ExecError> {
421    use std::os::unix::fs::PermissionsExt;
422    let Some(mode) = mode else { return Ok(()) };
423    let Ok(bits) = u32::from_str_radix(mode, 8) else {
424        return Err(ExecError::InvalidPath(format!("invalid POSIX mode `{mode}`")));
425    };
426    std::fs::set_permissions(path, std::fs::Permissions::from_mode(bits))
427        .map_err(|e| io_to_fs("set_permissions", path.to_path_buf(), e))
428}
429
430/// Mode bits are POSIX-specific; Windows silently accepts the parsed value
431/// as a no-op so pack authors can publish cross-platform manifests.
432#[cfg(windows)]
433fn apply_mode(_path: &Path, _mode: Option<&str>) -> Result<(), ExecError> {
434    Ok(())
435}
436
437// ---------------------------------------------------------------- rmdir
438
439pub(crate) fn fs_rmdir(args: &RmdirArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
440    let path = require_path(expand_field(&args.path, ctx.vars, "rmdir.path")?)?;
441    let result = apply_rmdir(&path, args.backup, args.force)?;
442    Ok(ExecStep {
443        action_name: Cow::Borrowed(ACTION_RMDIR),
444        result,
445        details: StepKind::Rmdir { path, backup: args.backup, force: args.force },
446    })
447}
448
449fn apply_rmdir(path: &Path, backup: bool, force: bool) -> Result<ExecResult, ExecError> {
450    if !path.exists() {
451        return Ok(ExecResult::NoOp);
452    }
453    if backup {
454        backup_with_timestamp(path)?;
455        return Ok(ExecResult::PerformedChange);
456    }
457    let res = if force { std::fs::remove_dir_all(path) } else { std::fs::remove_dir(path) };
458    match res {
459        Ok(()) => Ok(ExecResult::PerformedChange),
460        Err(e) if !force && is_not_empty(&e) => {
461            Err(ExecError::RmdirNotEmpty { path: path.to_path_buf() })
462        }
463        Err(e) => Err(io_to_fs("remove_dir", path.to_path_buf(), e)),
464    }
465}
466
467/// `ErrorKind::DirectoryNotEmpty` is nightly-only; sniff the raw OS error
468/// instead (ENOTEMPTY on POSIX, ERROR_DIR_NOT_EMPTY = 145 on Windows).
469fn is_not_empty(err: &std::io::Error) -> bool {
470    #[cfg(unix)]
471    {
472        matches!(err.raw_os_error(), Some(libc_enotempty) if libc_enotempty == 39 || libc_enotempty == 66)
473    }
474    #[cfg(windows)]
475    {
476        err.raw_os_error() == Some(145)
477    }
478    #[cfg(not(any(unix, windows)))]
479    {
480        let _ = err;
481        false
482    }
483}
484
485/// Rename `path` to `<path>.grex.bak.<unix_ts_nanos>` so multiple rmdir
486/// backups across a session never collide.
487fn backup_with_timestamp(path: &Path) -> Result<(), ExecError> {
488    let ts = std::time::SystemTime::now()
489        .duration_since(std::time::UNIX_EPOCH)
490        .map(|d| d.as_nanos())
491        .unwrap_or(0);
492    let mut backup = path.as_os_str().to_owned();
493    backup.push(format!(".grex.bak.{ts}"));
494    let backup = PathBuf::from(backup);
495    std::fs::rename(path, &backup).map_err(|e| io_to_fs("rename", path.to_path_buf(), e))
496}
497
498// ---------------------------------------------------------------- require
499
500pub(crate) fn fs_require(spec: &RequireSpec, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
501    let satisfied = evaluate_combiner(&spec.combiner, ctx)?;
502    let outcome =
503        if satisfied { PredicateOutcome::Satisfied } else { PredicateOutcome::Unsatisfied };
504    let result = classify_require(satisfied, spec.on_fail)?;
505    Ok(ExecStep {
506        action_name: Cow::Borrowed(ACTION_REQUIRE),
507        result,
508        details: StepKind::Require { outcome, on_fail: spec.on_fail },
509    })
510}
511
512fn evaluate_combiner(
513    combiner: &crate::pack::Combiner,
514    ctx: &ExecCtx<'_>,
515) -> Result<bool, ExecError> {
516    use crate::pack::Combiner;
517    match combiner {
518        Combiner::AllOf(list) => {
519            for p in list {
520                if !evaluate(p, ctx)? {
521                    return Ok(false);
522                }
523            }
524            Ok(true)
525        }
526        Combiner::AnyOf(list) => {
527            for p in list {
528                if evaluate(p, ctx)? {
529                    return Ok(true);
530                }
531            }
532            Ok(false)
533        }
534        Combiner::NoneOf(list) => {
535            for p in list {
536                if evaluate(p, ctx)? {
537                    return Ok(false);
538                }
539            }
540            Ok(true)
541        }
542    }
543}
544
545fn classify_require(satisfied: bool, on_fail: RequireOnFail) -> Result<ExecResult, ExecError> {
546    if satisfied {
547        return Ok(ExecResult::AlreadySatisfied);
548    }
549    match on_fail {
550        RequireOnFail::Error => {
551            Err(ExecError::RequireFailed { detail: "combiner evaluated to false".to_string() })
552        }
553        RequireOnFail::Skip => Ok(ExecResult::NoOp),
554        RequireOnFail::Warn => {
555            tracing::warn!(target: "grex::execute", "require predicate unsatisfied (on_fail=warn)");
556            Ok(ExecResult::NoOp)
557        }
558    }
559}
560
561// ---------------------------------------------------------------- when
562
563/// Wet-run `when` dispatch.
564///
565/// Nested actions are routed through the registry attached to `ctx` by the
566/// outer [`FsExecutor::execute`] so custom plugins registered by the caller
567/// are honoured inside `when` bodies. If no registry is attached (direct
568/// plugin invocation in a test that bypassed the executor), we fall back
569/// to a fresh bootstrap registry — the historical Stage-A behaviour —
570/// which preserves the built-in semantics.
571pub(crate) fn fs_when(spec: &WhenSpec, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
572    let branch_taken = evaluate_when_gate(spec, ctx)?;
573    let (result, nested_steps) = if branch_taken {
574        let mut out = Vec::with_capacity(spec.actions.len());
575        for a in &spec.actions {
576            out.push(dispatch_nested(a, ctx)?);
577        }
578        (ExecResult::PerformedChange, out)
579    } else {
580        (ExecResult::NoOp, Vec::new())
581    };
582    Ok(ExecStep {
583        action_name: Cow::Borrowed(ACTION_WHEN),
584        result,
585        details: StepKind::When { branch_taken, nested_steps },
586    })
587}
588
589/// Dispatch one nested wet-run action via the registry attached to `ctx`.
590/// Falls back to a bootstrap registry when none is attached so direct
591/// plugin invocations in tests still resolve the Tier-1 built-ins.
592fn dispatch_nested(action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
593    let name = action.name();
594    match ctx.registry {
595        Some(reg) => {
596            let plugin = reg.get(name).ok_or_else(|| ExecError::UnknownAction(name.to_string()))?;
597            plugin.execute(action, ctx)
598        }
599        None => {
600            let fallback = FsExecutor::new();
601            fallback.execute(action, ctx)
602        }
603    }
604}
605
606// ---------------------------------------------------------------- exec
607
608pub(crate) fn fs_exec(spec: &ExecSpec, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
609    let cwd = match spec.cwd.as_deref() {
610        Some(s) => Some(require_path(expand_field(s, ctx.vars, "exec.cwd")?)?),
611        None => None,
612    };
613    let (cmdline, status, stderr) = spawn_exec(spec, cwd.as_deref(), ctx.vars)?;
614    let result = classify_exec(status, spec.on_fail, &cmdline, &stderr)?;
615    Ok(ExecStep {
616        action_name: Cow::Borrowed(ACTION_EXEC),
617        result,
618        details: StepKind::Exec { cmdline, cwd, on_fail: spec.on_fail, shell: spec.shell },
619    })
620}
621
622/// Spawn the child and collect its exit code plus captured stderr.
623///
624/// Uses [`Command::output`] instead of [`Command::status`] so stderr is
625/// retained and can be folded into
626/// [`ExecError::ExecNonZero::stderr`] when the child exits non-zero.
627/// Stdout is captured as a side effect but currently dropped — the M3
628/// spec does not surface it and keeping the capture bounded is enough for
629/// the halt-diagnostics use case.
630fn spawn_exec(
631    spec: &ExecSpec,
632    cwd: Option<&Path>,
633    vars: &VarEnv,
634) -> Result<(String, i32, String), ExecError> {
635    let (mut cmd, display) = build_command(spec, vars)?;
636    if let Some(dir) = cwd {
637        cmd.current_dir(dir);
638    }
639    if let Some(env_map) = &spec.env {
640        for (k, v) in env_map {
641            let expanded = expand_field(v, vars, "exec.env")?;
642            cmd.env(k, expanded);
643        }
644    }
645    let out = cmd.output().map_err(|e| ExecError::ExecSpawnFailed {
646        command: display.clone(),
647        detail: e.to_string(),
648    })?;
649    let code = out.status.code().unwrap_or(-1);
650    let stderr = truncate_stderr(&out.stderr);
651    Ok((display, code, stderr))
652}
653
654/// Lossy-decode captured stderr bytes into UTF-8 and truncate the tail to
655/// [`EXEC_STDERR_CAPTURE_MAX`] bytes.
656///
657/// We keep the **tail** (most recent output) because shell errors and
658/// stack traces typically surface diagnostic content at the end. Returns
659/// the empty string if the child produced no stderr.
660fn truncate_stderr(bytes: &[u8]) -> String {
661    if bytes.is_empty() {
662        return String::new();
663    }
664    let start = bytes.len().saturating_sub(EXEC_STDERR_CAPTURE_MAX);
665    String::from_utf8_lossy(&bytes[start..]).into_owned()
666}
667
668fn build_command(spec: &ExecSpec, vars: &VarEnv) -> Result<(Command, String), ExecError> {
669    match (spec.shell, &spec.cmd, &spec.cmd_shell) {
670        (false, Some(argv), None) => build_argv_command(argv, vars),
671        (true, None, Some(line)) => build_shell_command(line, vars),
672        _ => Err(ExecError::ExecInvalid(
673            "exec requires cmd (shell=false) XOR cmd_shell (shell=true)".to_string(),
674        )),
675    }
676}
677
678fn build_argv_command(argv: &[String], vars: &VarEnv) -> Result<(Command, String), ExecError> {
679    if argv.is_empty() {
680        return Err(ExecError::ExecInvalid("exec.cmd is empty".to_string()));
681    }
682    let mut expanded = Vec::with_capacity(argv.len());
683    for a in argv {
684        expanded.push(expand_field(a, vars, "exec.cmd")?);
685    }
686    let mut cmd = Command::new(&expanded[0]);
687    cmd.args(&expanded[1..]);
688    Ok((cmd, expanded.join(" ")))
689}
690
691fn build_shell_command(line: &str, vars: &VarEnv) -> Result<(Command, String), ExecError> {
692    let expanded = expand_field(line, vars, "exec.cmd_shell")?;
693    #[cfg(windows)]
694    let (program, flag) = ("cmd", "/C");
695    #[cfg(not(windows))]
696    let (program, flag) = ("sh", "-c");
697    let mut cmd = Command::new(program);
698    cmd.arg(flag).arg(&expanded);
699    Ok((cmd, expanded))
700}
701
702fn classify_exec(
703    status: i32,
704    on_fail: ExecOnFail,
705    cmdline: &str,
706    stderr: &str,
707) -> Result<ExecResult, ExecError> {
708    if status == 0 {
709        return Ok(ExecResult::PerformedChange);
710    }
711    match on_fail {
712        ExecOnFail::Error => Err(ExecError::ExecNonZero {
713            status,
714            command: cmdline.to_string(),
715            stderr: stderr.to_string(),
716        }),
717        ExecOnFail::Warn => {
718            tracing::warn!(
719                target: "grex::execute",
720                status,
721                command = %cmdline,
722                stderr = %stderr,
723                "exec returned non-zero (on_fail=warn)"
724            );
725            Ok(ExecResult::PerformedChange)
726        }
727        ExecOnFail::Ignore => Ok(ExecResult::NoOp),
728    }
729}