Skip to main content

zeroclaw_config/
api_error.rs

1//! Structured error type for the gateway HTTP CRUD surface and its CLI peer.
2//!
3//! Every fallible operation against the new per-property endpoints (`/api/config/prop`,
4//! `/api/config/list`, `OPTIONS /api/config*`, `PATCH /api/config`) and the matching
5//! `zeroclaw config` CLI subcommands returns this error type. The `code` field is
6//! a stable string the dashboard / scripts can match programmatically; `message`
7//! is human-readable for terminal output and tooltip text. `path` carries the
8//! offending field (when applicable) so the dashboard can render the error
9//! contextually next to the input.
10//!
11//! This replaces the prior pattern of returning `anyhow::Error` strings that
12//! consumers had to substring-match. Existing `anyhow::bail!(...)` sites in
13//! `Config::validate()` are wrapped via `ConfigApiError::from_validation` —
14//! the friendly text becomes `message`, the code stays generic
15//! (`validation_failed`) until callers refine to a more specific code.
16
17use serde::{Deserialize, Serialize};
18
19/// Stable error code consumed by HTTP / CLI / dashboard. Add codes here as new
20/// failure cases land — never invent codes ad-hoc at call sites.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
23#[serde(rename_all = "snake_case")]
24pub enum ConfigApiCode {
25    /// The supplied property path is not defined in the schema.
26    PathNotFound,
27    /// Generic schema validation failure (catch-all wrapping `Config::validate()` bails).
28    ValidationFailed,
29    /// On-disk config differs from in-memory state (an out-of-band file edit
30    /// happened despite the daemon-running rule). Caller should reload.
31    ConfigChangedExternally,
32    /// The daemon-reload step after a successful save failed; on-disk config
33    /// has been reverted to the pre-write snapshot to keep state consistent.
34    ReloadFailed,
35    /// JSON Patch operation type is not supported in this PR (`move` / `copy`).
36    OpNotSupported,
37    /// JSON Patch `test` operation targeted a secret or derived-from-secret
38    /// path; rejected to prevent differential value inference.
39    SecretTestForbidden,
40    /// The supplied JSON value does not match the field's declared type
41    /// (e.g. an array passed where a scalar was expected, or a non-string
42    /// element in a `Vec<String>`).
43    ValueTypeMismatch,
44    /// A required scalar field was empty / missing / blank.
45    /// Path identifies which one (e.g. `gateway.host`,
46    /// `tunnel.openvpn.config_file`).
47    RequiredFieldEmpty,
48    /// A numeric field was out of its allowed range (zero, negative, or
49    /// above an upper bound). Path identifies which one.
50    InvalidNumericRange,
51    /// A string did not match its allowed format — invalid URL, bad
52    /// scheme, invalid path prefix, characters outside the allowed set.
53    InvalidFormat,
54    /// An enum / discriminator field carried a value not in the allowed
55    /// set (e.g. `tunnel.tunnel_provider` with an unknown name).
56    InvalidEnumVariant,
57    /// A reference to another config entry pointed at something that
58    /// doesn't exist (e.g. `agents.<x>.delegate_to` naming a missing agent).
59    DanglingReference,
60    /// Catch-all server failure not classified above. Avoid in code; log the
61    /// original error and convert to a more specific code where possible.
62    InternalError,
63}
64
65impl ConfigApiCode {
66    pub fn as_str(self) -> &'static str {
67        match self {
68            Self::PathNotFound => "path_not_found",
69            Self::ValidationFailed => "validation_failed",
70            Self::ConfigChangedExternally => "config_changed_externally",
71            Self::ReloadFailed => "reload_failed",
72            Self::OpNotSupported => "op_not_supported",
73            Self::SecretTestForbidden => "secret_test_forbidden",
74            Self::ValueTypeMismatch => "value_type_mismatch",
75            Self::RequiredFieldEmpty => "required_field_empty",
76            Self::InvalidNumericRange => "invalid_numeric_range",
77            Self::InvalidFormat => "invalid_format",
78            Self::InvalidEnumVariant => "invalid_enum_variant",
79            Self::DanglingReference => "dangling_reference",
80            Self::InternalError => "internal_error",
81        }
82    }
83
84    /// HTTP status that the gateway returns when this code is the response.
85    pub fn http_status(self) -> u16 {
86        match self {
87            Self::PathNotFound => 404,
88            Self::ValidationFailed
89            | Self::OpNotSupported
90            | Self::SecretTestForbidden
91            | Self::ValueTypeMismatch
92            | Self::RequiredFieldEmpty
93            | Self::InvalidNumericRange
94            | Self::InvalidFormat
95            | Self::InvalidEnumVariant
96            | Self::DanglingReference => 400,
97            Self::ConfigChangedExternally => 409,
98            Self::ReloadFailed | Self::InternalError => 500,
99        }
100    }
101}
102
103/// Structured error returned by the new HTTP CRUD endpoints and the `zeroclaw config`
104/// subcommands they share infrastructure with.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
107pub struct ConfigApiError {
108    /// Stable error code for programmatic matching.
109    pub code: ConfigApiCode,
110    /// Human-readable message. Safe to render directly in dashboards / terminals.
111    pub message: String,
112    /// Property path the error pertains to, when applicable. Empty when the
113    /// error is whole-config (e.g. `ReloadFailed`).
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub path: Option<String>,
116    /// Index into the JSON Patch operation array, when the error originated
117    /// from a specific op in a `PATCH /api/config` batch.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub op_index: Option<usize>,
120}
121
122impl ConfigApiError {
123    pub fn new(code: ConfigApiCode, message: impl Into<String>) -> Self {
124        Self {
125            code,
126            message: message.into(),
127            path: None,
128            op_index: None,
129        }
130    }
131
132    pub fn with_path(mut self, path: impl Into<String>) -> Self {
133        self.path = Some(path.into());
134        self
135    }
136
137    pub fn with_op_index(mut self, index: usize) -> Self {
138        self.op_index = Some(index);
139        self
140    }
141
142    /// Wrap an `anyhow::Error` from `Config::validate()` (or similar bail
143    /// sites) into a structured error. The error string becomes `message`;
144    /// the code is best-effort classified by matching the error text against
145    /// known patterns from `Config::validate()`. Unrecognized text falls
146    /// through to `ValidationFailed`.
147    ///
148    /// First tries to downcast — `Config::validate()` and friends now use
149    /// the `validation_bail!` macro to attach a structured `ConfigApiError`
150    /// directly to the anyhow chain. The classifier remains as the
151    /// fallback for any bail sites not yet converted, so the contract
152    /// degrades gracefully across the refactor.
153    pub fn from_validation(err: anyhow::Error) -> Self {
154        if let Some(structured) = err.downcast_ref::<ConfigApiError>() {
155            return structured.clone();
156        }
157        let msg = err.to_string();
158        let code = classify_validation_message(&msg);
159        Self::new(code, msg)
160    }
161}
162
163/// Best-effort classify a `Config::validate()` error string into a stable
164/// code. Matches against the specific message text the validator emits today
165/// (`crates/zeroclaw-config/src/schema.rs:10151+`). Adding a new pattern here
166/// is the safe step until `validate()` itself is refactored to return
167/// structured errors per bail site.
168pub fn classify_validation_message(msg: &str) -> ConfigApiCode {
169    let lower = msg.to_lowercase();
170    if lower.contains("type mismatch") || lower.contains("invalid value") {
171        return ConfigApiCode::ValueTypeMismatch;
172    }
173    if lower.starts_with("unknown property") {
174        return ConfigApiCode::PathNotFound;
175    }
176    ConfigApiCode::ValidationFailed
177}
178
179impl ConfigApiError {
180    /// Convenience: a `path_not_found` error for the given path.
181    pub fn path_not_found(path: impl Into<String>) -> Self {
182        let path = path.into();
183        Self::new(
184            ConfigApiCode::PathNotFound,
185            format!("property path not found in schema: {path}"),
186        )
187        .with_path(path)
188    }
189
190    /// Convenience: a `secret_test_forbidden` error for the given path.
191    pub fn secret_test_forbidden(path: impl Into<String>) -> Self {
192        let path = path.into();
193        Self::new(
194            ConfigApiCode::SecretTestForbidden,
195            format!(
196                "JSON Patch `test` operations against secret or derived-from-secret paths \
197                 are forbidden: {path}"
198            ),
199        )
200        .with_path(path)
201    }
202
203    /// Convenience: an `op_not_supported` error.
204    pub fn op_not_supported(op: impl Into<String>) -> Self {
205        let op = op.into();
206        Self::new(
207            ConfigApiCode::OpNotSupported,
208            format!("JSON Patch operation `{op}` is not supported in this version"),
209        )
210    }
211}
212
213impl std::fmt::Display for ConfigApiError {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        match &self.path {
216            Some(path) => write!(f, "[{}] {} ({})", self.code.as_str(), self.message, path),
217            None => write!(f, "[{}] {}", self.code.as_str(), self.message),
218        }
219    }
220}
221
222impl std::error::Error for ConfigApiError {}
223
224/// Per-bail-site shorthand for emitting a structured `ConfigApiError`
225/// inside a `validate()` chain that returns `anyhow::Result<()>`. Wraps
226/// the structured error as the anyhow source so
227/// `ConfigApiError::from_validation` downcasts to it without having to
228/// re-classify the message text. Pattern:
229///
230/// ```ignore
231/// validation_bail!(
232///     RequiredFieldEmpty,
233///     "gateway.host",
234///     "gateway.host must not be empty",
235/// );
236/// ```
237///
238/// Sites not yet converted still bail through `anyhow::bail!` — the
239/// classifier in `from_validation` covers them as fallback. Migration
240/// is incremental: each PR that touches a `validate()` site swaps the
241/// macro in.
242#[macro_export]
243macro_rules! validation_bail {
244    ($code:ident, $path:expr, $($msg:tt)*) => {{
245        let err = $crate::api_error::ConfigApiError::new(
246            $crate::api_error::ConfigApiCode::$code,
247            format!($($msg)*),
248        )
249        .with_path($path);
250        return Err(::anyhow::Error::from(err));
251    }};
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn code_str_round_trip() {
260        for code in [
261            ConfigApiCode::PathNotFound,
262            ConfigApiCode::ValidationFailed,
263            ConfigApiCode::ConfigChangedExternally,
264            ConfigApiCode::ReloadFailed,
265            ConfigApiCode::OpNotSupported,
266            ConfigApiCode::SecretTestForbidden,
267            ConfigApiCode::ValueTypeMismatch,
268            ConfigApiCode::InternalError,
269        ] {
270            let serialized = serde_json::to_value(code).unwrap();
271            let s = serialized.as_str().unwrap();
272            assert_eq!(s, code.as_str());
273        }
274    }
275
276    #[test]
277    fn http_status_matches_intent() {
278        assert_eq!(ConfigApiCode::PathNotFound.http_status(), 404);
279        assert_eq!(ConfigApiCode::ValidationFailed.http_status(), 400);
280        assert_eq!(ConfigApiCode::ConfigChangedExternally.http_status(), 409);
281        assert_eq!(ConfigApiCode::ReloadFailed.http_status(), 500);
282    }
283
284    #[test]
285    fn classify_unknown_property() {
286        assert_eq!(
287            classify_validation_message("Unknown property 'foo.bar'"),
288            ConfigApiCode::PathNotFound
289        );
290    }
291
292    #[test]
293    fn classify_falls_back_to_validation_failed() {
294        assert_eq!(
295            classify_validation_message("some unrelated random validator output"),
296            ConfigApiCode::ValidationFailed
297        );
298    }
299
300    #[test]
301    fn path_not_found_carries_path() {
302        let err = ConfigApiError::path_not_found("providers.models");
303        assert_eq!(err.code, ConfigApiCode::PathNotFound);
304        assert_eq!(err.path.as_deref(), Some("providers.models"));
305        assert!(err.message.contains("providers.models"));
306    }
307
308    #[test]
309    fn secret_test_forbidden_carries_path() {
310        let err = ConfigApiError::secret_test_forbidden("providers.models.openrouter.api-key");
311        assert_eq!(err.code, ConfigApiCode::SecretTestForbidden);
312        assert!(err.message.contains("providers.models.openrouter.api-key"));
313    }
314
315    #[test]
316    fn from_validation_uses_message() {
317        let anyhow_err = anyhow::Error::msg("gateway.host must not be empty");
318        let api_err = ConfigApiError::from_validation(anyhow_err);
319        assert_eq!(api_err.code, ConfigApiCode::ValidationFailed);
320        assert!(api_err.message.contains("gateway.host"));
321    }
322
323    #[test]
324    fn display_includes_code_and_path() {
325        let err = ConfigApiError::path_not_found("foo.bar");
326        let s = format!("{err}");
327        assert!(s.contains("path_not_found"));
328        assert!(s.contains("foo.bar"));
329    }
330}