Skip to main content

hjkl_config/
validate.rs

1//! Validation hook + reusable field-named bounds-check helpers.
2//!
3//! The [`Validate`] trait is the consumer-defined entry point; [`load`] does
4//! not invoke it automatically — consumers call `cfg.validate()` themselves
5//! after loading. The helpers below ([`ensure_range`], [`ensure_non_zero`],
6//! …) are field-named building blocks consumers can compose into their
7//! `Validate` impl. Each returns [`ValidationError`] on violation, which
8//! carries the field name and a human-readable message.
9//!
10//! ```no_run
11//! # use hjkl_config::{Validate, ValidationError, ensure_range, ensure_non_zero};
12//! struct Config { tab_width: u8, max_lines: u32 }
13//!
14//! impl Validate for Config {
15//!     type Error = ValidationError;
16//!     fn validate(&self) -> Result<(), Self::Error> {
17//!         ensure_range(self.tab_width, 1, 16, "tab_width")?;
18//!         ensure_non_zero(self.max_lines, "max_lines")?;
19//!         Ok(())
20//!     }
21//! }
22//! ```
23//!
24//! [`load`]: crate::load
25
26use std::fmt::Display;
27
28/// Optional consumer-defined validation hook.
29///
30/// Decoupled from loading — implementing this trait does **not** make
31/// `load()` invoke it automatically. Consumers call `cfg.validate()`
32/// themselves after `load()` if validation is desired. This keeps the
33/// loader's surface narrow and lets apps decide when (and whether) to
34/// validate.
35pub trait Validate {
36    type Error: std::error::Error + Send + Sync + 'static;
37
38    fn validate(&self) -> Result<(), Self::Error>;
39}
40
41/// Standard validation error carrying a field name and a human-readable
42/// message. Consumers can use this directly as their `Validate::Error` or
43/// wrap it in their own error type.
44#[derive(Debug, thiserror::Error)]
45#[error("config field `{field}` invalid: {message}")]
46pub struct ValidationError {
47    pub field: &'static str,
48    pub message: String,
49}
50
51impl ValidationError {
52    pub fn new(field: &'static str, message: impl Into<String>) -> Self {
53        Self {
54            field,
55            message: message.into(),
56        }
57    }
58}
59
60/// `min ≤ value ≤ max`, else `Err`. Inclusive on both ends — the common
61/// shape for config bounds (e.g. `tab_width` in `1..=16`).
62pub fn ensure_range<T>(value: T, min: T, max: T, field: &'static str) -> Result<(), ValidationError>
63where
64    T: PartialOrd + Display + Copy,
65{
66    if value < min || value > max {
67        return Err(ValidationError::new(
68            field,
69            format!("value {value} not in {min}..={max}"),
70        ));
71    }
72    Ok(())
73}
74
75/// `value != T::default()`, else `Err`. For numeric types `T::default()`
76/// is `0`, so this rejects zero. Generic over `Default` rather than a
77/// numeric trait to keep the dep footprint at zero.
78pub fn ensure_non_zero<T>(value: T, field: &'static str) -> Result<(), ValidationError>
79where
80    T: PartialEq + Default + Display,
81{
82    if value == T::default() {
83        return Err(ValidationError::new(field, "must not be zero"));
84    }
85    Ok(())
86}
87
88/// `value ∈ allowed`, else `Err` with the allowed list in the message.
89/// Use for enum-shaped string fields (theme names, channels, etc).
90pub fn ensure_one_of<T>(
91    value: &T,
92    allowed: &[T],
93    field: &'static str,
94) -> Result<(), ValidationError>
95where
96    T: PartialEq + Display,
97{
98    if !allowed.iter().any(|a| a == value) {
99        let listed: Vec<String> = allowed.iter().map(|x| format!("\"{x}\"")).collect();
100        return Err(ValidationError::new(
101            field,
102            format!("value \"{value}\" not in [{}]", listed.join(", ")),
103        ));
104    }
105    Ok(())
106}
107
108/// `!value.is_empty()`, else `Err`.
109pub fn ensure_non_empty_str(value: &str, field: &'static str) -> Result<(), ValidationError> {
110    if value.is_empty() {
111        return Err(ValidationError::new(field, "must not be empty"));
112    }
113    Ok(())
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn ensure_range_inclusive_boundaries_ok() {
122        assert!(ensure_range(1u8, 1, 16, "tab_width").is_ok());
123        assert!(ensure_range(16u8, 1, 16, "tab_width").is_ok());
124        assert!(ensure_range(8u8, 1, 16, "tab_width").is_ok());
125    }
126
127    #[test]
128    fn ensure_range_below_min_errs() {
129        let err = ensure_range(0u8, 1, 16, "tab_width").unwrap_err();
130        assert_eq!(err.field, "tab_width");
131        assert!(err.message.contains("0"));
132        assert!(err.message.contains("1..=16"));
133    }
134
135    #[test]
136    fn ensure_range_above_max_errs() {
137        let err = ensure_range(64u8, 1, 16, "tab_width").unwrap_err();
138        assert_eq!(err.field, "tab_width");
139        assert!(err.message.contains("64"));
140    }
141
142    #[test]
143    fn ensure_non_zero_rejects_zero() {
144        assert!(ensure_non_zero(0u32, "x").is_err());
145        assert!(ensure_non_zero(0i64, "x").is_err());
146    }
147
148    #[test]
149    fn ensure_non_zero_accepts_nonzero() {
150        assert!(ensure_non_zero(1u32, "x").is_ok());
151        assert!(ensure_non_zero(42u64, "x").is_ok());
152    }
153
154    #[test]
155    fn ensure_one_of_finds_match() {
156        let allowed = ["dark".to_string(), "light".to_string()];
157        assert!(ensure_one_of(&"dark".to_string(), &allowed, "theme").is_ok());
158    }
159
160    #[test]
161    fn ensure_one_of_rejects_unknown() {
162        let allowed = ["dark".to_string(), "light".to_string()];
163        let err = ensure_one_of(&"solarized".to_string(), &allowed, "theme").unwrap_err();
164        assert_eq!(err.field, "theme");
165        assert!(err.message.contains("solarized"));
166        assert!(err.message.contains("dark"));
167        assert!(err.message.contains("light"));
168    }
169
170    #[test]
171    fn ensure_non_empty_str_works() {
172        assert!(ensure_non_empty_str("x", "name").is_ok());
173        let err = ensure_non_empty_str("", "name").unwrap_err();
174        assert_eq!(err.field, "name");
175    }
176
177    #[test]
178    fn validation_error_display_includes_field_and_message() {
179        let err = ValidationError::new("editor.tab_width", "value 0 not in 1..=16");
180        let s = err.to_string();
181        assert!(s.contains("editor.tab_width"));
182        assert!(s.contains("value 0 not in 1..=16"));
183    }
184}