Skip to main content

siderite_validation/
context.rs

1//! Validation context: current location, collected errors, model settings
2//! and user data shared with validators.
3
4use crate::error::{FieldError, LocationItem, ValidationError};
5use std::any::{Any, TypeId};
6use std::borrow::Cow;
7use std::collections::HashMap;
8
9/// How unknown input keys are treated (Pydantic `extra`).
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum Extra {
12    /// Drop unknown keys silently (default).
13    #[default]
14    Ignore,
15    /// Reject unknown keys with `extra_forbidden`. Keys of fields Serde
16    /// skips when deserializing count as unknown, as with
17    /// `deny_unknown_fields`.
18    Forbid,
19    /// Accept unknown keys. Rust structs cannot store them, so they are
20    /// dropped after validation; see `PYDANTIC_EQUIVALENCE.md`.
21    Allow,
22}
23
24/// Per-model settings (Pydantic `model_config`) that leaf types consult.
25///
26/// Settings are **not** inherited by nested models: each derived model
27/// installs its own config while its fields are prepared.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub struct ModelConfig {
30    /// Reject lax coercions (e.g. `"1"` for an integer).
31    pub strict: bool,
32    /// Unknown-key handling.
33    pub extra: Extra,
34    /// Trim surrounding whitespace of string inputs.
35    pub str_strip_whitespace: bool,
36    /// Lower-case string inputs.
37    pub str_to_lower: bool,
38    /// Upper-case string inputs.
39    pub str_to_upper: bool,
40}
41
42impl ModelConfig {
43    /// Default configuration (lax, ignore extras, no string transforms).
44    pub const DEFAULT: Self = Self {
45        strict: false,
46        extra: Extra::Ignore,
47        str_strip_whitespace: false,
48        str_to_lower: false,
49        str_to_upper: false,
50    };
51}
52
53/// Where the input came from; text sources (query strings, forms, path
54/// segments) always allow string → scalar coercion, even in strict mode,
55/// because they cannot carry typed values.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum InputKind {
58    /// JSON (typed) input.
59    #[default]
60    Json,
61    /// Text-only input (query, form, path).
62    Text,
63}
64
65/// Mutable state threaded through `prepare` and `validate`.
66#[derive(Default)]
67pub struct ValidationContext {
68    config: ModelConfig,
69    strict_override: Option<bool>,
70    input: InputKind,
71    location: Vec<LocationItem>,
72    errors: ValidationError,
73    data: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
74}
75
76impl std::fmt::Debug for ValidationContext {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        f.debug_struct("ValidationContext")
79            .field("config", &self.config)
80            .field("strict_override", &self.strict_override)
81            .field("input", &self.input)
82            .field("location", &self.location)
83            .field("errors", &self.errors)
84            .finish_non_exhaustive()
85    }
86}
87
88impl ValidationContext {
89    /// Fresh context for JSON input.
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Context for text input (query strings, forms, path segments).
95    pub fn for_text() -> Self {
96        Self {
97            input: InputKind::Text,
98            ..Self::default()
99        }
100    }
101
102    /// Force strict (`true`) or lax (`false`) mode for every model, like
103    /// Pydantic's `model_validate(strict=...)`.
104    #[must_use]
105    pub fn with_strict(mut self, strict: bool) -> Self {
106        self.strict_override = Some(strict);
107        self
108    }
109
110    /// Prefix every reported location with `item` (e.g. `"body"`).
111    #[must_use]
112    pub fn at_root(mut self, item: impl Into<LocationItem>) -> Self {
113        self.location.push(item.into());
114        self
115    }
116
117    /// Attach user data readable by validators (Pydantic validation context).
118    #[must_use]
119    pub fn with_data<T: Any + Send + Sync>(mut self, value: T) -> Self {
120        self.data.insert(TypeId::of::<T>(), Box::new(value));
121        self
122    }
123
124    /// User data of type `T`, if attached.
125    pub fn data<T: Any + Send + Sync>(&self) -> Option<&T> {
126        self.data.get(&TypeId::of::<T>())?.downcast_ref()
127    }
128
129    /// Settings of the model currently being processed.
130    pub fn config(&self) -> &ModelConfig {
131        &self.config
132    }
133
134    /// Whether lax string → scalar coercion is allowed right now.
135    pub fn allows_text_coercion(&self) -> bool {
136        self.input == InputKind::Text || !self.is_strict()
137    }
138
139    /// Effective strictness (override beats model config).
140    pub fn is_strict(&self) -> bool {
141        self.strict_override.unwrap_or(self.config.strict)
142    }
143
144    /// Input kind.
145    pub fn input_kind(&self) -> InputKind {
146        self.input
147    }
148
149    /// Run `f` with `config` installed, restoring the previous one after.
150    pub fn with_config<R>(&mut self, config: ModelConfig, f: impl FnOnce(&mut Self) -> R) -> R {
151        let previous = std::mem::replace(&mut self.config, config);
152        let out = f(self);
153        self.config = previous;
154        out
155    }
156
157    /// Run `f` with `item` appended to the current location.
158    pub fn at<R>(&mut self, item: impl Into<LocationItem>, f: impl FnOnce(&mut Self) -> R) -> R {
159        self.location.push(item.into());
160        let out = f(self);
161        self.location.pop();
162        out
163    }
164
165    /// Current location.
166    pub fn location(&self) -> &[LocationItem] {
167        &self.location
168    }
169
170    /// Record an error at the current location.
171    pub fn error(&mut self, code: impl Into<Cow<'static, str>>, message: impl Into<String>) {
172        self.push(FieldError::new(code, message));
173    }
174
175    /// Record `error`, prefixing its location with the current location.
176    pub fn push(&mut self, mut error: FieldError) {
177        let mut location = self.location.clone();
178        location.append(&mut error.location);
179        error.location = location;
180        self.errors.push(error);
181    }
182
183    /// Record the result of a rule check at the current location.
184    pub fn check(&mut self, result: Result<(), FieldError>) {
185        if let Err(error) = result {
186            self.push(error);
187        }
188    }
189
190    /// Whether any error has been recorded.
191    pub fn has_errors(&self) -> bool {
192        !self.errors.is_empty()
193    }
194
195    /// Number of errors recorded so far (useful to detect new ones).
196    pub fn error_count(&self) -> usize {
197        self.errors.errors.len()
198    }
199
200    /// Take the collected errors, leaving the context empty of errors.
201    pub fn take_errors(&mut self) -> ValidationError {
202        std::mem::take(&mut self.errors)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn locations_nest_and_restore() {
212        let mut ctx = ValidationContext::new().at_root("body");
213        ctx.at("user", |ctx| ctx.at(0_usize, |ctx| ctx.error("x", "bad")));
214        ctx.error("y", "top");
215        let errors = ctx.take_errors().errors;
216        assert_eq!(
217            errors[0].location,
218            vec!["body".into(), "user".into(), LocationItem::index(0)]
219        );
220        assert_eq!(errors[1].location, vec![LocationItem::key("body")]);
221    }
222
223    #[test]
224    fn strictness_override_and_text_input() {
225        let strict = ModelConfig {
226            strict: true,
227            ..ModelConfig::DEFAULT
228        };
229        let mut ctx = ValidationContext::new();
230        ctx.with_config(strict, |ctx| {
231            assert!(ctx.is_strict());
232            assert!(!ctx.allows_text_coercion());
233        });
234        assert!(!ctx.is_strict());
235        let mut text = ValidationContext::for_text().with_strict(true);
236        assert!(text.allows_text_coercion());
237        text.with_config(ModelConfig::DEFAULT, |ctx| assert!(ctx.is_strict()));
238    }
239
240    #[test]
241    fn user_data_round_trips() {
242        let ctx = ValidationContext::new().with_data(42_u32);
243        assert_eq!(ctx.data::<u32>(), Some(&42));
244        assert!(ctx.data::<i64>().is_none());
245    }
246}