Skip to main content

cordis/
error.rs

1//! Structured validation errors for declarative configuration surfaces.
2//!
3//! Stringly config errors (`CordisError::InvalidConfig(String)`) are easy to
4//! log but lossy for API consumers: an admin PATCH that fails a loader
5//! pre-flight can only echo prose. [`ValidationIssue`] pairs the human
6//! message with the config location it was found at, [`ValidationError`]
7//! aggregates them, and [`CordisError::validation`] lifts the aggregate into
8//! the existing InvalidConfig error class without changing that class's
9//! Display prefix.
10//!
11//! The loader trial path additionally stashes per-entry failures here
12//! ([`stash_trial_validation`] / [`take_trial_validation`]) because
13//! `AppliedAction` rows carry plain strings; the HTTP layer consumes the
14//! stash to attach a machine-readable `issues` array to otherwise unchanged
15//! 4xx bodies.
16
17use std::collections::HashMap;
18use std::fmt;
19use std::sync::{LazyLock, Mutex};
20
21use serde::{Deserialize, Serialize};
22
23use crate::service::CordisError;
24
25/// One structured validation failure: a human-readable message plus the
26/// config location it came from (`["entry-id", "field", "subfield"]`).
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct ValidationIssue {
29    /// What is wrong, phrased for an operator.
30    pub message: String,
31    /// Config path of the failure; empty when the whole document is at
32    /// fault.
33    pub path: Vec<String>,
34}
35
36impl ValidationIssue {
37    /// An issue with no path yet; chain [`Self::at`] to place it.
38    pub fn new(message: impl Into<String>) -> Self {
39        Self {
40            message: message.into(),
41            path: Vec::new(),
42        }
43    }
44
45    /// Builder: attach the config path this issue was found at. Segments
46    /// render joined by `.` in Display (`a.b.c`).
47    pub fn at<I, S>(mut self, path: I) -> Self
48    where
49        I: IntoIterator<Item = S>,
50        S: Into<String>,
51    {
52        self.path = path.into_iter().map(Into::into).collect();
53        self
54    }
55}
56
57impl fmt::Display for ValidationIssue {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        if self.path.is_empty() {
60            write!(f, "- {}", self.message)
61        } else {
62            write!(f, "- {} (at {})", self.message, self.path.join("."))
63        }
64    }
65}
66
67/// Aggregated validation failures from one configuration surface.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ValidationError {
70    /// Every failure found, in discovery order.
71    pub issues: Vec<ValidationIssue>,
72}
73
74impl ValidationError {
75    /// Aggregate already-placed issues.
76    pub fn new(issues: Vec<ValidationIssue>) -> Self {
77        Self { issues }
78    }
79}
80
81impl fmt::Display for ValidationError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        let rendered = self
84            .issues
85            .iter()
86            .map(ToString::to_string)
87            .collect::<Vec<_>>()
88            .join("; ");
89        f.write_str(&rendered)
90    }
91}
92
93impl std::error::Error for ValidationError {}
94
95/// Per-entry stash of the most recent structured validation failures from
96/// loader trial pre-flights (`Loader::trial_config_verified`).
97///
98/// `AppliedAction` rows flatten errors to strings, so the HTTP PATCH surface
99/// could not answer with machine-readable issues. Trials record here keyed
100/// by entry id; the handler consumes the slot after a failed apply. Slots
101/// mirror the LATEST trial outcome: recording a non-validation error clears
102/// the entry, and consumption removes it.
103static TRIAL_VALIDATIONS: LazyLock<Mutex<HashMap<String, ValidationError>>> =
104    LazyLock::new(|| Mutex::new(HashMap::new()));
105
106/// Record the structured issues carried by `err` for `entry_id`, replacing
107/// any earlier record; an error without structured issues clears the slot
108/// instead, so a stale list is never served for a different failure mode.
109pub fn stash_trial_validation(entry_id: &str, err: &CordisError) {
110    let mut stash = TRIAL_VALIDATIONS
111        .lock()
112        .expect("trial validation stash poisoned");
113    match err.validation_error() {
114        Some(validation) => {
115            stash.insert(entry_id.to_string(), validation.clone());
116        }
117        None => {
118            stash.remove(entry_id);
119        }
120    }
121}
122
123/// Consume the stashed issues for `entry_id`, if any.
124pub fn take_trial_validation(entry_id: &str) -> Option<ValidationError> {
125    TRIAL_VALIDATIONS
126        .lock()
127        .expect("trial validation stash poisoned")
128        .remove(entry_id)
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn validation_issue_display_renders_path() {
137        let placed = ValidationIssue::new("missing url").at(["calc", "url"]);
138        assert_eq!(placed.to_string(), "- missing url (at calc.url)");
139
140        let deep = ValidationIssue::new("port out of range").at(["a", "b", "c"]);
141        assert_eq!(deep.to_string(), "- port out of range (at a.b.c)");
142
143        let bare = ValidationIssue::new("whole document rejected");
144        assert_eq!(bare.to_string(), "- whole document rejected");
145    }
146
147    #[test]
148    fn validation_error_roundtrips_through_cordis_error() {
149        let issues = vec![
150            ValidationIssue::new("missing url").at(["calc", "url"]),
151            ValidationIssue::new("retries must be numeric").at(["llm", "retries"]),
152        ];
153
154        let err = CordisError::validation(issues.clone());
155        // InvalidConfig error class: Display keeps the established prefix.
156        assert!(err.to_string().starts_with("invalid config: "));
157        assert!(
158            err.to_string().contains("(at calc.url)"),
159            "issue text survives the lift: {err}"
160        );
161
162        // Structure survives: the accessor hands back the same issue list.
163        let roundtripped = err
164            .validation_error()
165            .expect("validation error exposes issues");
166        assert_eq!(roundtripped.issues, issues);
167
168        // Other variants report no structured issues.
169        let plain = CordisError::Configuration("not about validation".into());
170        assert!(plain.validation_error().is_none());
171
172        // The aggregate serializes for API payloads.
173        let json = serde_json::to_value(roundtripped).expect("serialize");
174        assert_eq!(
175            json,
176            serde_json::json!({
177                "issues": [
178                    {"message": "missing url", "path": ["calc", "url"]},
179                    {"message": "retries must be numeric", "path": ["llm", "retries"]},
180                ]
181            })
182        );
183    }
184}