Skip to main content

grex_core/execute/
plan.rs

1//! Dry-run executor.
2//!
3//! [`PlanExecutor`] implements [`super::ActionExecutor`] without mutating
4//! state. Every action string field is passed through
5//! [`crate::vars::expand`] and every filesystem idempotency check goes
6//! through read-only syscalls (`symlink_metadata`, `Path::exists`,
7//! `std::env::var`). No spawns, no writes, no registry probes.
8//!
9//! Errors distinguish three layers:
10//! 1. Variable expansion failure → [`ExecError::VarExpand`].
11//! 2. Expanded path is empty or otherwise unusable → [`ExecError::InvalidPath`].
12//! 3. A `require` predicate held false under `on_fail: error` →
13//!    [`ExecError::RequireFailed`].
14//!
15//! Anything else (`when.os` not matching, `require` skip/warn) emits an
16//! [`ExecStep`] with [`ExecResult::NoOp`] — "nothing to do here, carry on".
17
18use std::borrow::Cow;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21
22use crate::pack::{
23    Action, Combiner, EnvArgs, ExecOnFail, ExecSpec, MkdirArgs, RequireOnFail, RequireSpec,
24    RmdirArgs, SymlinkArgs, UnlinkArgs, WhenSpec,
25};
26use crate::plugin::Registry;
27use crate::vars::{expand, VarEnv};
28
29use super::ctx::ExecCtx;
30use super::error::ExecError;
31use super::predicate::{evaluate, evaluate_when_gate};
32use super::step::{
33    ExecResult, ExecStep, PredicateOutcome, StepKind, ACTION_ENV, ACTION_EXEC, ACTION_MKDIR,
34    ACTION_REQUIRE, ACTION_RMDIR, ACTION_SYMLINK, ACTION_UNLINK, ACTION_WHEN,
35};
36use super::ActionExecutor;
37
38/// Dry-run [`ActionExecutor`] — never mutates state.
39///
40/// Dispatch is registry-validated (M4-B S1): every action's
41/// [`Action::name`] is looked up in the embedded plugin [`Registry`] so
42/// unknown action kinds surface as [`ExecError::UnknownAction`] with the
43/// same taxonomy as [`crate::execute::FsExecutor`]. The planner keeps its
44/// own dry-run implementations (the Tier-1 [`crate::plugin::ActionPlugin`]
45/// set is wet-run only) and uses the registry purely as a name oracle.
46///
47/// Useful for `grex plan`, CI validation, and unit-testing pack semantics.
48/// Safe to call across threads; `Clone` bumps the inner [`Arc`] refcount.
49#[derive(Debug, Clone)]
50pub struct PlanExecutor {
51    registry: Arc<Registry>,
52}
53
54impl Default for PlanExecutor {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl PlanExecutor {
61    /// Construct a fresh planner backed by the full Tier-1 built-in
62    /// registry ([`Registry::bootstrap`]). Matches the pre-M4-B
63    /// constructor shape so existing test sites continue to compile.
64    #[must_use]
65    pub fn new() -> Self {
66        Self { registry: Arc::new(Registry::bootstrap()) }
67    }
68
69    /// Construct a planner backed by an explicit registry. Primarily for
70    /// tests that want to exercise [`ExecError::UnknownAction`] or share
71    /// a single registry instance with the wet-run executor.
72    #[must_use]
73    pub fn with_registry(registry: Arc<Registry>) -> Self {
74        Self { registry }
75    }
76}
77
78impl ActionExecutor for PlanExecutor {
79    fn name(&self) -> &'static str {
80        "plan"
81    }
82
83    fn execute(&self, action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
84        let name = action.name();
85        // Registry membership gates dispatch so `PlanExecutor` surfaces
86        // the same `UnknownAction` taxonomy as `FsExecutor`. The planner
87        // then delegates to its own dry-run `plan_*` helpers — Tier-1
88        // plugins are wet-run only and would mutate state if invoked here,
89        // so the registry is used purely as a name oracle.
90        if self.registry.get(name).is_none() {
91            return Err(ExecError::UnknownAction(name.to_string()));
92        }
93        // Attach our registry to the ctx so nested dry-run dispatch
94        // (today: `when`) can perform the same name-oracle check and
95        // stays symmetric with `FsExecutor`.
96        let nested_ctx = ExecCtx {
97            vars: ctx.vars,
98            pack_root: ctx.pack_root,
99            workspace: ctx.workspace,
100            // v1.3.0: pack added as additive sibling. workspace retained for ABI stability through v1.x. Both hold identical value.
101            pack: ctx.pack,
102            platform: ctx.platform,
103            registry: Some(&self.registry),
104            pack_type_registry: ctx.pack_type_registry,
105            visited_meta: ctx.visited_meta,
106            // feat-m6-1: propagate the scheduler handle unchanged.
107            scheduler: ctx.scheduler,
108        };
109        dispatch_plan(action, &nested_ctx)
110    }
111}
112
113/// Dry-run dispatch table keyed by [`Action`] variant. Kept as a free
114/// function (not a method) so the planner struct stays a thin registry
115/// wrapper and the per-variant logic remains colocated with the other
116/// `plan_*` helpers in this module. The `match` is exhaustive across the
117/// seven Tier-1 action kinds returned by [`Action::name`]; any unknown
118/// name has already been rejected by [`PlanExecutor::execute`], so no
119/// fallback arm is needed.
120fn dispatch_plan(action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
121    match action {
122        Action::Symlink(s) => plan_symlink(s, ctx),
123        Action::Unlink(u) => plan_unlink(u, ctx),
124        Action::Env(e) => plan_env(e, ctx),
125        Action::Mkdir(m) => plan_mkdir(m, ctx),
126        Action::Rmdir(r) => plan_rmdir(r, ctx),
127        Action::Require(r) => plan_require(r, ctx),
128        Action::When(w) => plan_when(w, ctx),
129        Action::Exec(x) => plan_exec(x, ctx),
130    }
131}
132
133/// Expand a string field, wrapping expansion errors with field context.
134fn expand_field(raw: &str, env: &VarEnv, field: &'static str) -> Result<String, ExecError> {
135    expand(raw, env).map_err(|source| ExecError::VarExpand { field, source })
136}
137
138/// Convert an expanded string into a [`PathBuf`], rejecting empty paths.
139fn require_path(expanded: String) -> Result<PathBuf, ExecError> {
140    if expanded.is_empty() {
141        return Err(ExecError::InvalidPath(expanded));
142    }
143    Ok(PathBuf::from(expanded))
144}
145
146pub(crate) fn plan_symlink(args: &SymlinkArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
147    let src = require_path(expand_field(&args.src, ctx.vars, "symlink.src")?)?;
148    let dst = require_path(expand_field(&args.dst, ctx.vars, "symlink.dst")?)?;
149    let result = classify_symlink(&src, &dst);
150    Ok(ExecStep {
151        action_name: Cow::Borrowed(ACTION_SYMLINK),
152        result,
153        details: StepKind::Symlink {
154            src,
155            dst,
156            kind: args.kind,
157            backup: args.backup,
158            normalize: args.normalize,
159        },
160    })
161}
162
163fn classify_symlink(src: &Path, dst: &Path) -> ExecResult {
164    match std::fs::symlink_metadata(dst) {
165        Ok(meta) if meta.file_type().is_symlink() => match std::fs::read_link(dst) {
166            Ok(target) if target == src => ExecResult::AlreadySatisfied,
167            _ => ExecResult::WouldPerformChange,
168        },
169        _ => ExecResult::WouldPerformChange,
170    }
171}
172
173pub(crate) fn plan_unlink(args: &UnlinkArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
174    let dst = require_path(expand_field(&args.dst, ctx.vars, "unlink.dst")?)?;
175    // Only a symlink at `dst` is considered actionable — anything else
176    // (absent, regular file, directory) reports AlreadySatisfied so a
177    // misdirected teardown never destroys operator-managed content.
178    let result = match std::fs::symlink_metadata(&dst) {
179        Ok(meta) if meta.file_type().is_symlink() => ExecResult::WouldPerformChange,
180        _ => ExecResult::AlreadySatisfied,
181    };
182    Ok(ExecStep {
183        action_name: Cow::Borrowed(ACTION_UNLINK),
184        result,
185        details: StepKind::Unlink { dst },
186    })
187}
188
189pub(crate) fn plan_env(args: &EnvArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
190    let value = expand_field(&args.value, ctx.vars, "env.value")?;
191    let result = classify_env(&args.name, &value, ctx.vars);
192    Ok(ExecStep {
193        action_name: Cow::Borrowed(ACTION_ENV),
194        result,
195        details: StepKind::Env { name: args.name.clone(), value, scope: args.scope },
196    })
197}
198
199fn classify_env(name: &str, value: &str, vars: &VarEnv) -> ExecResult {
200    match vars.get(name) {
201        Some(existing) if existing == value => ExecResult::AlreadySatisfied,
202        _ => ExecResult::WouldPerformChange,
203    }
204}
205
206pub(crate) fn plan_mkdir(args: &MkdirArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
207    let path = require_path(expand_field(&args.path, ctx.vars, "mkdir.path")?)?;
208    let result =
209        if path.is_dir() { ExecResult::AlreadySatisfied } else { ExecResult::WouldPerformChange };
210    Ok(ExecStep {
211        action_name: Cow::Borrowed(ACTION_MKDIR),
212        result,
213        details: StepKind::Mkdir { path, mode: args.mode.clone() },
214    })
215}
216
217pub(crate) fn plan_rmdir(args: &RmdirArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
218    let path = require_path(expand_field(&args.path, ctx.vars, "rmdir.path")?)?;
219    let result =
220        if path.exists() { ExecResult::WouldPerformChange } else { ExecResult::AlreadySatisfied };
221    Ok(ExecStep {
222        action_name: Cow::Borrowed(ACTION_RMDIR),
223        result,
224        details: StepKind::Rmdir { path, backup: args.backup, force: args.force },
225    })
226}
227
228pub(crate) fn plan_require(spec: &RequireSpec, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
229    let satisfied = evaluate_combiner(&spec.combiner, ctx)?;
230    let outcome =
231        if satisfied { PredicateOutcome::Satisfied } else { PredicateOutcome::Unsatisfied };
232    let result = classify_require(satisfied, spec.on_fail)?;
233    Ok(ExecStep {
234        action_name: Cow::Borrowed(ACTION_REQUIRE),
235        result,
236        details: StepKind::Require { outcome, on_fail: spec.on_fail },
237    })
238}
239
240/// Map a `(satisfied, on_fail)` pair to an [`ExecResult`].
241///
242/// `satisfied == true` always reports [`ExecResult::AlreadySatisfied`] — a
243/// require block performs no work; it asserts. An unsatisfied predicate
244/// under `on_fail: error` short-circuits to [`ExecError::RequireFailed`];
245/// `skip` and `warn` both yield [`ExecResult::NoOp`]. The warn/skip
246/// distinction is preserved in [`StepKind::Require::on_fail`] for audit.
247fn classify_require(satisfied: bool, on_fail: RequireOnFail) -> Result<ExecResult, ExecError> {
248    if satisfied {
249        return Ok(ExecResult::AlreadySatisfied);
250    }
251    match on_fail {
252        RequireOnFail::Error => {
253            Err(ExecError::RequireFailed { detail: "combiner evaluated to false".to_string() })
254        }
255        RequireOnFail::Skip | RequireOnFail::Warn => Ok(ExecResult::NoOp),
256    }
257}
258
259fn evaluate_combiner(combiner: &Combiner, ctx: &ExecCtx<'_>) -> Result<bool, ExecError> {
260    match combiner {
261        Combiner::AllOf(list) => {
262            for p in list {
263                if !evaluate(p, ctx)? {
264                    return Ok(false);
265                }
266            }
267            Ok(true)
268        }
269        Combiner::AnyOf(list) => {
270            for p in list {
271                if evaluate(p, ctx)? {
272                    return Ok(true);
273                }
274            }
275            Ok(false)
276        }
277        Combiner::NoneOf(list) => {
278            for p in list {
279                if evaluate(p, ctx)? {
280                    return Ok(false);
281                }
282            }
283            Ok(true)
284        }
285    }
286}
287
288pub(crate) fn plan_when(spec: &WhenSpec, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
289    let branch_taken = evaluate_when_gate(spec, ctx)?;
290    let nested_steps = if branch_taken { plan_nested(&spec.actions, ctx)? } else { Vec::new() };
291    let result = if branch_taken { ExecResult::WouldPerformChange } else { ExecResult::NoOp };
292    Ok(ExecStep {
293        action_name: Cow::Borrowed(ACTION_WHEN),
294        result,
295        details: StepKind::When { branch_taken, nested_steps },
296    })
297}
298
299pub(crate) fn plan_nested(
300    actions: &[Action],
301    ctx: &ExecCtx<'_>,
302) -> Result<Vec<ExecStep>, ExecError> {
303    // Nested planning re-applies the registry name-oracle check so nested
304    // actions receive the same `UnknownAction` taxonomy as the top-level
305    // dispatch. When `ctx.registry` is absent (direct helper invocation
306    // in tests that bypassed `PlanExecutor`), we skip the membership
307    // check — the call sites that construct `ExecCtx` without a registry
308    // are responsible for their own sanitisation.
309    actions
310        .iter()
311        .map(|a| {
312            if let Some(reg) = ctx.registry {
313                if reg.get(a.name()).is_none() {
314                    return Err(ExecError::UnknownAction(a.name().to_string()));
315                }
316            }
317            dispatch_plan(a, ctx)
318        })
319        .collect()
320}
321
322pub(crate) fn plan_exec(spec: &ExecSpec, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
323    let cwd = expand_optional_path(spec.cwd.as_deref(), ctx.vars, "exec.cwd")?;
324    let cmdline = build_exec_cmdline(spec, ctx.vars)?;
325    Ok(ExecStep {
326        action_name: Cow::Borrowed(ACTION_EXEC),
327        result: ExecResult::WouldPerformChange,
328        details: StepKind::Exec { cmdline, cwd, on_fail: spec.on_fail, shell: spec.shell },
329    })
330}
331
332fn expand_optional_path(
333    raw: Option<&str>,
334    env: &VarEnv,
335    field: &'static str,
336) -> Result<Option<PathBuf>, ExecError> {
337    match raw {
338        Some(s) => {
339            let expanded = expand_field(s, env, field)?;
340            Ok(Some(require_path(expanded)?))
341        }
342        None => Ok(None),
343    }
344}
345
346/// Build a display command line for an [`ExecSpec`], expanding every arg.
347///
348/// The returned string is informational only — the wet-run executor will
349/// reconstruct argv from the typed [`ExecSpec`] fields rather than parsing
350/// this back. Keeping the display separate means authors see the same
351/// quoted form regardless of platform shell quirks.
352fn build_exec_cmdline(spec: &ExecSpec, env: &VarEnv) -> Result<String, ExecError> {
353    match (spec.shell, &spec.cmd, &spec.cmd_shell) {
354        (false, Some(argv), None) => join_argv(argv, env),
355        (true, None, Some(line)) => expand_field(line, env, "exec.cmd_shell"),
356        _ => Err(ExecError::ExecInvalid(
357            "exec requires cmd (shell=false) XOR cmd_shell (shell=true)".to_string(),
358        )),
359    }
360}
361
362fn join_argv(argv: &[String], env: &VarEnv) -> Result<String, ExecError> {
363    let mut parts = Vec::with_capacity(argv.len());
364    for a in argv {
365        parts.push(expand_field(a, env, "exec.cmd")?);
366    }
367    Ok(parts.join(" "))
368}
369
370// Silence clippy about the `ExecOnFail` import being behind the ExecSpec re-
371// export path; kept explicit for readability of generated docs.
372#[allow(dead_code)]
373const _: Option<ExecOnFail> = None;