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}