1use std::collections::HashMap;
18use std::fmt;
19use std::sync::{LazyLock, Mutex};
20
21use serde::{Deserialize, Serialize};
22
23use crate::service::CordisError;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct ValidationIssue {
29 pub message: String,
31 pub path: Vec<String>,
34}
35
36impl ValidationIssue {
37 pub fn new(message: impl Into<String>) -> Self {
39 Self {
40 message: message.into(),
41 path: Vec::new(),
42 }
43 }
44
45 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ValidationError {
70 pub issues: Vec<ValidationIssue>,
72}
73
74impl ValidationError {
75 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
95static TRIAL_VALIDATIONS: LazyLock<Mutex<HashMap<String, ValidationError>>> =
104 LazyLock::new(|| Mutex::new(HashMap::new()));
105
106pub 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
123pub 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 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 let roundtripped = err
164 .validation_error()
165 .expect("validation error exposes issues");
166 assert_eq!(roundtripped.issues, issues);
167
168 let plain = CordisError::Configuration("not about validation".into());
170 assert!(plain.validation_error().is_none());
171
172 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}