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 }
121}
122
123#[derive(Debug, Clone)]
126pub struct FieldRule<C> {
127 pub at: PathPat,
129 pub ty: Option<FieldType>,
131 pub constraint: Option<C>,
133 pub present: Presentation,
135}
136
137impl<C: Validate> FieldRule<C> {
138 pub fn validate(&self, value: &Value) -> Validation {
141 match &self.constraint {
142 Some(c) => c.validate(value),
143 None => Validation::Ok,
144 }
145 }
146}
147
148#[derive(Debug, Clone)]
151pub struct Schema<C> {
152 rules: Vec<FieldRule<C>>,
153}
154
155impl<C> Default for Schema<C> {
156 fn default() -> Self {
157 Self { rules: Vec::new() }
158 }
159}
160
161impl<C> Schema<C> {
162 pub fn new(rules: Vec<FieldRule<C>>) -> Self {
164 Self { rules }
165 }
166
167 pub fn rules(&self) -> &[FieldRule<C>] {
169 &self.rules
170 }
171
172 pub fn is_empty(&self) -> bool {
174 self.rules.is_empty()
175 }
176
177 pub fn rule_for(&self, path: &[Seg]) -> Option<&FieldRule<C>> {
180 self.rules.iter().find(|r| r.at.matches(path))
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::vocab::Issue;
188
189 #[test]
190 fn type_directed_parse_keeps_a_string_field_a_string() {
191 assert_eq!(FieldType::Str.coerce("123"), Value::Str("123".into()));
192 assert_eq!(FieldType::Int.coerce("123"), Value::Int(123));
193 assert_eq!(FieldType::Bool.coerce("true"), Value::Bool(true));
194 assert_eq!(FieldType::Int.coerce("abc"), Value::Str("abc".into()));
196 }
197
198 #[test]
199 fn a_null_field_keeps_text_it_cannot_read_as_null() {
200 assert_eq!(FieldType::Null.coerce(""), Value::Null);
201 assert_eq!(FieldType::Null.coerce("null"), Value::Null);
202 assert_eq!(FieldType::Null.coerce("NULL"), Value::Null);
203 assert_eq!(FieldType::Null.coerce("~"), Value::Null);
204 assert_eq!(
206 FieldType::Null.coerce("important data"),
207 Value::Str("important data".into())
208 );
209 }
210
211 #[test]
212 fn a_bool_field_accepts_the_yaml_spellings() {
213 for yes in ["true", "True", "TRUE", "yes", "Yes", "on"] {
214 assert_eq!(FieldType::Bool.coerce(yes), Value::Bool(true), "{yes}");
215 }
216 for no in ["false", "False", "FALSE", "no", "No", "off"] {
217 assert_eq!(FieldType::Bool.coerce(no), Value::Bool(false), "{no}");
218 }
219 assert_eq!(FieldType::Bool.coerce("maybe"), Value::Str("maybe".into()));
220 }
221
222 #[test]
223 fn an_extended_field_keeps_its_native_type() {
224 let ty = FieldType::Extended(ExtKind::LocalDate);
225 assert_eq!(
226 ty.coerce("1979-05-27"),
227 Value::Extended {
228 kind: ExtKind::LocalDate,
229 text: "1979-05-27".into(),
230 }
231 );
232 assert_eq!(ty.coerce("not a date"), Value::Str("not a date".into()));
235 assert_eq!(ty.coerce(""), Value::Str("".into()));
236 }
237
238 #[test]
239 fn extended_shape_guard_covers_every_kind() {
240 assert!(extended_text_fits(
241 ExtKind::OffsetDateTime,
242 "1979-05-27T07:32:00Z"
243 ));
244 assert!(extended_text_fits(ExtKind::LocalTime, "07:32:00.999"));
245 assert!(extended_text_fits(ExtKind::EnumLiteral, "foo_bar"));
246 assert!(!extended_text_fits(ExtKind::EnumLiteral, "9lives"));
247 assert!(!extended_text_fits(ExtKind::EnumLiteral, "has space"));
248 assert!(extended_text_fits(ExtKind::CharLiteral, "97"));
249 assert!(!extended_text_fits(ExtKind::CharLiteral, "a"));
250 assert!(extended_text_fits(ExtKind::NumberSpecial, "-Infinity"));
251 assert!(!extended_text_fits(ExtKind::NumberSpecial, "inf"));
252 }
253
254 #[derive(Debug, Clone)]
257 struct AlwaysReject;
258 impl Validate for AlwaysReject {
259 fn validate(&self, _value: &Value) -> Validation {
260 Validation::Reject(Issue::custom("", "no"))
261 }
262 }
263
264 #[test]
265 fn rule_validate_dispatches_to_the_embedder_constraint() {
266 let rule = FieldRule {
267 at: PathPat::key("status"),
268 ty: Some(FieldType::Str),
269 constraint: Some(AlwaysReject),
270 present: Presentation::default(),
271 };
272 assert!(rule.validate(&Value::Str("anything".into())).is_reject());
273 }
274
275 #[test]
276 fn rule_with_no_constraint_always_validates_ok() {
277 let rule: FieldRule<AlwaysReject> = FieldRule {
278 at: PathPat::key("status"),
279 ty: None,
280 constraint: None,
281 present: Presentation::default(),
282 };
283 assert_eq!(
284 rule.validate(&Value::Str("anything".into())),
285 Validation::Ok
286 );
287 }
288
289 #[test]
290 fn schema_rule_for_finds_first_match_in_declaration_order() {
291 let schema = Schema::new(vec![
292 FieldRule {
293 at: PathPat::each_item_of("tags"),
294 ty: Some(FieldType::Str),
295 constraint: None::<AlwaysReject>,
296 present: Presentation::default(),
297 },
298 FieldRule {
299 at: PathPat::key("title"),
300 ty: Some(FieldType::Str),
301 constraint: None,
302 present: Presentation::default(),
303 },
304 ]);
305 assert!(schema.rule_for(&[Seg::Key("title".into())]).is_some());
306 assert!(
307 schema
308 .rule_for(&[Seg::Key("tags".into()), Seg::Index(0)])
309 .is_some()
310 );
311 assert!(schema.rule_for(&[Seg::Key("missing".into())]).is_none());
312 }
313
314 #[test]
315 fn a_specific_rule_takes_precedence_over_a_subtree_rule() {
316 let schema = Schema::new(vec![
317 FieldRule {
318 at: PathPat(vec![
319 crate::SegPat::Key("meta".into()),
320 crate::SegPat::Key("id".into()),
321 ]),
322 ty: Some(FieldType::Int),
323 constraint: None::<AlwaysReject>,
324 present: Presentation::default(),
325 },
326 FieldRule {
327 at: PathPat::subtree_of("meta"),
328 ty: Some(FieldType::Str),
329 constraint: None,
330 present: Presentation::default(),
331 },
332 ]);
333 let id = [Seg::Key("meta".into()), Seg::Key("id".into())];
334 assert_eq!(schema.rule_for(&id).unwrap().ty, Some(FieldType::Int));
335 let other = [Seg::Key("meta".into()), Seg::Key("author".into())];
336 assert_eq!(schema.rule_for(&other).unwrap().ty, Some(FieldType::Str));
337 }
338}