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