Skip to main content

grex_core/pack/
error.rs

1//! Error type for pack-manifest parsing.
2//!
3//! All Stage-A parse failures surface as [`PackParseError`]. Variants carry
4//! enough context (offending key, value, depth, etc.) to produce actionable
5//! messages without the caller needing to re-read the YAML.
6
7use thiserror::Error;
8
9/// Maximum nesting depth for `require` / `when` predicate trees. Exceeding
10/// this limit yields [`PackParseError::RequireDepthExceeded`]; the cap exists
11/// to bound recursive evaluation cost at execute time.
12pub const MAX_REQUIRE_DEPTH: usize = 32;
13
14/// Errors produced by [`crate::pack::parse`] and related entry points.
15///
16/// Each variant is designed to be self-describing when rendered through
17/// `Display` (via `thiserror`), so `eprintln!("{err}")` is sufficient for a
18/// CLI diagnostic. File-path context, when available, is attached by the
19/// caller using [`PackParseError::with_source_path`].
20///
21/// Marked `#[non_exhaustive]` so new diagnostic variants can land without
22/// breaking external match sites.
23#[non_exhaustive]
24#[derive(Debug, Error)]
25pub enum PackParseError {
26    /// `schema_version` is present but not the supported literal `"1"`.
27    #[error("unsupported pack schema_version {got:?}: this grex build only understands \"1\"")]
28    InvalidSchemaVersion {
29        /// Raw value seen in the manifest.
30        got: String,
31    },
32
33    /// `name` does not match `^[a-z][a-z0-9-]*$`.
34    #[error(
35        "invalid pack name {got:?}: must match ^[a-z][a-z0-9-]*$ (lowercase letter first; then lowercase letters, digits, or hyphens)"
36    )]
37    InvalidName {
38        /// Raw value seen in the manifest.
39        got: String,
40    },
41
42    /// The raw YAML contains an anchor (`&`) or alias (`*`) node. grex
43    /// rejects these at parse time as a security policy (cycle / billion-
44    /// laughs mitigation).
45    #[error("YAML anchors/aliases are not supported (security policy)")]
46    YamlAliasRejected,
47
48    /// A top-level key was present that is neither a known manifest field
49    /// nor prefixed with `x-` (reserved-for-extension namespace).
50    #[error(
51        "unknown top-level key {key:?}: only documented fields and `x-*` extensions are accepted"
52    )]
53    UnknownTopLevelKey {
54        /// Offending key name.
55        key: String,
56    },
57
58    /// An action entry's single-key map names an action not in the Tier-1
59    /// registry.
60    #[error(
61        "unknown action {key:?}: valid actions are symlink, env, mkdir, rmdir, require, when, exec"
62    )]
63    UnknownActionKey {
64        /// Offending key name.
65        key: String,
66    },
67
68    /// An action entry is an empty map (`- {}`).
69    #[error("empty action entry: each list item must have exactly one action key")]
70    EmptyActionEntry,
71
72    /// An action entry has more than one top-level key, e.g.
73    /// `- { symlink: ..., env: ... }`.
74    #[error(
75        "action entry has multiple keys {keys:?}: each list item must name exactly one action"
76    )]
77    MultipleActionKeys {
78        /// All keys seen on the entry, in iteration order.
79        keys: Vec<String>,
80    },
81
82    /// A `require` (or `when`) spec declares zero combiners when at least
83    /// one is required, or more than one combiner at the same level.
84    #[error(
85        "require block must declare exactly one of `all_of`, `any_of`, `none_of` (got {count})"
86    )]
87    RequireCombinerArity {
88        /// Number of combiner keys seen.
89        count: usize,
90    },
91
92    /// A predicate entry is shaped wrong (not a single-key map, or the key
93    /// names an unknown predicate).
94    #[error("invalid predicate entry: {detail}")]
95    InvalidPredicate {
96        /// Human-readable detail.
97        detail: String,
98    },
99
100    /// An `exec` spec violates the `cmd` XOR `cmd_shell` invariant.
101    #[error(
102        "exec args invariant violated (shell={shell}, cmd={cmd_present}, cmd_shell={cmd_shell_present}): \
103when shell=false exactly `cmd` must be set; when shell=true exactly `cmd_shell` must be set"
104    )]
105    ExecCmdMutex {
106        /// Value of the `shell` flag (default `false`).
107        shell: bool,
108        /// Whether `cmd` was present in the parsed spec.
109        cmd_present: bool,
110        /// Whether `cmd_shell` was present in the parsed spec.
111        cmd_shell_present: bool,
112    },
113
114    /// The recursive predicate tree exceeded [`MAX_REQUIRE_DEPTH`].
115    #[error("require/when predicate nesting depth {depth} exceeds maximum {max}")]
116    RequireDepthExceeded {
117        /// Observed depth.
118        depth: usize,
119        /// Configured maximum.
120        max: usize,
121    },
122
123    /// Wrap an inner error with the offending source-file path so CLI
124    /// callers can present `path: error` diagnostics.
125    #[error("{path}: {source}")]
126    WithPath {
127        /// Source file path (display form — may be non-UTF-8 lossy).
128        path: String,
129        /// Underlying error.
130        #[source]
131        source: Box<PackParseError>,
132    },
133
134    /// Underlying `serde_yaml` deserialization error (malformed YAML, type
135    /// mismatch, etc.).
136    #[error("yaml parse error: {0}")]
137    Inner(#[from] serde_yaml::Error),
138}
139
140impl PackParseError {
141    /// Attach source-file context to an error. Intended for the entry point
142    /// that reads a file from disk; Stage A's pure-parse API does not use
143    /// it directly but surfaces it for consumers.
144    #[must_use]
145    pub fn with_source_path(self, path: impl Into<String>) -> Self {
146        match self {
147            // Avoid double-wrapping: keep the innermost error but replace
148            // the path with the outermost caller's view.
149            Self::WithPath { source, .. } => Self::WithPath { path: path.into(), source },
150            other => Self::WithPath { path: path.into(), source: Box::new(other) },
151        }
152    }
153}