1use crate::schema::{ContentSchema, FieldType};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16const FOREIGN_FIELD_HINTS: &[(&str, &str)] = &[
32 ("slug", "url"),
34 ("permalink", "url"),
35 ("summary", "description"),
37 ("excerpt", "description"),
38 ("subtitle", "description"),
39 ("categories", "tags"),
41 ("category", "tags"),
42 ("keywords", "tags"),
43 ("image", "cover"),
45 ("thumbnail", "cover"),
46 ("banner", "cover"),
47 ("featuredimage", "cover"),
48 ("pubdate", "date"),
50 ("publishdate", "date"),
51 ("datepublished", "date"),
52 ("authors", "author"),
54 ("language", "lang"),
56 ("locale", "lang"),
57 ("order", "weight"),
59 ("menuorder", "weight"),
60 ("sortorder", "weight"),
61];
62
63fn normalize_field_name(name: &str) -> String {
67 name.chars()
68 .filter(|c| *c != '_' && *c != '-')
69 .flat_map(|c| c.to_lowercase())
70 .collect()
71}
72
73pub fn foreign_field_suggestion(name: &str) -> Option<&'static str> {
79 let folded = normalize_field_name(name);
80 FOREIGN_FIELD_HINTS
81 .iter()
82 .find(|(foreign, _)| *foreign == folded)
83 .map(|(_, moss_field)| *moss_field)
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88pub enum Severity {
89 Error = 1,
91 Warning = 2,
93 Info = 3,
95 Hint = 4,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct Diagnostic {
102 pub severity: Severity,
104 pub message: String,
106 pub path: Option<String>,
108 pub line: Option<usize>,
110 pub column: Option<usize>,
112}
113
114pub fn validate_frontmatter(
118 fm: &HashMap<String, serde_yaml::Value>,
119 schema: &ContentSchema,
120) -> Vec<Diagnostic> {
121 let mut diags = Vec::new();
122
123 for (name, def) in &schema.frontmatter.fields {
125 match fm.get(name) {
126 None => {
127 if def.required {
128 diags.push(Diagnostic {
129 severity: Severity::Error,
130 message: format!("required field '{}' is missing", name),
131 path: Some(name.clone()),
132 line: None,
133 column: None,
134 });
135 }
136 }
137 Some(value) => {
138 if !value_matches_def(value, def) {
140 diags.push(Diagnostic {
141 severity: Severity::Error,
142 message: format!(
143 "field '{}' has wrong type: expected {}, got {}",
144 name,
145 type_name(&def.field_type),
146 yaml_type_name(value),
147 ),
148 path: Some(name.clone()),
149 line: None,
150 column: None,
151 });
152 }
153
154 if let Some(ref allowed) = def.enum_values {
156 if let Some(s) = value.as_str() {
157 if !allowed.contains(&s.to_string()) {
158 diags.push(Diagnostic {
159 severity: Severity::Error,
160 message: format!(
161 "field '{}' has invalid value '{}'; allowed: {:?}",
162 name, s, allowed
163 ),
164 path: Some(name.clone()),
165 line: None,
166 column: None,
167 });
168 }
169 }
170 }
171
172 if def.format.as_deref() == Some("date") {
174 if let Some(s) = value.as_str() {
175 if !is_valid_date(s) {
176 diags.push(Diagnostic {
177 severity: Severity::Warning,
178 message: format!(
179 "field '{}' has invalid date format '{}'; expected YYYY-MM-DD",
180 name, s
181 ),
182 path: Some(name.clone()),
183 line: None,
184 column: None,
185 });
186 }
187 }
188 }
189
190 if def.field_type == FieldType::Array {
192 if let (Some(items_def), Some(seq)) = (&def.items, value.as_sequence()) {
193 for (i, item) in seq.iter().enumerate() {
194 if !value_matches_type(item, &items_def.field_type) {
195 diags.push(Diagnostic {
196 severity: Severity::Error,
197 message: format!(
198 "field '{}[{}]' has wrong type: expected {}, got {}",
199 name,
200 i,
201 type_name(&items_def.field_type),
202 yaml_type_name(item),
203 ),
204 path: Some(format!("{}[{}]", name, i)),
205 line: None,
206 column: None,
207 });
208 }
209 }
210 }
211 }
212 }
213 }
214 }
215
216 for key in fm.keys() {
220 if schema.frontmatter.fields.contains_key(key)
221 || schema.frontmatter.internal_fields.contains(key)
222 {
223 continue;
224 }
225 let message = match foreign_field_suggestion(key) {
228 Some(moss_field) => format!(
229 "unknown field '{}' is not defined in the schema — did you mean '{}'?",
230 key, moss_field
231 ),
232 None => format!("unknown field '{}' is not defined in the schema", key),
233 };
234 diags.push(Diagnostic {
235 severity: Severity::Hint,
236 message,
237 path: Some(key.clone()),
238 line: None,
239 column: None,
240 });
241 }
242
243 diags
244}
245
246fn value_matches_def(value: &serde_yaml::Value, def: &crate::schema::FieldDefinition) -> bool {
249 if def.field_type == FieldType::OneOf {
250 return match &def.one_of {
251 Some(members) => members.iter().any(|m| value_matches_def(value, m)),
252 None => true,
255 };
256 }
257 value_matches_type(value, &def.field_type)
258}
259
260fn value_matches_type(value: &serde_yaml::Value, expected: &FieldType) -> bool {
262 match expected {
263 FieldType::String => value.is_string(),
264 FieldType::Boolean => {
268 value.is_bool() || matches!(value.as_str(), Some("true") | Some("false"))
269 }
270 FieldType::Integer => {
271 value.is_i64() || value.is_u64()
273 }
274 FieldType::Number => {
275 value.is_number()
277 }
278 FieldType::Array => value.is_sequence(),
279 FieldType::Object => value.is_mapping(),
280 FieldType::OneOf => true,
283 }
284}
285
286fn type_name(ft: &FieldType) -> &'static str {
288 match ft {
289 FieldType::String => "string",
290 FieldType::Boolean => "boolean",
291 FieldType::Integer => "integer",
292 FieldType::Number => "number",
293 FieldType::Array => "array",
294 FieldType::Object => "object",
295 FieldType::OneOf => "one-of",
296 }
297}
298
299fn yaml_type_name(value: &serde_yaml::Value) -> &'static str {
301 match value {
302 serde_yaml::Value::Null => "null",
303 serde_yaml::Value::Bool(_) => "boolean",
304 serde_yaml::Value::Number(n) => {
305 if n.is_f64() && !n.is_i64() && !n.is_u64() {
306 "number"
307 } else {
308 "integer"
309 }
310 }
311 serde_yaml::Value::String(_) => "string",
312 serde_yaml::Value::Sequence(_) => "array",
313 serde_yaml::Value::Mapping(_) => "object",
314 serde_yaml::Value::Tagged(_) => "tagged",
315 }
316}
317
318fn is_valid_date(s: &str) -> bool {
322 if s.len() != 10 {
324 return false;
325 }
326
327 let bytes = s.as_bytes();
328 if bytes[4] != b'-' || bytes[7] != b'-' {
329 return false;
330 }
331
332 for &i in &[0, 1, 2, 3, 5, 6, 8, 9] {
334 if !bytes[i].is_ascii_digit() {
335 return false;
336 }
337 }
338
339 let parts: Vec<&str> = s.split('-').collect();
347 let [year_str, month_str, day_str] = parts.as_slice() else {
348 return false;
349 };
350 let Ok(year) = year_str.parse::<u32>() else {
351 return false;
352 };
353 let Ok(month) = month_str.parse::<u32>() else {
354 return false;
355 };
356 let Ok(day) = day_str.parse::<u32>() else {
357 return false;
358 };
359
360 if year < 1 || month < 1 || month > 12 || day < 1 {
361 return false;
362 }
363
364 let days_in_month = match month {
365 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
366 4 | 6 | 9 | 11 => 30,
367 2 => {
368 if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
369 29
370 } else {
371 28
372 }
373 }
374 _ => return false,
375 };
376
377 day <= days_in_month
378}
379
380#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::schema::builtin_schema;
388
389 fn make_fm(pairs: &[(&str, serde_yaml::Value)]) -> HashMap<String, serde_yaml::Value> {
390 pairs
391 .iter()
392 .map(|(k, v)| (k.to_string(), v.clone()))
393 .collect()
394 }
395
396 fn str_val(s: &str) -> serde_yaml::Value {
397 serde_yaml::Value::String(s.to_string())
398 }
399
400 fn bool_val(b: bool) -> serde_yaml::Value {
401 serde_yaml::Value::Bool(b)
402 }
403
404 fn int_val(n: i64) -> serde_yaml::Value {
405 serde_yaml::Value::Number(serde_yaml::Number::from(n))
406 }
407
408 #[test]
409 fn test_valid_frontmatter() {
410 let schema = builtin_schema();
411 let fm = make_fm(&[
412 ("title", str_val("My Page")),
413 ("date", str_val("2024-01-15")),
414 ("draft", bool_val(false)),
415 ]);
416
417 let diags = validate_frontmatter(&fm, &schema);
418 let errors: Vec<_> = diags.iter().filter(|d| d.severity == Severity::Error).collect();
419 assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors);
420 }
421
422 #[test]
423 fn test_missing_required_title() {
424 let schema = builtin_schema();
425 let fm = make_fm(&[("date", str_val("2024-01-15"))]);
426
427 let diags = validate_frontmatter(&fm, &schema);
428 let missing: Vec<_> = diags
429 .iter()
430 .filter(|d| d.severity == Severity::Error && d.message.contains("title"))
431 .collect();
432 assert_eq!(missing.len(), 1);
433 }
434
435 #[test]
436 fn test_type_mismatch_string_for_boolean() {
437 let schema = builtin_schema();
438 let fm = make_fm(&[
439 ("title", str_val("Test")),
440 ("draft", str_val("yes")), ]);
442
443 let diags = validate_frontmatter(&fm, &schema);
444 let type_errs: Vec<_> = diags
445 .iter()
446 .filter(|d| d.severity == Severity::Error && d.message.contains("wrong type"))
447 .collect();
448 assert_eq!(type_errs.len(), 1);
449 assert!(type_errs[0].message.contains("draft"));
450 }
451
452 #[test]
453 fn test_quoted_true_false_strings_accepted_for_boolean() {
454 let schema = builtin_schema();
459 let fm = make_fm(&[
460 ("title", str_val("Test")),
461 ("draft", str_val("true")),
462 ]);
463
464 let diags = validate_frontmatter(&fm, &schema);
465 let type_errs: Vec<_> = diags
466 .iter()
467 .filter(|d| d.severity == Severity::Error && d.message.contains("wrong type"))
468 .collect();
469 assert!(type_errs.is_empty(), "quoted \"true\" must not be flagged: {:?}", type_errs);
470 }
471
472 #[test]
473 fn test_type_mismatch_boolean_for_string() {
474 let schema = builtin_schema();
475 let fm = make_fm(&[
476 ("title", bool_val(true)), ]);
478
479 let diags = validate_frontmatter(&fm, &schema);
480 let type_errs: Vec<_> = diags
481 .iter()
482 .filter(|d| d.severity == Severity::Error && d.message.contains("title"))
483 .collect();
484 assert_eq!(type_errs.len(), 1);
485 }
486
487 #[test]
488 fn test_type_mismatch_string_for_integer() {
489 let schema = builtin_schema();
490 let fm = make_fm(&[
491 ("title", str_val("Test")),
492 ("weight", str_val("heavy")), ]);
494
495 let diags = validate_frontmatter(&fm, &schema);
496 let type_errs: Vec<_> = diags
497 .iter()
498 .filter(|d| d.severity == Severity::Error && d.message.contains("weight"))
499 .collect();
500 assert_eq!(type_errs.len(), 1);
501 }
502
503 #[test]
504 fn test_enum_violation() {
505 let schema = builtin_schema();
506 let fm = make_fm(&[
507 ("title", str_val("Test")),
508 ("children_style", str_val("table")), ]);
510
511 let diags = validate_frontmatter(&fm, &schema);
512 let enum_errs: Vec<_> = diags
513 .iter()
514 .filter(|d| d.severity == Severity::Error && d.message.contains("children_style"))
515 .collect();
516 assert_eq!(enum_errs.len(), 1);
517 assert!(enum_errs[0].message.contains("table"));
518 }
519
520 #[test]
521 fn test_enum_valid() {
522 let schema = builtin_schema();
523 let fm = make_fm(&[
524 ("title", str_val("Test")),
525 ("children_style", str_val("list")),
526 ]);
527
528 let diags = validate_frontmatter(&fm, &schema);
529 let enum_errs: Vec<_> = diags
530 .iter()
531 .filter(|d| d.severity == Severity::Error && d.message.contains("children_style"))
532 .collect();
533 assert!(enum_errs.is_empty());
534 }
535
536 #[test]
537 fn test_enum_summary_valid() {
538 let schema = builtin_schema();
539 let fm = make_fm(&[
540 ("title", str_val("Test")),
541 ("children_style", str_val("summary")),
542 ]);
543
544 let diags = validate_frontmatter(&fm, &schema);
545 let enum_errs: Vec<_> = diags
546 .iter()
547 .filter(|d| d.severity == Severity::Error && d.message.contains("children_style"))
548 .collect();
549 assert!(enum_errs.is_empty());
550 }
551
552 #[test]
553 fn test_enum_card_now_invalid() {
554 let schema = builtin_schema();
555 let fm = make_fm(&[
556 ("title", str_val("Test")),
557 ("children_style", str_val("card")), ]);
559
560 let diags = validate_frontmatter(&fm, &schema);
561 let enum_errs: Vec<_> = diags
562 .iter()
563 .filter(|d| d.severity == Severity::Error && d.message.contains("children_style"))
564 .collect();
565 assert_eq!(enum_errs.len(), 1);
566 assert!(enum_errs[0].message.contains("card"));
567 }
568
569 #[test]
570 fn test_invalid_date_format() {
571 let schema = builtin_schema();
572 let fm = make_fm(&[
573 ("title", str_val("Test")),
574 ("date", str_val("01/15/2024")), ]);
576
577 let diags = validate_frontmatter(&fm, &schema);
578 let date_warns: Vec<_> = diags
579 .iter()
580 .filter(|d| d.severity == Severity::Warning && d.message.contains("date"))
581 .collect();
582 assert_eq!(date_warns.len(), 1);
583 }
584
585 #[test]
586 fn test_valid_date_format() {
587 let schema = builtin_schema();
588 let fm = make_fm(&[
589 ("title", str_val("Test")),
590 ("date", str_val("2024-02-29")), ]);
592
593 let diags = validate_frontmatter(&fm, &schema);
594 let date_warns: Vec<_> = diags
595 .iter()
596 .filter(|d| d.severity == Severity::Warning && d.message.contains("date"))
597 .collect();
598 assert!(date_warns.is_empty());
599 }
600
601 #[test]
602 fn test_invalid_leap_year() {
603 let schema = builtin_schema();
604 let fm = make_fm(&[
605 ("title", str_val("Test")),
606 ("date", str_val("2023-02-29")), ]);
608
609 let diags = validate_frontmatter(&fm, &schema);
610 let date_warns: Vec<_> = diags
611 .iter()
612 .filter(|d| d.severity == Severity::Warning && d.message.contains("date"))
613 .collect();
614 assert_eq!(date_warns.len(), 1);
615 }
616
617 #[test]
618 fn test_unknown_fields_are_hints() {
619 let schema = builtin_schema();
620 let fm = make_fm(&[
621 ("title", str_val("Test")),
622 ("custom_field", str_val("value")),
623 ("another_unknown", int_val(42)),
624 ]);
625
626 let diags = validate_frontmatter(&fm, &schema);
627 let hints: Vec<_> = diags
628 .iter()
629 .filter(|d| d.severity == Severity::Hint)
630 .collect();
631 assert_eq!(hints.len(), 2);
632 }
633
634 #[test]
635 fn test_array_item_type_validation() {
636 let schema = builtin_schema();
637 let fm = make_fm(&[
638 ("title", str_val("Test")),
639 (
640 "also_in",
641 serde_yaml::Value::Sequence(vec![
642 str_val("section-a"),
643 serde_yaml::Value::Number(serde_yaml::Number::from(42)), ]),
645 ),
646 ]);
647
648 let diags = validate_frontmatter(&fm, &schema);
649 let arr_errs: Vec<_> = diags
650 .iter()
651 .filter(|d| d.severity == Severity::Error && d.message.contains("also_in[1]"))
652 .collect();
653 assert_eq!(arr_errs.len(), 1);
654 }
655
656 #[test]
663 fn sort_accepts_an_axis_name_or_an_explicit_list() {
664 let schema = builtin_schema();
665 let errors = |fm: &HashMap<String, serde_yaml::Value>| -> Vec<String> {
666 validate_frontmatter(fm, &schema)
667 .into_iter()
668 .filter(|d| d.severity == Severity::Error)
669 .map(|d| d.message)
670 .collect()
671 };
672
673 let list = make_fm(&[
674 ("title", str_val("Test")),
675 (
676 "sort",
677 serde_yaml::Value::Sequence(vec![
678 str_val("上篇"),
679 str_val("中篇"),
680 str_val("下篇"),
681 ]),
682 ),
683 ]);
684 assert!(errors(&list).is_empty(), "list form: {:?}", errors(&list));
685
686 let axis = make_fm(&[("title", str_val("Test")), ("sort", str_val("weight"))]);
687 assert!(errors(&axis).is_empty(), "axis form: {:?}", errors(&axis));
688
689 let bogus = make_fm(&[("title", str_val("Test")), ("sort", str_val("banana"))]);
690 assert!(
691 errors(&bogus).iter().any(|m| m.contains("invalid value 'banana'")),
692 "an unknown axis name must still be rejected: {:?}",
693 errors(&bogus)
694 );
695 }
696
697 #[test]
698 fn test_valid_integer_field() {
699 let schema = builtin_schema();
700 let fm = make_fm(&[
701 ("title", str_val("Test")),
702 ("weight", int_val(10)),
703 ]);
704
705 let diags = validate_frontmatter(&fm, &schema);
706 let errors: Vec<_> = diags.iter().filter(|d| d.severity == Severity::Error).collect();
707 assert!(errors.is_empty(), "Unexpected errors: {:?}", errors);
708 }
709
710 #[test]
711 fn test_empty_frontmatter_only_required_errors() {
712 let schema = builtin_schema();
713 let fm = HashMap::new();
714
715 let diags = validate_frontmatter(&fm, &schema);
716 let errors: Vec<_> = diags
718 .iter()
719 .filter(|d| d.severity == Severity::Error)
720 .collect();
721 assert_eq!(errors.len(), 1);
722 assert!(errors[0].message.contains("title"));
723 }
724
725 #[test]
728 fn skip_schema_fields_are_not_flagged_unknown() {
729 let schema = builtin_schema();
730 let fm = make_fm(&[
733 ("title", str_val("Test")),
734 ("home", bool_val(true)),
735 ("bogus", int_val(1)),
736 ]);
737 let diags = validate_frontmatter(&fm, &schema);
738 assert!(
739 !diags.iter().any(|d| d.message.contains("'home'")),
740 "skip_schema field 'home' must not produce an unknown-field hint"
741 );
742 assert!(
743 diags.iter().any(|d| d.message.contains("'bogus'")),
744 "genuinely unknown field 'bogus' must still produce an unknown-field hint"
745 );
746 }
747
748 #[test]
749 fn test_is_valid_date() {
750 assert!(is_valid_date("2024-01-15"));
751 assert!(is_valid_date("2024-02-29")); assert!(is_valid_date("2024-12-31"));
753 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"));
761 assert!(!is_valid_date("2024/01/15")); assert!(!is_valid_date("2024-1-5")); assert!(!is_valid_date("1900-02-29")); }
765
766 #[test]
771 fn slug_suggests_url() {
772 assert_eq!(foreign_field_suggestion("slug"), Some("url"));
776 }
777
778 #[test]
779 fn a_name_moss_simply_does_not_know_stays_silent() {
780 assert_eq!(foreign_field_suggestion("bogus"), None);
783 assert_eq!(foreign_field_suggestion("my_custom_thing"), None);
784 assert_eq!(foreign_field_suggestion("data"), None);
787 assert_eq!(foreign_field_suggestion("tag"), None);
788 }
789
790 #[test]
791 fn case_and_separators_fold_to_one_entry() {
792 for spelling in ["pubDate", "PubDate", "pub_date", "pub-date", "PUBDATE"] {
795 assert_eq!(
796 foreign_field_suggestion(spelling),
797 Some("date"),
798 "{} should fold to the pubdate entry",
799 spelling
800 );
801 }
802 assert_eq!(foreign_field_suggestion("featuredImage"), Some("cover"));
803 }
804
805 #[test]
806 fn every_suggested_field_actually_exists_in_the_schema() {
807 let schema = builtin_schema();
811 for (foreign, suggested) in FOREIGN_FIELD_HINTS {
812 assert!(
813 schema.frontmatter.fields.contains_key(*suggested),
814 "hint '{}' -> '{}' names a field the schema does not define",
815 foreign,
816 suggested
817 );
818 }
819 }
820
821 #[test]
822 fn no_hint_key_is_itself_a_real_moss_field() {
823 let schema = builtin_schema();
827 for (foreign, _) in FOREIGN_FIELD_HINTS {
828 assert!(
829 !schema
830 .frontmatter
831 .fields
832 .keys()
833 .any(|name| normalize_field_name(name) == *foreign),
834 "'{}' is a real moss field and must not be listed as foreign",
835 foreign
836 );
837 }
838 }
839
840 #[test]
841 fn the_unknown_field_hint_carries_the_suggestion() {
842 let schema = builtin_schema();
843 let fm = make_fm(&[("title", str_val("Hi")), ("slug", str_val("privacy"))]);
844 let diags = validate_frontmatter(&fm, &schema);
845 let hint = diags
846 .iter()
847 .find(|d| d.path.as_deref() == Some("slug"))
848 .expect("unknown field 'slug' must produce a diagnostic");
849 assert_eq!(hint.severity, Severity::Hint);
850 assert!(
851 hint.message.contains("did you mean 'url'"),
852 "hint should name the moss equivalent, got: {}",
853 hint.message
854 );
855 }
856}