Skip to main content

formars_core/
coerce.rs

1use std::marker::PhantomData;
2use std::str::FromStr;
3use std::sync::OnceLock;
4
5use crate::error::{FormaError, FormaIssue, IssueCode};
6use crate::schema::{ConstraintDesc, DynSchema, FieldMeta, Schema, ShapeKind, ShapeNode};
7use crate::value::{ToValue, Value};
8
9/// Parses strings into `T: FromStr` — the duality citizen with
10/// `Input = String`, `Output = T` (SC-8), mirroring HTML form input.
11pub struct CoercedSchema<T: FromStr> {
12    meta: FieldMeta,
13    shape_cache: OnceLock<ShapeNode>,
14    _marker: PhantomData<fn() -> T>,
15}
16
17impl<T: FromStr> Clone for CoercedSchema<T> {
18    fn clone(&self) -> Self {
19        Self {
20            meta: self.meta.clone(),
21            shape_cache: OnceLock::new(),
22            _marker: PhantomData,
23        }
24    }
25}
26
27/// Creates an empty schema via [`coerced()`].
28impl<T: FromStr> Default for CoercedSchema<T> {
29    fn default() -> Self {
30        Self {
31            meta: FieldMeta::default(),
32            shape_cache: OnceLock::new(),
33            _marker: PhantomData,
34        }
35    }
36}
37
38/// Type-erased debug output.
39impl<T: FromStr> std::fmt::Debug for CoercedSchema<T> {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("CoercedSchema").finish()
42    }
43}
44
45impl<T: FromStr> CoercedSchema<T> {
46    /// Sets the UI label metadata slot.
47    #[must_use]
48    pub fn label(mut self, label: &'static str) -> Self {
49        self.meta.label = Some(label.into());
50        self
51    }
52
53    /// Sets the UI description metadata slot.
54    #[must_use]
55    pub fn description(mut self, description: &'static str) -> Self {
56        self.meta.description = Some(description.into());
57        self
58    }
59
60    /// Sets the UI placeholder metadata slot.
61    #[must_use]
62    pub fn placeholder(mut self, placeholder: &'static str) -> Self {
63        self.meta.placeholder = Some(placeholder.into());
64        self
65    }
66
67    fn bridge(v: &Value) -> Option<&str> {
68        match v {
69            Value::String(s) => Some(s),
70            _ => None,
71        }
72    }
73
74    fn coerce(input: &str) -> Result<T, FormaIssue> {
75        T::from_str(input).map_err(|_| {
76            crate::schema::issue_at_root(
77                IssueCode::Coerce,
78                format!("cannot parse {input:?} into the target type").into(),
79                Vec::new(),
80            )
81        })
82    }
83}
84
85impl<T: FromStr> Schema for CoercedSchema<T> {
86    type Input = String;
87    type Output = T;
88
89    fn parse(&self, input: &Self::Input) -> Result<Self::Output, FormaError> {
90        Self::coerce(input).map_err(|issue| FormaError {
91            issues: vec![issue],
92        })
93    }
94}
95
96impl<T: FromStr + 'static> DynSchema for CoercedSchema<T> {
97    fn validate_value(&self, v: &Value) -> Vec<FormaIssue> {
98        match Self::bridge(v) {
99            Some(s) => match Self::coerce(s) {
100                Ok(_) => Vec::new(),
101                Err(issue) => vec![issue],
102            },
103            None => vec![crate::schema::issue_at_root(
104                IssueCode::TypeMismatch,
105                "value is not a string".into(),
106                Vec::new(),
107            )],
108        }
109    }
110
111    fn shape(&self) -> &ShapeNode {
112        self.shape_cache.get_or_init(|| ShapeNode {
113            kind: ShapeKind::Coerced,
114            constraints: vec![ConstraintDesc {
115                code: IssueCode::Coerce,
116                params: Vec::new(),
117            }],
118        })
119    }
120
121    fn metadata(&self) -> &FieldMeta {
122        &self.meta
123    }
124}
125
126/// Creates a schema that parses strings into `T` (HTML form inputs always yield strings).
127#[must_use]
128pub fn coerced<T: FromStr>() -> CoercedSchema<T> {
129    CoercedSchema::default()
130}
131
132impl<T: FromStr + ToValue + 'static> crate::schema::ObjectChild for CoercedSchema<T> {
133    fn validate_at(
134        &self,
135        v: &Value,
136        path: &crate::error::FieldPath,
137        _fail_fast: bool,
138    ) -> Result<Value, Vec<FormaIssue>> {
139        match Self::bridge(v) {
140            Some(s) => match Self::coerce(s) {
141                Ok(t) => Ok(t.to_value()),
142                Err(mut issue) => {
143                    issue.path = path.clone();
144                    Err(vec![issue])
145                }
146            },
147            None => Err(vec![FormaIssue {
148                path: path.clone(),
149                code: IssueCode::TypeMismatch,
150                message: "value is not a string".into(),
151                params: Vec::new(),
152            }]),
153        }
154    }
155
156    fn shape_node(&self) -> ShapeNode {
157        self.shape().clone()
158    }
159
160    fn meta(&self) -> &FieldMeta {
161        &self.meta
162    }
163
164    fn clone_boxed(&self) -> Box<dyn crate::schema::ObjectChild> {
165        Box::new(self.clone())
166    }
167}
168
169impl<T: FromStr + ToValue> crate::schema::sealed::Sealed for CoercedSchema<T> {}
170
171#[cfg(test)]
172mod tests {
173    use crate::coerce::coerced;
174    use crate::error::IssueCode;
175    use crate::schema::{DynSchema, Schema};
176    use crate::value::Value;
177
178    #[test]
179    fn sc8_coercion_success() {
180        let s = coerced::<u32>();
181        assert_eq!(s.parse(&"42".to_string()), Ok(42u32));
182    }
183
184    #[test]
185    fn sc8_coercion_failure_yields_coerce_code() {
186        let s = coerced::<u32>();
187        let err = s.parse(&"abc".to_string()).unwrap_err();
188        assert!(!err.issues.is_empty());
189        assert_eq!(err.first().unwrap().code, IssueCode::Coerce);
190        assert!(!err.first().unwrap().message.is_empty());
191    }
192
193    #[test]
194    fn dv5_form_input_string_via_dyn_dynschema() {
195        let s: Box<dyn DynSchema> = Box::new(coerced::<u32>());
196        let issues = s.validate_value(&Value::String("42".into()));
197        assert!(
198            issues.is_empty(),
199            "HTML-form-style string input must validate via coercion"
200        );
201        let bad = s.validate_value(&Value::String("abc".into()));
202        assert_eq!(bad.len(), 1);
203        assert_eq!(bad[0].code, IssueCode::Coerce);
204    }
205
206    #[test]
207    fn bridge_type_mismatch_never_panics() {
208        let s: Box<dyn DynSchema> = Box::new(coerced::<u32>());
209        let issues = s.validate_value(&Value::Bool(true));
210        assert_eq!(issues.len(), 1);
211    }
212}