Skip to main content

moss_core/
validation.rs

1//! Schema-driven frontmatter validation.
2//!
3//! Validates parsed frontmatter against a [`ContentSchema`], producing
4//! LSP-compatible [`Diagnostic`] messages. Checks include:
5//!
6//! - Required fields missing
7//! - Type mismatches (e.g. string where boolean expected)
8//! - Enum constraint violations
9//! - Date format validation (YYYY-MM-DD)
10//! - Unknown fields (reported as `Hint`)
11
12use crate::schema::{ContentSchema, FieldType};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16/// Frontmatter names moss does not use, paired with the field that does the job.
17///
18/// An unknown field is normally harmless — plugins and templates read their own
19/// keys, so moss ignores what it doesn't recognize rather than rejecting it.
20/// That silence is wrong for exactly one class of name: the field a writer
21/// arrives with from another generator. `slug:` is the case that prompted this
22/// — it is the custom-URL field in Hugo, Jekyll, Zola and Astro, moss spells it
23/// `url:`, and writing `slug:` did nothing at all and said nothing about it.
24///
25/// Curated, not computed. Edit distance would pair `data:` with `date:` and
26/// `image:` with nothing, producing confident wrong advice on fields that are
27/// legitimately someone's own. Every entry here is a name that means something
28/// specific somewhere else, so the suggestion is a translation rather than a
29/// guess. Keys are compared after [`normalize_field_name`], so `publishDate`,
30/// `publish_date` and `publish-date` all match one entry.
31const FOREIGN_FIELD_HINTS: &[(&str, &str)] = &[
32    // Custom URL segment — Hugo, Jekyll, Zola, Astro, Eleventy.
33    ("slug", "url"),
34    ("permalink", "url"),
35    // Short blurb — Hugo (`summary`), Jekyll (`excerpt`).
36    ("summary", "description"),
37    ("excerpt", "description"),
38    ("subtitle", "description"),
39    // Taxonomy — Jekyll/Hugo split tags from categories; moss has one axis.
40    ("categories", "tags"),
41    ("category", "tags"),
42    ("keywords", "tags"),
43    // Lead image.
44    ("image", "cover"),
45    ("thumbnail", "cover"),
46    ("banner", "cover"),
47    ("featuredimage", "cover"),
48    // Publication date — Astro (`pubDate`), assorted (`publishDate`).
49    ("pubdate", "date"),
50    ("publishdate", "date"),
51    ("datepublished", "date"),
52    // Singular in moss.
53    ("authors", "author"),
54    // Language.
55    ("language", "lang"),
56    ("locale", "lang"),
57    // Manual ordering.
58    ("order", "weight"),
59    ("menuorder", "weight"),
60    ("sortorder", "weight"),
61];
62
63/// Fold a frontmatter key to the form [`FOREIGN_FIELD_HINTS`] is keyed by:
64/// lowercase, with `_` and `-` removed. `pubDate`, `pub_date` and `pub-date`
65/// all fold to `pubdate`.
66fn 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
73/// The moss field a foreign frontmatter name most likely meant, if any.
74///
75/// Returns `None` for a name moss simply doesn't know — that is an ordinary
76/// custom field and must stay silent. Callers should phrase the result as a
77/// question, not a correction: a template really may read its own `image:`.
78pub 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/// Diagnostic severity levels (LSP-compatible integer values).
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88pub enum Severity {
89    /// Fatal error — the content cannot be published.
90    Error = 1,
91    /// Something likely wrong but not fatal.
92    Warning = 2,
93    /// Informational message.
94    Info = 3,
95    /// Suggestion or style hint.
96    Hint = 4,
97}
98
99/// A validation diagnostic.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct Diagnostic {
102    /// Severity level.
103    pub severity: Severity,
104    /// Human-readable message.
105    pub message: String,
106    /// Frontmatter field path (e.g. "title", "also_in[0]").
107    pub path: Option<String>,
108    /// Source line (1-based), if available.
109    pub line: Option<usize>,
110    /// Source column (1-based), if available.
111    pub column: Option<usize>,
112}
113
114/// Validate parsed frontmatter against a content schema.
115///
116/// Returns a list of diagnostics. An empty list means the frontmatter is valid.
117pub fn validate_frontmatter(
118    fm: &HashMap<String, serde_yaml::Value>,
119    schema: &ContentSchema,
120) -> Vec<Diagnostic> {
121    let mut diags = Vec::new();
122
123    // Check each field defined in the schema.
124    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                // Type check.
139                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                // Enum constraint check.
155                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                // Date format validation for fields with format: "date".
173                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                // Array item type check.
191                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    // Check for unknown fields (not in schema) — report as Hint.
217    // Skip keys that are internal (skip_schema) fields — they are managed by the
218    // build pipeline and must not be flagged as unknown to the user.
219    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        // Name a moss equivalent when the key is one another generator uses,
226        // so the hint is actionable instead of merely true.
227        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
246/// Check if a YAML value satisfies a field definition. For `OneOf` unions the
247/// value must match at least one member; otherwise it's a plain type check.
248fn 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            // A OneOf with no declared members accepts nothing meaningful;
253            // treat as permissive to avoid false positives on malformed schemas.
254            None => true,
255        };
256    }
257    value_matches_type(value, &def.field_type)
258}
259
260/// Check if a YAML value matches a scalar/array field type.
261fn value_matches_type(value: &serde_yaml::Value, expected: &FieldType) -> bool {
262    match expected {
263        FieldType::String => value.is_string(),
264        // Mirrors deserialize_bool_lenient in frontmatter_typed.rs: the typed
265        // build path coerces "true"/"false" strings, so this diagnostic must
266        // accept them too or it'll flag a value the build path already fixed.
267        FieldType::Boolean => {
268            value.is_bool() || matches!(value.as_str(), Some("true") | Some("false"))
269        }
270        FieldType::Integer => {
271            // Accept both i64 and u64.
272            value.is_i64() || value.is_u64()
273        }
274        FieldType::Number => {
275            // Accept integers and floats.
276            value.is_number()
277        }
278        FieldType::Array => value.is_sequence(),
279        FieldType::Object => value.is_mapping(),
280        // OneOf is dispatched by value_matches_def before reaching here; a bare
281        // OneOf with no members is permissive.
282        FieldType::OneOf => true,
283    }
284}
285
286/// Human-readable name for a field type.
287fn 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
299/// Human-readable name for a YAML value's actual type.
300fn 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
318/// Validate a date string in YYYY-MM-DD format.
319///
320/// Requires exactly 10 characters: 4 digits, dash, 2 digits, dash, 2 digits.
321fn is_valid_date(s: &str) -> bool {
322    // Strict format: YYYY-MM-DD (exactly 10 chars)
323    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    // Verify all digit positions are ASCII digits.
333    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    // The byte-position checks above guarantee the three-segment shape and
340    // ASCII-digit content, so the parses below cannot fail today. But "panics
341    // only when the author was right" is the same shape that just bit us in
342    // `date.rs` — refactor the byte checks above and these `.unwrap()`s become
343    // a panic on user input. Use slice-pattern destructuring + `let-else`
344    // instead so the compiler enforces the three-segment shape, and bail
345    // cleanly via `Result::Err` rather than a panic if parsing ever fails.
346    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// ---------------------------------------------------------------------------
381// Tests
382// ---------------------------------------------------------------------------
383
384#[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")), // should be boolean
441        ]);
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        // #925: the typed build path (deserialize_bool_lenient) coerces
455        // "true"/"false" strings for bool fields; this diagnostic must agree,
456        // or the editor would show a fresh "wrong type" error for a value the
457        // build path already accepts.
458        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)), // should be string
477        ]);
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")), // should be integer
493        ]);
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")), // not in ["list", "summary", "grid"]
509        ]);
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")), // was valid, now invalid
558        ]);
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")), // wrong format
575        ]);
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")), // leap year
591        ]);
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")), // not a leap year
607        ]);
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)), // wrong type
644                ]),
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    /// `sort:` takes an axis name or a list of child stems. The build has
657    /// always honoured both and the field's own description documents both,
658    /// but the schema declared a bare string — so a real site emitted
659    /// "field 'sort' has wrong type: expected string, got array" on every
660    /// folder index that spelled its order out. Both forms validate clean;
661    /// a bad axis name is still an error.
662    #[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        // Only "title" is required in the builtin schema
717        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    // --- Date validation unit tests ---
726
727    #[test]
728    fn skip_schema_fields_are_not_flagged_unknown() {
729        let schema = builtin_schema();
730        // `home` is a skip_schema field — it lives in internal_fields, not fields.
731        // `bogus` is genuinely unknown.
732        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")); // leap year
752        assert!(is_valid_date("2024-12-31"));
753        assert!(is_valid_date("2000-02-29")); // century leap year
754
755        assert!(!is_valid_date("2023-02-29")); // not leap year
756        assert!(!is_valid_date("2024-13-01")); // month > 12
757        assert!(!is_valid_date("2024-00-01")); // month 0
758        assert!(!is_valid_date("2024-01-32")); // day > 31
759        assert!(!is_valid_date("2024-04-31")); // April has 30 days
760        assert!(!is_valid_date("not-a-date"));
761        assert!(!is_valid_date("2024/01/15")); // wrong separator
762        assert!(!is_valid_date("2024-1-5")); // this passes since parse() accepts it
763        assert!(!is_valid_date("1900-02-29")); // not a leap year (divisible by 100 but not 400)
764    }
765
766    // -----------------------------------------------------------------------
767    // Foreign-field hints
768    // -----------------------------------------------------------------------
769
770    #[test]
771    fn slug_suggests_url() {
772        // The case that prompted the table: `slug:` is the custom-URL field in
773        // Hugo, Jekyll, Zola and Astro. moss spells it `url:` and used to
774        // ignore `slug:` without a word.
775        assert_eq!(foreign_field_suggestion("slug"), Some("url"));
776    }
777
778    #[test]
779    fn a_name_moss_simply_does_not_know_stays_silent() {
780        // Custom fields are legitimate — plugins and templates read their own
781        // keys. Suggesting anything here would be noise on every build.
782        assert_eq!(foreign_field_suggestion("bogus"), None);
783        assert_eq!(foreign_field_suggestion("my_custom_thing"), None);
784        // Near-misses that edit distance would have "corrected". `data:` is one
785        // character from `date:` and is a perfectly ordinary custom field.
786        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        // Astro writes `pubDate`, Hugo-era templates write `publish_date`, and
793        // some exporters write `publish-date`. All are the same mistake.
794        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        // The failure this guards is worse than the silence it replaces:
808        // pointing an author at a field moss also ignores. If a builtin field
809        // is ever renamed, this fails instead of shipping confident bad advice.
810        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        // A key that moss actually supports must never be reported as
824        // meaningless. If moss ever adopts one of these names for real, the
825        // entry has to go — this fails the moment that happens.
826        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}