1use crate::schema::{ContentSchema, FieldType};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum Severity {
19 Error = 1,
21 Warning = 2,
23 Info = 3,
25 Hint = 4,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Diagnostic {
32 pub severity: Severity,
34 pub message: String,
36 pub path: Option<String>,
38 pub line: Option<usize>,
40 pub column: Option<usize>,
42}
43
44pub fn validate_frontmatter(
48 fm: &HashMap<String, serde_yaml::Value>,
49 schema: &ContentSchema,
50) -> Vec<Diagnostic> {
51 let mut diags = Vec::new();
52
53 for (name, def) in &schema.frontmatter.fields {
55 match fm.get(name) {
56 None => {
57 if def.required {
58 diags.push(Diagnostic {
59 severity: Severity::Error,
60 message: format!("required field '{}' is missing", name),
61 path: Some(name.clone()),
62 line: None,
63 column: None,
64 });
65 }
66 }
67 Some(value) => {
68 if !value_matches_def(value, def) {
70 diags.push(Diagnostic {
71 severity: Severity::Error,
72 message: format!(
73 "field '{}' has wrong type: expected {}, got {}",
74 name,
75 type_name(&def.field_type),
76 yaml_type_name(value),
77 ),
78 path: Some(name.clone()),
79 line: None,
80 column: None,
81 });
82 }
83
84 if let Some(ref allowed) = def.enum_values {
86 if let Some(s) = value.as_str() {
87 if !allowed.contains(&s.to_string()) {
88 diags.push(Diagnostic {
89 severity: Severity::Error,
90 message: format!(
91 "field '{}' has invalid value '{}'; allowed: {:?}",
92 name, s, allowed
93 ),
94 path: Some(name.clone()),
95 line: None,
96 column: None,
97 });
98 }
99 }
100 }
101
102 if def.format.as_deref() == Some("date") {
104 if let Some(s) = value.as_str() {
105 if !is_valid_date(s) {
106 diags.push(Diagnostic {
107 severity: Severity::Warning,
108 message: format!(
109 "field '{}' has invalid date format '{}'; expected YYYY-MM-DD",
110 name, s
111 ),
112 path: Some(name.clone()),
113 line: None,
114 column: None,
115 });
116 }
117 }
118 }
119
120 if def.field_type == FieldType::Array {
122 if let (Some(items_def), Some(seq)) = (&def.items, value.as_sequence()) {
123 for (i, item) in seq.iter().enumerate() {
124 if !value_matches_type(item, &items_def.field_type) {
125 diags.push(Diagnostic {
126 severity: Severity::Error,
127 message: format!(
128 "field '{}[{}]' has wrong type: expected {}, got {}",
129 name,
130 i,
131 type_name(&items_def.field_type),
132 yaml_type_name(item),
133 ),
134 path: Some(format!("{}[{}]", name, i)),
135 line: None,
136 column: None,
137 });
138 }
139 }
140 }
141 }
142 }
143 }
144 }
145
146 for key in fm.keys() {
150 if schema.frontmatter.fields.contains_key(key)
151 || schema.frontmatter.internal_fields.contains(key)
152 {
153 continue;
154 }
155 diags.push(Diagnostic {
156 severity: Severity::Hint,
157 message: format!("unknown field '{}' is not defined in the schema", key),
158 path: Some(key.clone()),
159 line: None,
160 column: None,
161 });
162 }
163
164 diags
165}
166
167fn value_matches_def(value: &serde_yaml::Value, def: &crate::schema::FieldDefinition) -> bool {
170 if def.field_type == FieldType::OneOf {
171 return match &def.one_of {
172 Some(members) => members.iter().any(|m| value_matches_def(value, m)),
173 None => true,
176 };
177 }
178 value_matches_type(value, &def.field_type)
179}
180
181fn value_matches_type(value: &serde_yaml::Value, expected: &FieldType) -> bool {
183 match expected {
184 FieldType::String => value.is_string(),
185 FieldType::Boolean => value.is_bool(),
186 FieldType::Integer => {
187 value.is_i64() || value.is_u64()
189 }
190 FieldType::Number => {
191 value.is_number()
193 }
194 FieldType::Array => value.is_sequence(),
195 FieldType::Object => value.is_mapping(),
196 FieldType::OneOf => true,
199 }
200}
201
202fn type_name(ft: &FieldType) -> &'static str {
204 match ft {
205 FieldType::String => "string",
206 FieldType::Boolean => "boolean",
207 FieldType::Integer => "integer",
208 FieldType::Number => "number",
209 FieldType::Array => "array",
210 FieldType::Object => "object",
211 FieldType::OneOf => "one-of",
212 }
213}
214
215fn yaml_type_name(value: &serde_yaml::Value) -> &'static str {
217 match value {
218 serde_yaml::Value::Null => "null",
219 serde_yaml::Value::Bool(_) => "boolean",
220 serde_yaml::Value::Number(n) => {
221 if n.is_f64() && !n.is_i64() && !n.is_u64() {
222 "number"
223 } else {
224 "integer"
225 }
226 }
227 serde_yaml::Value::String(_) => "string",
228 serde_yaml::Value::Sequence(_) => "array",
229 serde_yaml::Value::Mapping(_) => "object",
230 serde_yaml::Value::Tagged(_) => "tagged",
231 }
232}
233
234fn is_valid_date(s: &str) -> bool {
238 if s.len() != 10 {
240 return false;
241 }
242
243 let bytes = s.as_bytes();
244 if bytes[4] != b'-' || bytes[7] != b'-' {
245 return false;
246 }
247
248 for &i in &[0, 1, 2, 3, 5, 6, 8, 9] {
250 if !bytes[i].is_ascii_digit() {
251 return false;
252 }
253 }
254
255 let parts: Vec<&str> = s.split('-').collect();
263 let [year_str, month_str, day_str] = parts.as_slice() else {
264 return false;
265 };
266 let Ok(year) = year_str.parse::<u32>() else {
267 return false;
268 };
269 let Ok(month) = month_str.parse::<u32>() else {
270 return false;
271 };
272 let Ok(day) = day_str.parse::<u32>() else {
273 return false;
274 };
275
276 if year < 1 || month < 1 || month > 12 || day < 1 {
277 return false;
278 }
279
280 let days_in_month = match month {
281 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
282 4 | 6 | 9 | 11 => 30,
283 2 => {
284 if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
285 29
286 } else {
287 28
288 }
289 }
290 _ => return false,
291 };
292
293 day <= days_in_month
294}
295
296#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::schema::builtin_schema;
304
305 fn make_fm(pairs: &[(&str, serde_yaml::Value)]) -> HashMap<String, serde_yaml::Value> {
306 pairs
307 .iter()
308 .map(|(k, v)| (k.to_string(), v.clone()))
309 .collect()
310 }
311
312 fn str_val(s: &str) -> serde_yaml::Value {
313 serde_yaml::Value::String(s.to_string())
314 }
315
316 fn bool_val(b: bool) -> serde_yaml::Value {
317 serde_yaml::Value::Bool(b)
318 }
319
320 fn int_val(n: i64) -> serde_yaml::Value {
321 serde_yaml::Value::Number(serde_yaml::Number::from(n))
322 }
323
324 #[test]
325 fn test_valid_frontmatter() {
326 let schema = builtin_schema();
327 let fm = make_fm(&[
328 ("title", str_val("My Page")),
329 ("date", str_val("2024-01-15")),
330 ("draft", bool_val(false)),
331 ]);
332
333 let diags = validate_frontmatter(&fm, &schema);
334 let errors: Vec<_> = diags.iter().filter(|d| d.severity == Severity::Error).collect();
335 assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors);
336 }
337
338 #[test]
339 fn test_missing_required_title() {
340 let schema = builtin_schema();
341 let fm = make_fm(&[("date", str_val("2024-01-15"))]);
342
343 let diags = validate_frontmatter(&fm, &schema);
344 let missing: Vec<_> = diags
345 .iter()
346 .filter(|d| d.severity == Severity::Error && d.message.contains("title"))
347 .collect();
348 assert_eq!(missing.len(), 1);
349 }
350
351 #[test]
352 fn test_type_mismatch_string_for_boolean() {
353 let schema = builtin_schema();
354 let fm = make_fm(&[
355 ("title", str_val("Test")),
356 ("draft", str_val("yes")), ]);
358
359 let diags = validate_frontmatter(&fm, &schema);
360 let type_errs: Vec<_> = diags
361 .iter()
362 .filter(|d| d.severity == Severity::Error && d.message.contains("wrong type"))
363 .collect();
364 assert_eq!(type_errs.len(), 1);
365 assert!(type_errs[0].message.contains("draft"));
366 }
367
368 #[test]
369 fn test_type_mismatch_boolean_for_string() {
370 let schema = builtin_schema();
371 let fm = make_fm(&[
372 ("title", bool_val(true)), ]);
374
375 let diags = validate_frontmatter(&fm, &schema);
376 let type_errs: Vec<_> = diags
377 .iter()
378 .filter(|d| d.severity == Severity::Error && d.message.contains("title"))
379 .collect();
380 assert_eq!(type_errs.len(), 1);
381 }
382
383 #[test]
384 fn test_type_mismatch_string_for_integer() {
385 let schema = builtin_schema();
386 let fm = make_fm(&[
387 ("title", str_val("Test")),
388 ("weight", str_val("heavy")), ]);
390
391 let diags = validate_frontmatter(&fm, &schema);
392 let type_errs: Vec<_> = diags
393 .iter()
394 .filter(|d| d.severity == Severity::Error && d.message.contains("weight"))
395 .collect();
396 assert_eq!(type_errs.len(), 1);
397 }
398
399 #[test]
400 fn test_enum_violation() {
401 let schema = builtin_schema();
402 let fm = make_fm(&[
403 ("title", str_val("Test")),
404 ("children_style", str_val("table")), ]);
406
407 let diags = validate_frontmatter(&fm, &schema);
408 let enum_errs: Vec<_> = diags
409 .iter()
410 .filter(|d| d.severity == Severity::Error && d.message.contains("children_style"))
411 .collect();
412 assert_eq!(enum_errs.len(), 1);
413 assert!(enum_errs[0].message.contains("table"));
414 }
415
416 #[test]
417 fn test_enum_valid() {
418 let schema = builtin_schema();
419 let fm = make_fm(&[
420 ("title", str_val("Test")),
421 ("children_style", str_val("list")),
422 ]);
423
424 let diags = validate_frontmatter(&fm, &schema);
425 let enum_errs: Vec<_> = diags
426 .iter()
427 .filter(|d| d.severity == Severity::Error && d.message.contains("children_style"))
428 .collect();
429 assert!(enum_errs.is_empty());
430 }
431
432 #[test]
433 fn test_enum_summary_valid() {
434 let schema = builtin_schema();
435 let fm = make_fm(&[
436 ("title", str_val("Test")),
437 ("children_style", str_val("summary")),
438 ]);
439
440 let diags = validate_frontmatter(&fm, &schema);
441 let enum_errs: Vec<_> = diags
442 .iter()
443 .filter(|d| d.severity == Severity::Error && d.message.contains("children_style"))
444 .collect();
445 assert!(enum_errs.is_empty());
446 }
447
448 #[test]
449 fn test_enum_card_now_invalid() {
450 let schema = builtin_schema();
451 let fm = make_fm(&[
452 ("title", str_val("Test")),
453 ("children_style", str_val("card")), ]);
455
456 let diags = validate_frontmatter(&fm, &schema);
457 let enum_errs: Vec<_> = diags
458 .iter()
459 .filter(|d| d.severity == Severity::Error && d.message.contains("children_style"))
460 .collect();
461 assert_eq!(enum_errs.len(), 1);
462 assert!(enum_errs[0].message.contains("card"));
463 }
464
465 #[test]
466 fn test_invalid_date_format() {
467 let schema = builtin_schema();
468 let fm = make_fm(&[
469 ("title", str_val("Test")),
470 ("date", str_val("01/15/2024")), ]);
472
473 let diags = validate_frontmatter(&fm, &schema);
474 let date_warns: Vec<_> = diags
475 .iter()
476 .filter(|d| d.severity == Severity::Warning && d.message.contains("date"))
477 .collect();
478 assert_eq!(date_warns.len(), 1);
479 }
480
481 #[test]
482 fn test_valid_date_format() {
483 let schema = builtin_schema();
484 let fm = make_fm(&[
485 ("title", str_val("Test")),
486 ("date", str_val("2024-02-29")), ]);
488
489 let diags = validate_frontmatter(&fm, &schema);
490 let date_warns: Vec<_> = diags
491 .iter()
492 .filter(|d| d.severity == Severity::Warning && d.message.contains("date"))
493 .collect();
494 assert!(date_warns.is_empty());
495 }
496
497 #[test]
498 fn test_invalid_leap_year() {
499 let schema = builtin_schema();
500 let fm = make_fm(&[
501 ("title", str_val("Test")),
502 ("date", str_val("2023-02-29")), ]);
504
505 let diags = validate_frontmatter(&fm, &schema);
506 let date_warns: Vec<_> = diags
507 .iter()
508 .filter(|d| d.severity == Severity::Warning && d.message.contains("date"))
509 .collect();
510 assert_eq!(date_warns.len(), 1);
511 }
512
513 #[test]
514 fn test_unknown_fields_are_hints() {
515 let schema = builtin_schema();
516 let fm = make_fm(&[
517 ("title", str_val("Test")),
518 ("custom_field", str_val("value")),
519 ("another_unknown", int_val(42)),
520 ]);
521
522 let diags = validate_frontmatter(&fm, &schema);
523 let hints: Vec<_> = diags
524 .iter()
525 .filter(|d| d.severity == Severity::Hint)
526 .collect();
527 assert_eq!(hints.len(), 2);
528 }
529
530 #[test]
531 fn test_array_item_type_validation() {
532 let schema = builtin_schema();
533 let fm = make_fm(&[
534 ("title", str_val("Test")),
535 (
536 "also_in",
537 serde_yaml::Value::Sequence(vec![
538 str_val("section-a"),
539 serde_yaml::Value::Number(serde_yaml::Number::from(42)), ]),
541 ),
542 ]);
543
544 let diags = validate_frontmatter(&fm, &schema);
545 let arr_errs: Vec<_> = diags
546 .iter()
547 .filter(|d| d.severity == Severity::Error && d.message.contains("also_in[1]"))
548 .collect();
549 assert_eq!(arr_errs.len(), 1);
550 }
551
552 #[test]
553 fn test_valid_integer_field() {
554 let schema = builtin_schema();
555 let fm = make_fm(&[
556 ("title", str_val("Test")),
557 ("weight", int_val(10)),
558 ]);
559
560 let diags = validate_frontmatter(&fm, &schema);
561 let errors: Vec<_> = diags.iter().filter(|d| d.severity == Severity::Error).collect();
562 assert!(errors.is_empty(), "Unexpected errors: {:?}", errors);
563 }
564
565 #[test]
566 fn test_empty_frontmatter_only_required_errors() {
567 let schema = builtin_schema();
568 let fm = HashMap::new();
569
570 let diags = validate_frontmatter(&fm, &schema);
571 let errors: Vec<_> = diags
573 .iter()
574 .filter(|d| d.severity == Severity::Error)
575 .collect();
576 assert_eq!(errors.len(), 1);
577 assert!(errors[0].message.contains("title"));
578 }
579
580 #[test]
583 fn skip_schema_fields_are_not_flagged_unknown() {
584 let schema = builtin_schema();
585 let fm = make_fm(&[
588 ("title", str_val("Test")),
589 ("home", bool_val(true)),
590 ("bogus", int_val(1)),
591 ]);
592 let diags = validate_frontmatter(&fm, &schema);
593 assert!(
594 !diags.iter().any(|d| d.message.contains("'home'")),
595 "skip_schema field 'home' must not produce an unknown-field hint"
596 );
597 assert!(
598 diags.iter().any(|d| d.message.contains("'bogus'")),
599 "genuinely unknown field 'bogus' must still produce an unknown-field hint"
600 );
601 }
602
603 #[test]
604 fn test_is_valid_date() {
605 assert!(is_valid_date("2024-01-15"));
606 assert!(is_valid_date("2024-02-29")); assert!(is_valid_date("2024-12-31"));
608 assert!(is_valid_date("2000-02-29")); assert!(!is_valid_date("2023-02-29")); assert!(!is_valid_date("2024-13-01")); assert!(!is_valid_date("2024-00-01")); assert!(!is_valid_date("2024-01-32")); assert!(!is_valid_date("2024-04-31")); assert!(!is_valid_date("not-a-date"));
616 assert!(!is_valid_date("2024/01/15")); assert!(!is_valid_date("2024-1-5")); assert!(!is_valid_date("1900-02-29")); }
620}