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
9pub 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
27impl<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
38impl<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 #[must_use]
48 pub fn label(mut self, label: &'static str) -> Self {
49 self.meta.label = Some(label.into());
50 self
51 }
52
53 #[must_use]
55 pub fn description(mut self, description: &'static str) -> Self {
56 self.meta.description = Some(description.into());
57 self
58 }
59
60 #[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#[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}