apimock_config/error.rs
1//! Errors surfaced by the config crate.
2//!
3//! See `apimock_routing::error` for the rationale on per-crate error
4//! types. `ConfigError` wraps `RoutingError` via `#[from]` so rule-set
5//! load failures flow through without the caller pattern-matching on
6//! origin.
7//!
8//! # 5.1.0 additions
9//!
10//! - `WorkspaceError` — surfaced by `Workspace::load`.
11//! - `ApplyError` — surfaced by `Workspace::apply`.
12//! - `SaveError` — surfaced by `Workspace::save`.
13//!
14//! Each of the three "operation" errors wraps `ConfigError` via
15//! `#[from]` because the underlying cause of most workspace failures
16//! is a plain config load / write problem.
17//!
18//! # `#[non_exhaustive]` and `kind()` (RFC 041)
19//!
20//! All four enums here are `#[non_exhaustive]` — a public error type is
21//! exactly where a new variant is most likely to be added later, so
22//! leaving it exhaustively matchable freezes today's variant set as a
23//! public contract by accident. Each gains a `kind()` accessor
24//! returning its own `#[non_exhaustive]` `*Kind` enum, mechanically one
25//! kind per variant, so a caller forced into a wildcard match arm by
26//! `#[non_exhaustive]` still has a stable way to branch on failure
27//! class instead of falling back to matching on `Display` text.
28//!
29//! **This is a different taxonomy from `apimock::cmd::envelope::ErrorKind`**,
30//! deliberately. That one is the CLI's published contract, with a
31//! schema version and a stability promise to agents. These `kind()`
32//! methods describe library failures for library callers. Neither
33//! delegates to the other — fusing them would tie a published CLI
34//! contract to internal error refactoring.
35//!
36//! A variant that wraps another crate's error (`ConfigError::RuleSet`,
37//! `WorkspaceError::Config`, …) gets its own kind naming *that it
38//! wrapped something*, not the inner error's kind — `kind()` describes
39//! *this* enum's variant, one-to-one, never a second hop into a nested
40//! type's own taxonomy. A caller wanting the inner detail already has
41//! `Error::source()` for that.
42
43use std::{io, path::PathBuf};
44
45use crate::view::NodeId;
46
47pub type ConfigResult<T> = Result<T, ConfigError>;
48
49/// # `#[non_exhaustive]` (RFC 041)
50///
51/// An enum's own fields stay constructible across the crate boundary —
52/// `#[non_exhaustive]` on an `enum` restricts matching, not building
53/// its existing variants. What it forbids is an exhaustive `match`
54/// with no wildcard arm, since a future variant would otherwise make
55/// this a silent non-breaking-looking compile error downstream:
56///
57/// ```compile_fail
58/// use apimock_config::ConfigError;
59///
60/// fn describe(e: &ConfigError) -> &'static str {
61/// match e {
62/// ConfigError::ConfigRead { .. } => "read",
63/// ConfigError::ConfigParse { .. } => "parse",
64/// ConfigError::PathResolve { .. } => "path",
65/// ConfigError::Validation { .. } => "validation",
66/// ConfigError::RuleSet(_) => "rule_set",
67/// // no `_` arm — exhaustive matches outside the crate must
68/// // carry one once the enum is `#[non_exhaustive]`.
69/// }
70/// }
71/// ```
72#[derive(Debug, thiserror::Error)]
73#[non_exhaustive]
74pub enum ConfigError {
75 /// The config TOML file could not be read from disk.
76 #[error("failed to read config file `{path}`: {source}")]
77 ConfigRead {
78 path: PathBuf,
79 #[source]
80 source: io::Error,
81 },
82
83 /// The config TOML file was read, but could not be parsed.
84 #[error("invalid TOML in `{path}`{canonical_display}: {source}", canonical_display = match canonical {
85 Some(p) => format!(" ({})", p.display()),
86 None => String::new(),
87 })]
88 ConfigParse {
89 path: PathBuf,
90 canonical: Option<PathBuf>,
91 // Boxed (RFC 041): `toml::de::Error` is 88 bytes, making this
92 // variant 136 — the sole cause of every
93 // `clippy::result_large_err` suppression this crate carried.
94 // `#[source]` still reaches through the box unchanged; this is
95 // a representation change, not a behavioural one.
96 #[source]
97 source: Box<toml::de::Error>,
98 },
99
100 /// A path on disk could not be resolved.
101 #[error("failed to resolve path `{path}`: {source}")]
102 PathResolve {
103 path: PathBuf,
104 #[source]
105 source: io::Error,
106 },
107
108 /// Startup-time validation failed. `reason` is the first failure
109 /// encountered (RFC 065) — previously a bare unit variant whose
110 /// only detail lived in a `log::error!` call at the failing
111 /// validator's own site, which never reached a caller with no
112 /// logger installed (`apimock validate`/`get`/`set`/`match-test`,
113 /// none of which do).
114 #[error("configuration validation failed: {reason}")]
115 Validation { reason: String },
116
117 /// A rule-set file failed to load or parse. Wraps the routing
118 /// crate's error type.
119 #[error(transparent)]
120 RuleSet(#[from] apimock_routing::RoutingError),
121}
122
123/// `ConfigError`'s failure class, one variant per `ConfigError` variant.
124/// See the module doc for why this exists and how it differs from
125/// `apimock::cmd::envelope::ErrorKind`.
126#[non_exhaustive]
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum ConfigErrorKind {
129 Read,
130 Parse,
131 PathResolve,
132 Validation,
133 RuleSet,
134}
135
136impl ConfigError {
137 pub fn kind(&self) -> ConfigErrorKind {
138 match self {
139 ConfigError::ConfigRead { .. } => ConfigErrorKind::Read,
140 ConfigError::ConfigParse { .. } => ConfigErrorKind::Parse,
141 ConfigError::PathResolve { .. } => ConfigErrorKind::PathResolve,
142 ConfigError::Validation { .. } => ConfigErrorKind::Validation,
143 ConfigError::RuleSet(_) => ConfigErrorKind::RuleSet,
144 }
145 }
146}
147
148/// Failure during `Workspace::load`. Currently a thin wrapper around
149/// `ConfigError` — kept as its own type so the `Workspace` API signals
150/// intent at the type level and has room to grow (e.g. "path is not a
151/// directory", "no root config found").
152#[derive(Debug, thiserror::Error)]
153#[non_exhaustive]
154pub enum WorkspaceError {
155 #[error(transparent)]
156 Config(#[from] ConfigError),
157
158 /// Root path was not found or was not a regular file/directory.
159 #[error("workspace root `{path}` is not a valid apimock workspace: {reason}")]
160 InvalidRoot { path: PathBuf, reason: String },
161}
162
163/// `WorkspaceError`'s failure class. **Does not delegate to
164/// `ConfigErrorKind`** (RFC 041's handoff § 4, decided explicitly):
165/// `WorkspaceError` exists so the `Workspace` API signals intent at the
166/// type level, and delegating its `kind()` would leak `ConfigError`'s
167/// taxonomy through the type whose whole purpose is to have its own.
168#[non_exhaustive]
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum WorkspaceErrorKind {
171 Config,
172 InvalidRoot,
173}
174
175impl WorkspaceError {
176 pub fn kind(&self) -> WorkspaceErrorKind {
177 match self {
178 WorkspaceError::Config(_) => WorkspaceErrorKind::Config,
179 WorkspaceError::InvalidRoot { .. } => WorkspaceErrorKind::InvalidRoot,
180 }
181 }
182}
183
184/// Failure during `Workspace::apply`.
185///
186/// # Why these particular variants
187///
188/// Every `EditCommand` variant targets a node by NodeId. The two
189/// failure modes are "that ID doesn't exist" and "the ID exists but
190/// refers to a node of the wrong kind for this command". Everything
191/// else (file-not-found when `AddRuleSet` with a missing path) is a
192/// validation issue reported via `ApplyResult::diagnostics`, not an
193/// error return.
194#[derive(Debug, thiserror::Error)]
195#[non_exhaustive]
196pub enum ApplyError {
197 /// The NodeId in the command wasn't found in the workspace.
198 #[error("unknown node id: {id}")]
199 UnknownNode { id: NodeId },
200
201 /// The NodeId exists but names a node of the wrong kind for this
202 /// command (e.g. `DeleteRule` pointing at a rule-set ID).
203 #[error("node {id} is not of the expected kind for this command: {reason}")]
204 WrongNodeKind { id: NodeId, reason: String },
205
206 /// Invalid command payload (e.g. `MoveRule` with `new_index` past
207 /// end of parent's rule list).
208 #[error("invalid edit payload: {reason}")]
209 InvalidPayload { reason: String },
210}
211
212/// `ApplyError`'s failure class.
213#[non_exhaustive]
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum ApplyErrorKind {
216 UnknownNode,
217 WrongNodeKind,
218 InvalidPayload,
219}
220
221impl ApplyError {
222 pub fn kind(&self) -> ApplyErrorKind {
223 match self {
224 ApplyError::UnknownNode { .. } => ApplyErrorKind::UnknownNode,
225 ApplyError::WrongNodeKind { .. } => ApplyErrorKind::WrongNodeKind,
226 ApplyError::InvalidPayload { .. } => ApplyErrorKind::InvalidPayload,
227 }
228 }
229}
230
231/// Failure during `Workspace::save`.
232#[derive(Debug, thiserror::Error)]
233#[non_exhaustive]
234pub enum SaveError {
235 /// A TOML file failed to serialise.
236 #[error("failed to serialise `{path}`: {source}")]
237 Serialize {
238 path: PathBuf,
239 #[source]
240 source: toml::ser::Error,
241 },
242 /// Writing the serialised TOML to disk failed.
243 #[error("failed to write `{path}`: {source}")]
244 Write {
245 path: PathBuf,
246 #[source]
247 source: io::Error,
248 },
249 /// The workspace's internal state was inconsistent at save time —
250 /// usually a programmer error in the edit layer.
251 #[error("internal inconsistency: {reason}")]
252 Inconsistent { reason: String },
253 /// The file changed on disk since it was last loaded or saved.
254 /// In-place editing (RFC 056) re-reads the text it mutates, so it
255 /// notices this where the old rebuild-from-model path could not.
256 /// Overwriting would silently discard whatever changed it made —
257 /// the caller must reload and reapply instead.
258 #[error("`{path}` changed on disk since it was loaded; reload before saving")]
259 Conflict { path: PathBuf },
260 /// Re-reading a file to check it for external changes (RFC 056 §2
261 /// Q3, ahead of an in-place save) failed — permission denied, the
262 /// file deleted out from under us, etc. Distinct from `Conflict`:
263 /// this is "we couldn't tell whether it changed," not "we could
264 /// tell, and it did." Reloading — `Conflict`'s remedy — will not
265 /// fix a permission error, so the two need different messages.
266 #[error("failed to read `{path}` to check for external changes: {source}")]
267 Read {
268 path: PathBuf,
269 #[source]
270 source: io::Error,
271 },
272}
273
274/// `SaveError`'s failure class.
275#[non_exhaustive]
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum SaveErrorKind {
278 Serialize,
279 Write,
280 Inconsistent,
281 Conflict,
282 Read,
283}
284
285impl SaveError {
286 pub fn kind(&self) -> SaveErrorKind {
287 match self {
288 SaveError::Serialize { .. } => SaveErrorKind::Serialize,
289 SaveError::Write { .. } => SaveErrorKind::Write,
290 SaveError::Inconsistent { .. } => SaveErrorKind::Inconsistent,
291 SaveError::Conflict { .. } => SaveErrorKind::Conflict,
292 SaveError::Read { .. } => SaveErrorKind::Read,
293 }
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use serde::ser::Error as _;
301 use std::error::Error as _;
302
303 fn a_toml_parse_error() -> toml::de::Error {
304 toml::from_str::<toml::Value>("not valid toml =====")
305 .expect_err("deliberately malformed TOML must fail to parse")
306 }
307
308 // ── RFC 041 § 6: boxing must not change Display / source() ────────
309
310 #[test]
311 fn config_parse_display_matches_pre_boxing_format() {
312 let source = a_toml_parse_error();
313 let expected_source_display = source.to_string();
314 let err = ConfigError::ConfigParse {
315 path: PathBuf::from("apimock.toml"),
316 canonical: None,
317 source: Box::new(source),
318 };
319 assert_eq!(
320 err.to_string(),
321 format!("invalid TOML in `apimock.toml`: {expected_source_display}")
322 );
323 }
324
325 #[test]
326 fn config_parse_display_includes_canonical_path_when_present() {
327 let source = a_toml_parse_error();
328 let expected_source_display = source.to_string();
329 let err = ConfigError::ConfigParse {
330 path: PathBuf::from("apimock.toml"),
331 canonical: Some(PathBuf::from("/abs/apimock.toml")),
332 source: Box::new(source),
333 };
334 assert_eq!(
335 err.to_string(),
336 format!(
337 "invalid TOML in `apimock.toml` (/abs/apimock.toml): {expected_source_display}"
338 )
339 );
340 }
341
342 #[test]
343 fn config_parse_source_reaches_the_boxed_toml_error() {
344 let source = a_toml_parse_error();
345 let source_display = source.to_string();
346 let err = ConfigError::ConfigParse {
347 path: PathBuf::from("apimock.toml"),
348 canonical: None,
349 source: Box::new(source),
350 };
351 let reached = err.source().expect("ConfigParse always carries a source");
352 assert_eq!(reached.to_string(), source_display);
353 }
354
355 // ── kind() — one assertion per variant, all six enums ──────────────
356
357 #[test]
358 fn config_error_kind_matches_every_variant() {
359 assert_eq!(
360 ConfigError::ConfigRead {
361 path: PathBuf::from("x"),
362 source: io::Error::other("x"),
363 }
364 .kind(),
365 ConfigErrorKind::Read
366 );
367 assert_eq!(
368 ConfigError::ConfigParse {
369 path: PathBuf::from("x"),
370 canonical: None,
371 source: Box::new(a_toml_parse_error()),
372 }
373 .kind(),
374 ConfigErrorKind::Parse
375 );
376 assert_eq!(
377 ConfigError::PathResolve {
378 path: PathBuf::from("x"),
379 source: io::Error::other("x"),
380 }
381 .kind(),
382 ConfigErrorKind::PathResolve
383 );
384 assert_eq!(
385 ConfigError::Validation {
386 reason: "x".to_owned()
387 }
388 .kind(),
389 ConfigErrorKind::Validation
390 );
391 assert_eq!(
392 ConfigError::RuleSet(apimock_routing::RoutingError::RuleSetRead {
393 path: PathBuf::from("x"),
394 source: io::Error::other("x"),
395 })
396 .kind(),
397 ConfigErrorKind::RuleSet
398 );
399 }
400
401 #[test]
402 fn workspace_error_kind_matches_every_variant() {
403 assert_eq!(
404 WorkspaceError::Config(ConfigError::Validation {
405 reason: "x".to_owned()
406 })
407 .kind(),
408 WorkspaceErrorKind::Config
409 );
410 assert_eq!(
411 WorkspaceError::InvalidRoot {
412 path: PathBuf::from("x"),
413 reason: "x".to_owned(),
414 }
415 .kind(),
416 WorkspaceErrorKind::InvalidRoot
417 );
418 }
419
420 #[test]
421 fn apply_error_kind_matches_every_variant() {
422 assert_eq!(
423 ApplyError::UnknownNode { id: NodeId::new() }.kind(),
424 ApplyErrorKind::UnknownNode
425 );
426 assert_eq!(
427 ApplyError::WrongNodeKind {
428 id: NodeId::new(),
429 reason: "x".to_owned(),
430 }
431 .kind(),
432 ApplyErrorKind::WrongNodeKind
433 );
434 assert_eq!(
435 ApplyError::InvalidPayload {
436 reason: "x".to_owned(),
437 }
438 .kind(),
439 ApplyErrorKind::InvalidPayload
440 );
441 }
442
443 #[test]
444 fn save_error_kind_matches_every_variant() {
445 assert_eq!(
446 SaveError::Serialize {
447 path: PathBuf::from("x"),
448 source: toml::ser::Error::custom("x"),
449 }
450 .kind(),
451 SaveErrorKind::Serialize
452 );
453 assert_eq!(
454 SaveError::Write {
455 path: PathBuf::from("x"),
456 source: io::Error::other("x"),
457 }
458 .kind(),
459 SaveErrorKind::Write
460 );
461 assert_eq!(
462 SaveError::Inconsistent {
463 reason: "x".to_owned(),
464 }
465 .kind(),
466 SaveErrorKind::Inconsistent
467 );
468 assert_eq!(
469 SaveError::Conflict {
470 path: PathBuf::from("x"),
471 }
472 .kind(),
473 SaveErrorKind::Conflict
474 );
475 assert_eq!(
476 SaveError::Read {
477 path: PathBuf::from("x"),
478 source: io::Error::other("x"),
479 }
480 .kind(),
481 SaveErrorKind::Read
482 );
483 }
484}