//! Config validation traits and built-in validators (feature `validation`). //! //! [`ConfigValidator`] lets application config types sanity-check themselves //! after deserialization — e.g. ensuring a database URL parses, a port is in //! range, or a required field is non-empty — and collect all failures into a //! single report rather than failing one field at a time. use std::fmt; use crate::ConfigError; /// A single validation failure with a human-readable path and message. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ValidationFailure { /// Dotted path to the offending field, e.g. `"database.url"`. pub path: String, /// What was wrong. pub message: String, } impl fmt::Display for ValidationFailure { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}: {}", self.path, self.message) } } /// Errors produced by [`ConfigValidator::validate`]. #[derive(Debug, Clone)] pub struct ValidationError { /// All failures found in a single validation pass. pub failures: Vec, } impl fmt::Display for ValidationError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "config validation failed ({} issue(s)):", self.failures.len())?; for failure in &self.failures { write!(f, "\n - {failure}")?; } Ok(()) } } impl std::error::Error for ValidationError {} impl From for ConfigError { fn from(err: ValidationError) -> Self { ConfigError::Validation(err.to_string()) } } /// Trait for types that can validate themselves after configuration loading. /// /// Implementors collect field-level failures rather than returning on the /// first error, so operators see the full problem set in one pass. pub trait ConfigValidator { fn validate(&self) -> Result<(), ValidationError>; } /// Convenience blanket for any serializable config type that implements /// [`ConfigValidator`]. Callers typically invoke this on the output of /// [`ConfigLoader::build`](crate::loader::ConfigLoader::build). /// /// ```no_run /// # use mytheclipse_config::{ConfigLoader, ConfigValidator, ConfigValidatorExt}; /// # use serde::Deserialize; /// # #[derive(Debug, Deserialize)] /// # struct Cfg { port: u16 } /// # impl ConfigValidator for Cfg { /// # fn validate(&self) -> Result<(), mytheclipse_config::ValidationError> { Ok(()) } /// # } /// let cfg: Cfg = ConfigLoader::new().build().unwrap(); /// cfg.validate_config().unwrap(); /// ``` pub trait ConfigValidatorExt: ConfigValidator { /// Validates `self`, returning `Ok(())` on success. fn validate_config(&self) -> Result<(), ConfigError> { self.validate().map_err(ConfigError::from) } } impl ConfigValidatorExt for T {} /// Validates that a string is a well-formed URL (http/https). pub fn validate_url(path: &str, value: &str) -> Option { if value.is_empty() { return Some(ValidationFailure { path: path.to_string(), message: "url must not be empty".into(), }); } // Minimal heuristic: scheme + host. We avoid pulling in a full URL crate // to keep the dependency surface small. let scheme_len = if value.starts_with("http://") { 7 } else if value.starts_with("https://") { 8 } else { return Some(ValidationFailure { path: path.to_string(), message: format!("url must start with http:// or https:// (got {value:?})"), }); }; let host = &value[scheme_len..]; if host.is_empty() { return Some(ValidationFailure { path: path.to_string(), message: format!("url has no host portion (got {value:?})"), }); } None } /// Validates that a port number is in the valid range (1–65535). pub fn validate_port(path: &str, port: u16) -> Option { // u16 already ranges 0–65535; exclude 0 (reserved/unspecified). if port == 0 { Some(ValidationFailure { path: path.to_string(), message: "port must be > 0".into(), }) } else { None } } /// Validates that a string is non-empty. pub fn validate_non_empty(path: &str, value: &str) -> Option { if value.trim().is_empty() { Some(ValidationFailure { path: path.to_string(), message: "value must not be empty".into(), }) } else { None } } /// Validates that a numeric value falls within `[lo, hi]`. pub fn validate_range(path: &str, value: T, lo: T, hi: T) -> Option where T: PartialOrd + fmt::Display + Copy, { if value < lo || value > hi { Some(ValidationFailure { path: path.to_string(), message: format!("value {value} is out of range [{lo}, {hi}]"), }) } else { None } } /// Collects all failures from an iterator of `Option`. pub fn collect_failures(opts: impl IntoIterator>) -> Result<(), ValidationError> { let failures: Vec<_> = opts.into_iter().flatten().collect(); if failures.is_empty() { Ok(()) } else { Err(ValidationError { failures }) } } #[cfg(test)] mod tests { use super::*; #[test] fn url_validator_pass_and_fail() { assert!(validate_url("db.url", "https://example.com").is_none()); assert!(validate_url("db.url", "").is_some()); assert!(validate_url("db.url", "ftp://bad").is_some()); assert!(validate_url("db.url", "https://").is_some()); } #[test] fn port_validator_rejects_zero() { assert!(validate_port("port", 0).is_some()); assert!(validate_port("port", 1).is_none()); assert!(validate_port("port", 65535).is_none()); } #[test] fn range_validator_bounds() { assert!(validate_range("x", 5, 1, 10).is_none()); assert!(validate_range("x", 10, 1, 10).is_none()); assert!(validate_range("x", 0, 1, 10).is_some()); assert!(validate_range("x", 11, 1, 10).is_some()); } #[test] fn collect_failures_aggregates_all() { let opts = [validate_non_empty("a", ""), validate_non_empty("b", "ok"), validate_url("c.d", "bad://x")]; let err = collect_failures(opts).unwrap_err(); assert_eq!(err.failures.len(), 2); assert_eq!(err.failures[0].path, "a"); assert_eq!(err.failures[1].path, "c.d"); } #[test] fn collect_failures_ok_when_all_pass() { let opts = [validate_url("a", "https://ok.com"), validate_port("b", 8080)]; assert!(collect_failures(opts).is_ok()); } #[test] fn blanket_ext_wrappers_validator() { struct Cfg; impl ConfigValidator for Cfg { fn validate(&self) -> Result<(), ValidationError> { Err(ValidationError { failures: vec![ValidationFailure { path: "x".into(), message: "bad".into() }], }) } } let c = Cfg; assert!(c.validate_config().is_err()); } }