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}