use std::collections::HashMap;
use std::fmt;
use std::sync::{LazyLock, Mutex};
use serde::{Deserialize, Serialize};
use crate::service::CordisError;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidationIssue {
pub message: String,
pub path: Vec<String>,
}
impl ValidationIssue {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
path: Vec::new(),
}
}
pub fn at<I, S>(mut self, path: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.path = path.into_iter().map(Into::into).collect();
self
}
}
impl fmt::Display for ValidationIssue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.path.is_empty() {
write!(f, "- {}", self.message)
} else {
write!(f, "- {} (at {})", self.message, self.path.join("."))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidationError {
pub issues: Vec<ValidationIssue>,
}
impl ValidationError {
pub fn new(issues: Vec<ValidationIssue>) -> Self {
Self { issues }
}
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let rendered = self
.issues
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
f.write_str(&rendered)
}
}
impl std::error::Error for ValidationError {}
static TRIAL_VALIDATIONS: LazyLock<Mutex<HashMap<String, ValidationError>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub fn stash_trial_validation(entry_id: &str, err: &CordisError) {
let mut stash = TRIAL_VALIDATIONS
.lock()
.expect("trial validation stash poisoned");
match err.validation_error() {
Some(validation) => {
stash.insert(entry_id.to_string(), validation.clone());
}
None => {
stash.remove(entry_id);
}
}
}
pub fn take_trial_validation(entry_id: &str) -> Option<ValidationError> {
TRIAL_VALIDATIONS
.lock()
.expect("trial validation stash poisoned")
.remove(entry_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validation_issue_display_renders_path() {
let placed = ValidationIssue::new("missing url").at(["calc", "url"]);
assert_eq!(placed.to_string(), "- missing url (at calc.url)");
let deep = ValidationIssue::new("port out of range").at(["a", "b", "c"]);
assert_eq!(deep.to_string(), "- port out of range (at a.b.c)");
let bare = ValidationIssue::new("whole document rejected");
assert_eq!(bare.to_string(), "- whole document rejected");
}
#[test]
fn validation_error_roundtrips_through_cordis_error() {
let issues = vec![
ValidationIssue::new("missing url").at(["calc", "url"]),
ValidationIssue::new("retries must be numeric").at(["llm", "retries"]),
];
let err = CordisError::validation(issues.clone());
assert!(err.to_string().starts_with("invalid config: "));
assert!(
err.to_string().contains("(at calc.url)"),
"issue text survives the lift: {err}"
);
let roundtripped = err
.validation_error()
.expect("validation error exposes issues");
assert_eq!(roundtripped.issues, issues);
let plain = CordisError::Configuration("not about validation".into());
assert!(plain.validation_error().is_none());
let json = serde_json::to_value(roundtripped).expect("serialize");
assert_eq!(
json,
serde_json::json!({
"issues": [
{"message": "missing url", "path": ["calc", "url"]},
{"message": "retries must be numeric", "path": ["llm", "retries"]},
]
})
);
}
}