1use fig::{ExtKind, Value};
9
10use crate::path::{PathPat, Seg};
11use crate::present::Presentation;
12use crate::vocab::{Validate, Validation};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum FieldType {
17 Null,
18 Bool,
19 Int,
20 Float,
21 Str,
22 Ref,
24 Extended(ExtKind),
29 Map,
30 Seq,
31}
32
33impl FieldType {
34 pub fn coerce(self, s: &str) -> Value {
44 let t = s.trim();
45 match self {
46 FieldType::Null => match t {
49 "" | "~" => Value::Null,
50 _ if t.eq_ignore_ascii_case("null") => Value::Null,
51 _ => Value::Str(s.to_string()),
52 },
53 FieldType::Bool => match t.to_ascii_lowercase().as_str() {
58 "true" | "yes" | "on" => Value::Bool(true),
59 "false" | "no" | "off" => Value::Bool(false),
60 _ => Value::Str(s.to_string()),
61 },
62 FieldType::Int => t
63 .parse::<i64>()
64 .map(Value::Int)
65 .or_else(|_| t.parse::<u64>().map(Value::Uint))
66 .unwrap_or_else(|_| Value::Str(s.to_string())),
67 FieldType::Float => t
68 .parse::<f64>()
69 .map(Value::Float)
70 .unwrap_or_else(|_| Value::Str(s.to_string())),
71 FieldType::Extended(kind) => {
72 if extended_text_fits(kind, t) {
73 Value::Extended {
74 kind,
75 text: t.to_string(),
76 }
77 } else {
78 Value::Str(s.to_string())
79 }
80 }
81 FieldType::Str | FieldType::Ref | FieldType::Map | FieldType::Seq => {
84 Value::Str(s.to_string())
85 }
86 }
87 }
88}
89
90fn extended_text_fits(kind: ExtKind, text: &str) -> bool {
97 if text.is_empty() {
98 return false;
99 }
100 match kind {
101 ExtKind::OffsetDateTime
103 | ExtKind::LocalDateTime
104 | ExtKind::LocalDate
105 | ExtKind::LocalTime => text.chars().all(|c| {
106 c.is_ascii_digit() || matches!(c, '-' | ':' | '.' | '+' | 'T' | 't' | 'Z' | 'z' | ' ')
107 }),
108 ExtKind::EnumLiteral => {
110 let mut chars = text.chars();
111 chars.next().is_some_and(|c| c.is_alphabetic() || c == '_')
112 && chars.all(|c| c.is_alphanumeric() || c == '_')
113 }
114 ExtKind::CharLiteral => text.chars().all(|c| c.is_ascii_digit()),
116 ExtKind::NumberSpecial => matches!(
117 text,
118 "Infinity" | "-Infinity" | "+Infinity" | "NaN" | "-NaN" | "+NaN"
119 ),
120 _ => true,
125 }
126}
127
128#[derive(Debug, Clone)]
131pub struct FieldRule<C> {
132 pub at: PathPat,
134 pub ty: Option<FieldType>,
136 pub constraint: Option<C>,
138 pub present: Presentation,
140}
141
142impl<C: Validate> FieldRule<C> {
143 pub fn validate(&self, value: &Value) -> Validation {
146 match &self.constraint {
147 Some(c) => c.validate(value),
148 None => Validation::Ok,
149 }
150 }
151}
152
153#[derive(Debug, Clone)]
156pub struct Schema<C> {
157 rules: Vec<FieldRule<C>>,
158}
159
160impl<C> Default for Schema<C> {
161 fn default() -> Self {
162 Self { rules: Vec::new() }
163 }
164}
165
166impl<C> Schema<C> {
167 pub fn new(rules: Vec<FieldRule<C>>) -> Self {
169 Self { rules }
170 }
171
172 pub fn rules(&self) -> &[FieldRule<C>] {
174 &self.rules
175 }
176
177 pub fn is_empty(&self) -> bool {
179 self.rules.is_empty()
180 }
181
182 pub fn rule_for(&self, path: &[Seg]) -> Option<&FieldRule<C>> {
185 self.rules.iter().find(|r| r.at.matches(path))
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::vocab::Issue;
193
194 #[test]
195 fn type_directed_parse_keeps_a_string_field_a_string() {
196 assert_eq!(FieldType::Str.coerce("123"), Value::Str("123".into()));
197 assert_eq!(FieldType::Int.coerce("123"), Value::Int(123));
198 assert_eq!(FieldType::Bool.coerce("true"), Value::Bool(true));
199 assert_eq!(FieldType::Int.coerce("abc"), Value::Str("abc".into()));
201 }
202
203 #[test]
204 fn a_null_field_keeps_text_it_cannot_read_as_null() {
205 assert_eq!(FieldType::Null.coerce(""), Value::Null);
206 assert_eq!(FieldType::Null.coerce("null"), Value::Null);
207 assert_eq!(FieldType::Null.coerce("NULL"), Value::Null);
208 assert_eq!(FieldType::Null.coerce("~"), Value::Null);
209 assert_eq!(
211 FieldType::Null.coerce("important data"),
212 Value::Str("important data".into())
213 );
214 }
215
216 #[test]
217 fn a_bool_field_accepts_the_yaml_spellings() {
218 for yes in ["true", "True", "TRUE", "yes", "Yes", "on"] {
219 assert_eq!(FieldType::Bool.coerce(yes), Value::Bool(true), "{yes}");
220 }
221 for no in ["false", "False", "FALSE", "no", "No", "off"] {
222 assert_eq!(FieldType::Bool.coerce(no), Value::Bool(false), "{no}");
223 }
224 assert_eq!(FieldType::Bool.coerce("maybe"), Value::Str("maybe".into()));
225 }
226
227 #[test]
228 fn an_extended_field_keeps_its_native_type() {
229 let ty = FieldType::Extended(ExtKind::LocalDate);
230 assert_eq!(
231 ty.coerce("1979-05-27"),
232 Value::Extended {
233 kind: ExtKind::LocalDate,
234 text: "1979-05-27".into(),
235 }
236 );
237 assert_eq!(ty.coerce("not a date"), Value::Str("not a date".into()));
240 assert_eq!(ty.coerce(""), Value::Str("".into()));
241 }
242
243 #[test]
244 fn extended_shape_guard_covers_every_kind() {
245 assert!(extended_text_fits(
246 ExtKind::OffsetDateTime,
247 "1979-05-27T07:32:00Z"
248 ));
249 assert!(extended_text_fits(ExtKind::LocalTime, "07:32:00.999"));
250 assert!(extended_text_fits(ExtKind::EnumLiteral, "foo_bar"));
251 assert!(!extended_text_fits(ExtKind::EnumLiteral, "9lives"));
252 assert!(!extended_text_fits(ExtKind::EnumLiteral, "has space"));
253 assert!(extended_text_fits(ExtKind::CharLiteral, "97"));
254 assert!(!extended_text_fits(ExtKind::CharLiteral, "a"));
255 assert!(extended_text_fits(ExtKind::NumberSpecial, "-Infinity"));
256 assert!(!extended_text_fits(ExtKind::NumberSpecial, "inf"));
257 }
258
259 #[derive(Debug, Clone)]
262 struct AlwaysReject;
263 impl Validate for AlwaysReject {
264 fn validate(&self, _value: &Value) -> Validation {
265 Validation::Reject(Issue::custom("", "no"))
266 }
267 }
268
269 #[test]
270 fn rule_validate_dispatches_to_the_embedder_constraint() {
271 let rule = FieldRule {
272 at: PathPat::key("status"),
273 ty: Some(FieldType::Str),
274 constraint: Some(AlwaysReject),
275 present: Presentation::default(),
276 };
277 assert!(rule.validate(&Value::Str("anything".into())).is_reject());
278 }
279
280 #[test]
281 fn rule_with_no_constraint_always_validates_ok() {
282 let rule: FieldRule<AlwaysReject> = FieldRule {
283 at: PathPat::key("status"),
284 ty: None,
285 constraint: None,
286 present: Presentation::default(),
287 };
288 assert_eq!(
289 rule.validate(&Value::Str("anything".into())),
290 Validation::Ok
291 );
292 }
293
294 #[test]
295 fn schema_rule_for_finds_first_match_in_declaration_order() {
296 let schema = Schema::new(vec![
297 FieldRule {
298 at: PathPat::each_item_of("tags"),
299 ty: Some(FieldType::Str),
300 constraint: None::<AlwaysReject>,
301 present: Presentation::default(),
302 },
303 FieldRule {
304 at: PathPat::key("title"),
305 ty: Some(FieldType::Str),
306 constraint: None,
307 present: Presentation::default(),
308 },
309 ]);
310 assert!(schema.rule_for(&[Seg::Key("title".into())]).is_some());
311 assert!(
312 schema
313 .rule_for(&[Seg::Key("tags".into()), Seg::Index(0)])
314 .is_some()
315 );
316 assert!(schema.rule_for(&[Seg::Key("missing".into())]).is_none());
317 }
318
319 #[test]
320 fn a_specific_rule_takes_precedence_over_a_subtree_rule() {
321 let schema = Schema::new(vec![
322 FieldRule {
323 at: PathPat(vec![
324 crate::SegPat::Key("meta".into()),
325 crate::SegPat::Key("id".into()),
326 ]),
327 ty: Some(FieldType::Int),
328 constraint: None::<AlwaysReject>,
329 present: Presentation::default(),
330 },
331 FieldRule {
332 at: PathPat::subtree_of("meta"),
333 ty: Some(FieldType::Str),
334 constraint: None,
335 present: Presentation::default(),
336 },
337 ]);
338 let id = [Seg::Key("meta".into()), Seg::Key("id".into())];
339 assert_eq!(schema.rule_for(&id).unwrap().ty, Some(FieldType::Int));
340 let other = [Seg::Key("meta".into()), Seg::Key("author".into())];
341 assert_eq!(schema.rule_for(&other).unwrap().ty, Some(FieldType::Str));
342 }
343}