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}