Skip to main content

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}