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/// Diagnostic severity levels (LSP-compatible integer values).
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum Severity {
19    /// Fatal error — the content cannot be published.
20    Error = 1,
21    /// Something likely wrong but not fatal.
22    Warning = 2,
23    /// Informational message.
24    Info = 3,
25    /// Suggestion or style hint.
26    Hint = 4,
27}
28
29/// A validation diagnostic.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Diagnostic {
32    /// Severity level.
33    pub severity: Severity,
34    /// Human-readable message.
35    pub message: String,
36    /// Frontmatter field path (e.g. "title", "also_in[0]").
37    pub path: Option<String>,
38    /// Source line (1-based), if available.
39    pub line: Option<usize>,
40    /// Source column (1-based), if available.
41    pub column: Option<usize>,
42}
43
44/// Validate parsed frontmatter against a content schema.
45///
46/// Returns a list of diagnostics. An empty list means the frontmatter is valid.
47pub fn validate_frontmatter(
48    fm: &HashMap<String, serde_yaml::Value>,
49    schema: &ContentSchema,
50) -> Vec<Diagnostic> {
51    let mut diags = Vec::new();
52
53    // Check each field defined in the schema.
54    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                // Type check.
69                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                // Enum constraint check.
85                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                // Date format validation for fields with format: "date".
103                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                // Array item type check.
121                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    // Check for unknown fields (not in schema) — report as Hint.
147    // Skip keys that are internal (skip_schema) fields — they are managed by the
148    // build pipeline and must not be flagged as unknown to the user.
149    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
167/// Check if a YAML value satisfies a field definition. For `OneOf` unions the
168/// value must match at least one member; otherwise it's a plain type check.
169fn 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            // A OneOf with no declared members accepts nothing meaningful;
174            // treat as permissive to avoid false positives on malformed schemas.
175            None => true,
176        };
177    }
178    value_matches_type(value, &def.field_type)
179}
180
181/// Check if a YAML value matches a scalar/array field type.
182fn 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            // Accept both i64 and u64.
188            value.is_i64() || value.is_u64()
189        }
190        FieldType::Number => {
191            // Accept integers and floats.
192            value.is_number()
193        }
194        FieldType::Array => value.is_sequence(),
195        FieldType::Object => value.is_mapping(),
196        // OneOf is dispatched by value_matches_def before reaching here; a bare
197        // OneOf with no members is permissive.
198        FieldType::OneOf => true,
199    }
200}
201
202/// Human-readable name for a field type.
203fn 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
215/// Human-readable name for a YAML value's actual type.
216fn 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
234/// Validate a date string in YYYY-MM-DD format.
235///
236/// Requires exactly 10 characters: 4 digits, dash, 2 digits, dash, 2 digits.
237fn is_valid_date(s: &str) -> bool {
238    // Strict format: YYYY-MM-DD (exactly 10 chars)
239    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    // Verify all digit positions are ASCII digits.
249    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    // The byte-position checks above guarantee the three-segment shape and
256    // ASCII-digit content, so the parses below cannot fail today. But "panics
257    // only when the author was right" is the same shape that just bit us in
258    // `date.rs` — refactor the byte checks above and these `.unwrap()`s become
259    // a panic on user input. Use slice-pattern destructuring + `let-else`
260    // instead so the compiler enforces the three-segment shape, and bail
261    // cleanly via `Result::Err` rather than a panic if parsing ever fails.
262    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// ---------------------------------------------------------------------------
297// Tests
298// ---------------------------------------------------------------------------
299
300#[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")), // should be boolean
357        ]);
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)), // should be string
373        ]);
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")), // should be integer
389        ]);
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")), // not in ["list", "summary", "grid"]
405        ]);
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")), // was valid, now invalid
454        ]);
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")), // wrong format
471        ]);
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")), // leap year
487        ]);
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")), // not a leap year
503        ]);
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)), // wrong type
540                ]),
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        // Only "title" is required in the builtin schema
572        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    // --- Date validation unit tests ---
581
582    #[test]
583    fn skip_schema_fields_are_not_flagged_unknown() {
584        let schema = builtin_schema();
585        // `home` is a skip_schema field — it lives in internal_fields, not fields.
586        // `bogus` is genuinely unknown.
587        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")); // leap year
607        assert!(is_valid_date("2024-12-31"));
608        assert!(is_valid_date("2000-02-29")); // century leap year
609
610        assert!(!is_valid_date("2023-02-29")); // not leap year
611        assert!(!is_valid_date("2024-13-01")); // month > 12
612        assert!(!is_valid_date("2024-00-01")); // month 0
613        assert!(!is_valid_date("2024-01-32")); // day > 31
614        assert!(!is_valid_date("2024-04-31")); // April has 30 days
615        assert!(!is_valid_date("not-a-date"));
616        assert!(!is_valid_date("2024/01/15")); // wrong separator
617        assert!(!is_valid_date("2024-1-5")); // this passes since parse() accepts it
618        assert!(!is_valid_date("1900-02-29")); // not a leap year (divisible by 100 but not 400)
619    }
620}