Skip to main content

grex_core/execute/
error.rs

1//! Error taxonomy for the execute phase.
2
3use std::path::PathBuf;
4
5use thiserror::Error;
6
7use crate::vars::VarExpandError;
8
9/// Cap on captured-stderr length stored on
10/// [`ExecError::ExecNonZero::stderr`]. A 2 KiB window is enough to surface
11/// the tail of a typical shell error while keeping a halt-event log line
12/// bounded.
13pub const EXEC_STDERR_CAPTURE_MAX: usize = 2048;
14
15/// Errors surfaced by [`crate::execute::ActionExecutor::execute`]
16/// implementations.
17///
18/// Marked `#[non_exhaustive]` so slice 5b can add wet-run-specific variants
19/// (`FsIo`, `SymlinkCreate`, `SpawnFailed`, `ChildExit`, ...) without
20/// breaking downstream `match` arms.
21#[non_exhaustive]
22#[derive(Debug, Error)]
23pub enum ExecError {
24    /// Variable expansion failed on a specific field of an action.
25    #[error("variable expansion failed in field `{field}`: {source}")]
26    VarExpand {
27        /// Short field identifier (e.g. `"symlink.dst"`).
28        field: &'static str,
29        /// Underlying expansion error.
30        #[source]
31        source: VarExpandError,
32    },
33    /// An expanded string yielded a path shape grex cannot use (empty,
34    /// non-UTF-8 surrogate pair, etc.).
35    #[error("invalid path after expansion: `{0}`")]
36    InvalidPath(String),
37    /// A `require` action evaluated to false with `on_fail: error`.
38    #[error("require predicate failed: {detail}")]
39    RequireFailed {
40        /// Human-readable summary of which predicate(s) did not hold.
41        detail: String,
42    },
43    /// An `exec` action had an internally inconsistent post-expansion shape.
44    #[error("exec validation failed: {0}")]
45    ExecInvalid(String),
46    /// The executor's plugin registry has no entry registered under the
47    /// action's name. Emitted by the registry-dispatched
48    /// [`super::FsExecutor`] / [`super::PlanExecutor`] when a caller
49    /// constructs them with a partial registry that does not cover every
50    /// variant present in the pack.
51    ///
52    /// The stock [`crate::plugin::Registry::bootstrap`] path registers all
53    /// seven Tier-1 built-ins, so the default [`super::FsExecutor::new`] /
54    /// [`super::PlanExecutor::new`] constructors never surface this variant — it
55    /// is only reachable through the explicit `with_registry` entry points
56    /// that accept a custom registry.
57    #[error("no plugin registered for action `{0}`")]
58    UnknownAction(String),
59    /// A symlink target path is occupied by a non-symlink entry and
60    /// `backup: false`; the wet-run executor refuses to clobber blindly.
61    #[error("symlink destination `{}` is occupied; enable `backup: true` to rename it out of the way", dst.display())]
62    SymlinkDestOccupied {
63        /// Post-expansion destination path.
64        dst: PathBuf,
65    },
66    /// Symlink creation returned OS access-denied. On Windows this usually
67    /// means Developer Mode is disabled and the caller lacks
68    /// `SeCreateSymbolicLinkPrivilege`.
69    #[error("symlink creation denied (Windows: enable Developer Mode or run elevated): {detail}")]
70    SymlinkPrivilegeDenied {
71        /// Verbatim OS error detail for diagnostics.
72        detail: String,
73    },
74    /// A filesystem path exists in a shape incompatible with the requested
75    /// action (e.g. mkdir target is already a regular file).
76    #[error("path `{}` conflicts with action: {reason}", path.display())]
77    PathConflict {
78        /// Post-expansion path that conflicted.
79        path: PathBuf,
80        /// Stable short reason tag.
81        reason: &'static str,
82    },
83    /// `rmdir` without `force: true` attempted to delete a non-empty dir.
84    #[error("rmdir on non-empty directory `{}` without force", path.display())]
85    RmdirNotEmpty {
86        /// Post-expansion path.
87        path: PathBuf,
88    },
89    /// An `env` action requested a persistence scope this platform does not
90    /// implement.
91    #[error("env scope `{scope}` persistence not supported on {platform}")]
92    EnvPersistenceNotSupported {
93        /// Scope tag (`user` / `machine`).
94        scope: String,
95        /// Target platform tag.
96        platform: &'static str,
97    },
98    /// A predicate probed by the predicate evaluator (internal) cannot
99    /// be answered on the current platform (e.g. `reg_key` / `psversion`
100    /// evaluated on non-Windows). Replaces the pre-M4-C conservative-false
101    /// stub: planners and wet-run executors now surface the limitation as
102    /// a typed error instead of silently lying about satisfiability.
103    #[error("predicate `{predicate}` not supported on {platform}")]
104    PredicateNotSupported {
105        /// Predicate kind tag (`reg_key` / `psversion`).
106        predicate: &'static str,
107        /// Target platform tag (from `std::env::consts::OS`).
108        platform: &'static str,
109    },
110    /// A predicate probe ran on the correct platform but the probe itself
111    /// failed in a way that prevents a truthful yes/no answer (e.g. the
112    /// `powershell.exe` child exited non-zero, timed out, or a registry
113    /// read returned a non-`NOT_FOUND` OS error such as ACL denial).
114    /// Distinct from [`ExecError::PredicateNotSupported`]: that variant
115    /// says "grex cannot answer here at all"; this variant says "grex
116    /// tried but the probe itself broke". M4-C post-review introduced it
117    /// so syncs fail loud on a broken probe rather than silently
118    /// reporting `false`.
119    #[error("predicate `{predicate}` probe failed: {detail}")]
120    PredicateProbeFailed {
121        /// Predicate kind tag (`reg_key` / `psversion`).
122        predicate: &'static str,
123        /// Human-readable diagnostic (truncated where appropriate).
124        detail: String,
125    },
126    /// OS rejected an env-persistence write (e.g. HKLM without admin).
127    #[error("env scope `{scope}` persistence denied: {detail}")]
128    EnvPersistenceDenied {
129        /// Scope tag (`user` / `machine`).
130        scope: String,
131        /// Verbatim OS error detail.
132        detail: String,
133    },
134    /// An `exec` action returned a non-zero exit status under
135    /// `on_fail: error`.
136    ///
137    /// `stderr` contains the captured standard-error stream, truncated to
138    /// [`EXEC_STDERR_CAPTURE_MAX`] bytes to keep a halt-event log line at
139    /// a bounded size. Empty string if the child produced none. PR E
140    /// recovery review: previously `cmd.status()` discarded output, so
141    /// debugging non-zero exits was blind.
142    #[error("exec exited with status {status}: {command}")]
143    ExecNonZero {
144        /// Process exit status.
145        status: i32,
146        /// Display-friendly command line.
147        command: String,
148        /// Captured stderr (truncated to [`EXEC_STDERR_CAPTURE_MAX`] bytes).
149        stderr: String,
150    },
151    /// An `exec` action failed to spawn (program not found, permissions, ...).
152    #[error("exec spawn failed for `{command}`: {detail}")]
153    ExecSpawnFailed {
154        /// Display-friendly command line.
155        command: String,
156        /// Verbatim OS error detail.
157        detail: String,
158    },
159    /// Filesystem I/O error attributable to a specific op + path.
160    #[error("fs {op} failed on `{}`: {detail}", path.display())]
161    FsIo {
162        /// Stable op tag (`create_dir`, `remove_dir`, `symlink`, `rename`, ...).
163        op: &'static str,
164        /// Path involved in the op.
165        path: PathBuf,
166        /// Verbatim OS error detail.
167        detail: String,
168    },
169    /// Symlink was declared with `kind: auto` but `src` does not exist on
170    /// disk, so the Windows executor cannot infer whether to call
171    /// `symlink_file` or `symlink_dir`. The two Win32 syscalls are
172    /// distinct and picking the wrong one yields a reparse point the
173    /// shell will not resolve.
174    ///
175    /// Pack authors hitting this should set `kind: file` or
176    /// `kind: directory` explicitly, or ensure `src` exists before the
177    /// action runs (for example via an earlier `mkdir`). Only surfaced on
178    /// Windows; Unix's single `symlink(2)` does not require the hint.
179    #[error(
180        "cannot infer symlink kind for `{}`: `src` does not exist. \
181         Specify `kind: file` or `kind: directory` explicitly ({detail}).",
182        src.display()
183    )]
184    SymlinkAutoKindUnresolvable {
185        /// Post-expansion `src` path that failed to resolve.
186        src: PathBuf,
187        /// Human-readable context (typically the OS error from stat).
188        detail: String,
189    },
190    /// A meta pack's recursion re-visited a pack path already active on the
191    /// dispatch stack.
192    ///
193    /// M5-2c guards [`crate::plugin::pack_type::MetaPlugin`]'s registry
194    /// dispatch against infinite loops by maintaining a canonicalised
195    /// visited-set threaded through [`crate::execute::ExecCtx`]. A cycle
196    /// implies either an author bug (pack A directly or transitively
197    /// includes A) or a registry misconfiguration (a custom pack-type
198    /// plugin re-dispatching into its own root). The tree walker performs
199    /// its own structural cycle detection at walk time — this variant is
200    /// defence-in-depth for the registry-dispatch path, not a replacement.
201    #[error("meta recursion cycle at pack path `{}`", path.display())]
202    MetaCycle {
203        /// Canonicalised pack directory that the cycle re-entered.
204        path: PathBuf,
205    },
206    /// A pack manifest declared a `type:` value that no [`crate::plugin::PackTypeRegistry`]
207    /// entry implements. Surfaced by
208    /// [`crate::plugin::pack_type::MetaPlugin`] when it recurses into a
209    /// child whose type is not registered on the outer context's
210    /// [`crate::execute::ExecCtx::pack_type_registry`] — the top-level
211    /// `run_pack_lifecycle` guard catches the same shape for the root
212    /// pack, but a misconfigured custom registry can still lose an entry
213    /// between root and a deep child.
214    #[error("no pack-type plugin registered for `{requested}`")]
215    UnknownPackType {
216        /// The unknown `type:` discriminator as it appeared in the child
217        /// manifest.
218        requested: String,
219    },
220    /// Symlink creation failed *after* an existing `dst` was renamed aside
221    /// to the backup slot. The original `dst` no longer exists at the
222    /// requested path. Restore attempts also failed, so the backup file is
223    /// the only remaining artifact and the user must recover manually.
224    ///
225    /// Surfaced instead of plain [`ExecError::FsIo`] so callers can
226    /// distinguish "symlink create raced" (dst still present) from the
227    /// dangerous "backup orphan" state pinned by the M3 recovery review.
228    ///
229    /// NOTE: Logging backup intent into the event log before the rename is
230    /// a separate, related gap tracked for PR E (halt-state persistence);
231    /// this variant covers the in-executor rollback shape only.
232    #[error(
233        "symlink create failed after backup, dst `{}` could not be restored from `{}` (create: {create_error}; restore: {})",
234        dst.display(),
235        backup.display(),
236        restore_error.as_deref().unwrap_or("<none>"),
237    )]
238    SymlinkCreateAfterBackupFailed {
239        /// Original destination path the action targeted.
240        dst: PathBuf,
241        /// Surviving backup path (`<dst>.grex.bak`).
242        backup: PathBuf,
243        /// Error returned by the symlink create syscall.
244        create_error: String,
245        /// `Some(detail)` if the rename-back attempt also failed, else
246        /// `None`. When `None`, callers should prefer
247        /// [`ExecError::FsIo`] — this variant only fires when restore
248        /// also fails.
249        restore_error: Option<String>,
250    },
251}
252
253/// Helper: wrap a [`std::io::Error`] into an [`ExecError::FsIo`] with op +
254/// path context. Intentionally not a `From` impl — blanket conversions would
255/// let unrelated callsites silently map io errors and obscure the op tag.
256pub(crate) fn io_to_fs(op: &'static str, path: PathBuf, err: std::io::Error) -> ExecError {
257    ExecError::FsIo { op, path, detail: err.to_string() }
258}