Skip to main content

formars_core/
rule.rs

1//! Custom validation rules that compose identically to built-in checks.
2//!
3//! Rules observe post-normalization values, return rejection data, and are
4//! stored boxed inside schemas. Sync-only by signature (no async types), and
5//! every `Rule` is `Send + Sync`: rules may be validated from any thread and
6//! shared across threads via `Arc` without additional bounds.
7//!
8//! ```
9//! use formars_core::error::IssueCode;
10//! use formars_core::rule::{RefineRejection, Rule};
11//! use std::borrow::Cow;
12//!
13//! #[derive(Debug)]
14//! struct StartsWith(char);
15//!
16//! impl Rule<str> for StartsWith {
17//!     fn name(&self) -> &str { "starts_with" }
18//!     fn validate(&self, value: &str) -> Option<RefineRejection> {
19//!         if value.starts_with(self.0) { None } else {
20//!             Some(RefineRejection {
21//!                 code: Some(IssueCode::Custom("starts_with".into())),
22//!                 message: Cow::Borrowed("must start with the expected char"),
23//!                 params: Vec::new(),
24//!             })
25//!         }
26//!     }
27//! }
28//!
29//! use formars_core::schema::Schema;
30//! use formars_core::types::string;
31//! let schema = string().min(2).rule(StartsWith('a'));
32//! assert!(schema.parse(&"apple".to_string()).is_ok());
33//!
34//! // The rule's own code survives to the issue, so a translation table can
35//! // key off it without ever reading `message`.
36//! let err = schema.parse(&"pear".to_string()).unwrap_err();
37//! assert_eq!(err.issues[0].code, IssueCode::Custom("starts_with".into()));
38//! ```
39
40use std::borrow::Cow;
41use std::fmt;
42
43use crate::error::{IssueCode, IssueParams};
44
45/// Rejection data returned by a rule; the owning kernel handles paths,
46/// accumulation and fail-fast policy.
47#[derive(Clone, Debug, PartialEq)]
48pub struct RefineRejection {
49    /// `None` maps to [`IssueCode::Refine`], which every rule shares. Carry
50    /// an [`IssueCode::Custom`] instead to keep several custom rules
51    /// distinguishable by code alone.
52    pub code: Option<IssueCode>,
53    /// Human-readable rejection message.
54    pub message: Cow<'static, str>,
55    /// Ordered structured parameters.
56    pub params: IssueParams,
57}
58
59/// Object-safe extension point stored boxed (`Box<dyn Rule<T>>`) inside schemas (SC-7).
60///
61/// Rules observe post-normalization values and run strictly after builtin
62/// checks. Sync-only by signature (RF-3): no future/async types exist here.
63///
64/// The `Send + Sync` supertraits guarantee that every erased view
65/// (`Box<dyn Rule<T>>`, `&dyn Rule<T>`, `Arc<dyn Rule<T>>`) is usable from
66/// multiple threads. This is a breaking contract for hypothetical custom
67/// impls holding `!Send`/`!Sync` state; pre-1.0 this may change in a minor
68/// release.
69pub trait Rule<T: ?Sized>: fmt::Debug + Send + Sync {
70    /// Stable identifier for the rule, used for diagnostics and `Debug`
71    /// output only. In v0 it is NOT embedded in issue params and NOT surfaced
72    /// via `shape()`; surfacing remains a candidate future contract change.
73    /// Borrowed from the rule itself so generated names need no `'static`
74    /// interning.
75    fn name(&self) -> &str;
76
77    /// Returns rejection data on failure; `None` on pass.
78    fn validate(&self, value: &T) -> Option<RefineRejection>;
79}
80
81/// Adapter wrapping a sync closure as a [`Rule`] (used by `.refine(...)`).
82pub struct ClosureRule<T: ?Sized> {
83    f: Box<dyn Fn(&T) -> bool + Send + Sync>,
84    name: Cow<'static, str>,
85}
86
87/// Debug output shows only the stable name (closures are not printable).
88impl<T: ?Sized> fmt::Debug for ClosureRule<T> {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.debug_struct("ClosureRule")
91            .field("name", &self.name)
92            .finish_non_exhaustive()
93    }
94}
95
96impl<T: ?Sized> ClosureRule<T> {
97    /// Wraps a closure under the default name `"refine-0"`.
98    #[must_use]
99    pub fn new(f: impl Fn(&T) -> bool + Send + Sync + 'static) -> Self {
100        Self {
101            f: Box::new(f),
102            name: Cow::Borrowed("refine-0"),
103        }
104    }
105
106    /// Ordinal-based name (`"refine-3"`), stable within a schema instance.
107    /// The formatted name is owned by the rule; nothing is leaked.
108    #[must_use]
109    pub fn with_ordinal(ordinal: usize, f: impl Fn(&T) -> bool + Send + Sync + 'static) -> Self {
110        Self {
111            f: Box::new(f),
112            name: Cow::Owned(format!("refine-{ordinal}")),
113        }
114    }
115}
116
117impl<T: ?Sized> Rule<T> for ClosureRule<T> {
118    fn name(&self) -> &str {
119        &self.name
120    }
121
122    fn validate(&self, value: &T) -> Option<RefineRejection> {
123        if (self.f)(value) {
124            None
125        } else {
126            Some(RefineRejection {
127                code: Some(IssueCode::Refine),
128                message: Cow::Borrowed("failed refinement check"),
129                params: Vec::new(),
130            })
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use crate::error::IssueCode;
138    use crate::rule::{ClosureRule, RefineRejection, Rule};
139    use std::borrow::Cow;
140
141    #[test]
142    fn rf3_closure_rule_signature_is_sync() {
143        fn assert_sync<T: Sync>() {}
144        fn assert_send<T: Send>() {}
145        assert_sync::<ClosureRule<fn(&str) -> bool>>();
146        assert_send::<ClosureRule<fn(&str) -> bool>>();
147    }
148
149    #[test]
150    fn closure_rule_pass_yields_none() {
151        let r = ClosureRule::new(|_s: &str| true);
152        assert!(r.validate("abc").is_none());
153    }
154
155    #[test]
156    fn closure_rule_fail_yields_default_rejection() {
157        let r = ClosureRule::new(|_s: &str| false);
158        let rej: RefineRejection = r.validate("abc").unwrap();
159        assert_eq!(rej.code, Some(IssueCode::Refine));
160        assert_eq!(rej.message, Cow::Borrowed("failed refinement check"));
161    }
162
163    #[test]
164    fn closure_rule_name_is_refine_0_by_default() {
165        let r = ClosureRule::new(|_s: &str| false);
166        assert_eq!(r.name(), "refine-0");
167    }
168
169    #[test]
170    fn closure_rule_with_ordinal_names_stably() {
171        let r = ClosureRule::with_ordinal(2, |_s: &str| false);
172        assert_eq!(r.name(), "refine-2");
173    }
174
175    #[test]
176    fn closure_rule_ordinal_name_owned_not_leaked() {
177        // Arbitrary ordinals must render identically to the pre-Cow interned
178        // form; the name now borrows from the rule instance itself.
179        let r = ClosureRule::with_ordinal(4096, |_s: &str| true);
180        assert_eq!(r.name(), "refine-4096");
181    }
182
183    #[test]
184    fn custom_rule_implementor_participates() {
185        #[derive(Debug)]
186        struct StartsWith(char);
187        impl Rule<str> for StartsWith {
188            fn name(&self) -> &'static str {
189                "starts_with"
190            }
191            fn validate(&self, value: &str) -> Option<RefineRejection> {
192                if value.starts_with(self.0) {
193                    None
194                } else {
195                    Some(RefineRejection {
196                        code: None,
197                        message: format!("must start with '{}'", self.0).into(),
198                        params: Vec::new(),
199                    })
200                }
201            }
202        }
203        let r = StartsWith('a');
204        assert!(r.validate("apple").is_none());
205        let rej = r.validate("pear").unwrap();
206        assert_eq!(rej.code, None, "None means IssueCode::Refine at the kernel");
207        assert_eq!(r.name(), "starts_with");
208    }
209}