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    for key in fm.keys() {
148        if !schema.frontmatter.fields.contains_key(key) {
149            diags.push(Diagnostic {
150                severity: Severity::Hint,
151                message: format!("unknown field '{}' is not defined in the schema", key),
152                path: Some(key.clone()),
153                line: None,
154                column: None,
155            });
156        }
157    }
158
159    diags
160}
161
162/// Check if a YAML value satisfies a field definition. For `OneOf` unions the
163/// value must match at least one member; otherwise it's a plain type check.
164fn value_matches_def(value: &serde_yaml::Value, def: &crate::schema::FieldDefinition) -> bool {
165    if def.field_type == FieldType::OneOf {
166        return match &def.one_of {
167            Some(members) => members.iter().any(|m| value_matches_def(value, m)),
168            // A OneOf with no declared members accepts nothing meaningful;
169            // treat as permissive to avoid false positives on malformed schemas.
170            None => true,
171        };
172    }
173    value_matches_type(value, &def.field_type)
174}
175
176/// Check if a YAML value matches a scalar/array field type.
177fn value_matches_type(value: &serde_yaml::Value, expected: &FieldType) -> bool {
178    match expected {
179        FieldType::String => value.is_string(),
180        FieldType::Boolean => value.is_bool(),
181        FieldType::Integer => {
182            // Accept both i64 and u64.
183            value.is_i64() || value.is_u64()
184        }
185        FieldType::Number => {
186            // Accept integers and floats.
187            value.is_number()
188        }
189        FieldType::Array => value.is_sequence(),
190        FieldType::Object => value.is_mapping(),
191        // OneOf is dispatched by value_matches_def before reaching here; a bare
192        // OneOf with no members is permissive.
193        FieldType::OneOf => true,
194    }
195}
196
197/// Human-readable name for a field type.
198fn type_name(ft: &FieldType) -> &'static str {
199    match ft {
200        FieldType::String => "string",
201        FieldType::Boolean => "boolean",
202        FieldType::Integer => "integer",
203        FieldType::Number => "number",
204        FieldType::Array => "array",
205        FieldType::Object => "object",
206        FieldType::OneOf => "one-of",
207    }
208}
209
210/// Human-readable name for a YAML value's actual type.
211fn yaml_type_name(value: &serde_yaml::Value) -> &'static str {
212    match value {
213        serde_yaml::Value::Null => "null",
214        serde_yaml::Value::Bool(_) => "boolean",
215        serde_yaml::Value::Number(n) => {
216            if n.is_f64() && !n.is_i64() && !n.is_u64() {
217                "number"
218            } else {
219                "integer"
220            }
221        }
222        serde_yaml::Value::String(_) => "string",
223        serde_yaml::Value::Sequence(_) => "array",
224        serde_yaml::Value::Mapping(_) => "object",
225        serde_yaml::Value::Tagged(_) => "tagged",
226    }
227}
228
229/// Validate a date string in YYYY-MM-DD format.
230///
231/// Requires exactly 10 characters: 4 digits, dash, 2 digits, dash, 2 digits.
232fn is_valid_date(s: &str) -> bool {
233    // Strict format: YYYY-MM-DD (exactly 10 chars)
234    if s.len() != 10 {
235        return false;
236    }
237
238    let bytes = s.as_bytes();
239    if bytes[4] != b'-' || bytes[7] != b'-' {
240        return false;
241    }
242
243    // Verify all digit positions are ASCII digits.
244    for &i in &[0, 1, 2, 3, 5, 6, 8, 9] {
245        if !bytes[i].is_ascii_digit() {
246            return false;
247        }
248    }
249
250    // The byte-position checks above guarantee the three-segment shape and
251    // ASCII-digit content, so the parses below cannot fail today. But "panics
252    // only when the author was right" is the same shape that just bit us in
253    // `date.rs` — refactor the byte checks above and these `.unwrap()`s become
254    // a panic on user input. Use slice-pattern destructuring + `let-else`
255    // instead so the compiler enforces the three-segment shape, and bail
256    // cleanly via `Result::Err` rather than a panic if parsing ever fails.
257    let parts: Vec<&str> = s.split('-').collect();
258    let [year_str, month_str, day_str] = parts.as_slice() else {
259        return false;
260    };
261    let Ok(year) = year_str.parse::<u32>() else {
262        return false;
263    };
264    let Ok(month) = month_str.parse::<u32>() else {
265        return false;
266    };
267    let Ok(day) = day_str.parse::<u32>() else {
268        return false;
269    };
270
271    if year < 1 || month < 1 || month > 12 || day < 1 {
272        return false;
273    }
274
275    let days_in_month = match month {
276        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
277        4 | 6 | 9 | 11 => 30,
278        2 => {
279            if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
280                29
281            } else {
282                28
283            }
284        }
285        _ => return false,
286    };
287
288    day <= days_in_month
289}
290
291// ---------------------------------------------------------------------------
292// Tests
293// ---------------------------------------------------------------------------
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::schema::builtin_schema;
299
300    fn make_fm(pairs: &[(&str, serde_yaml::Value)]) -> HashMap<String, serde_yaml::Value> {
301        pairs
302            .iter()
303            .map(|(k, v)| (k.to_string(), v.clone()))
304            .collect()
305    }
306
307    fn str_val(s: &str) -> serde_yaml::Value {
308        serde_yaml::Value::String(s.to_string())
309    }
310
311    fn bool_val(b: bool) -> serde_yaml::Value {
312        serde_yaml::Value::Bool(b)
313    }
314
315    fn int_val(n: i64) -> serde_yaml::Value {
316        serde_yaml::Value::Number(serde_yaml::Number::from(n))
317    }
318
319    #[test]
320    fn test_valid_frontmatter() {
321        let schema = builtin_schema();
322        let fm = make_fm(&[
323            ("title", str_val("My Page")),
324            ("date", str_val("2024-01-15")),
325            ("draft", bool_val(false)),
326        ]);
327
328        let diags = validate_frontmatter(&fm, &schema);
329        let errors: Vec<_> = diags.iter().filter(|d| d.severity == Severity::Error).collect();
330        assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors);
331    }
332
333    #[test]
334    fn test_missing_required_title() {
335        let schema = builtin_schema();
336        let fm = make_fm(&[("date", str_val("2024-01-15"))]);
337
338        let diags = validate_frontmatter(&fm, &schema);
339        let missing: Vec<_> = diags
340            .iter()
341            .filter(|d| d.severity == Severity::Error && d.message.contains("title"))
342            .collect();
343        assert_eq!(missing.len(), 1);
344    }
345
346    #[test]
347    fn test_type_mismatch_string_for_boolean() {
348        let schema = builtin_schema();
349        let fm = make_fm(&[
350            ("title", str_val("Test")),
351            ("draft", str_val("yes")), // should be boolean
352        ]);
353
354        let diags = validate_frontmatter(&fm, &schema);
355        let type_errs: Vec<_> = diags
356            .iter()
357            .filter(|d| d.severity == Severity::Error && d.message.contains("wrong type"))
358            .collect();
359        assert_eq!(type_errs.len(), 1);
360        assert!(type_errs[0].message.contains("draft"));
361    }
362
363    #[test]
364    fn test_type_mismatch_boolean_for_string() {
365        let schema = builtin_schema();
366        let fm = make_fm(&[
367            ("title", bool_val(true)), // should be string
368        ]);
369
370        let diags = validate_frontmatter(&fm, &schema);
371        let type_errs: Vec<_> = diags
372            .iter()
373            .filter(|d| d.severity == Severity::Error && d.message.contains("title"))
374            .collect();
375        assert_eq!(type_errs.len(), 1);
376    }
377
378    #[test]
379    fn test_type_mismatch_string_for_integer() {
380        let schema = builtin_schema();
381        let fm = make_fm(&[
382            ("title", str_val("Test")),
383            ("weight", str_val("heavy")), // should be integer
384        ]);
385
386        let diags = validate_frontmatter(&fm, &schema);
387        let type_errs: Vec<_> = diags
388            .iter()
389            .filter(|d| d.severity == Severity::Error && d.message.contains("weight"))
390            .collect();
391        assert_eq!(type_errs.len(), 1);
392    }
393
394    #[test]
395    fn test_enum_violation() {
396        let schema = builtin_schema();
397        let fm = make_fm(&[
398            ("title", str_val("Test")),
399            ("children_style", str_val("table")), // not in ["list", "summary", "grid"]
400        ]);
401
402        let diags = validate_frontmatter(&fm, &schema);
403        let enum_errs: Vec<_> = diags
404            .iter()
405            .filter(|d| d.severity == Severity::Error && d.message.contains("children_style"))
406            .collect();
407        assert_eq!(enum_errs.len(), 1);
408        assert!(enum_errs[0].message.contains("table"));
409    }
410
411    #[test]
412    fn test_enum_valid() {
413        let schema = builtin_schema();
414        let fm = make_fm(&[
415            ("title", str_val("Test")),
416            ("children_style", str_val("list")),
417        ]);
418
419        let diags = validate_frontmatter(&fm, &schema);
420        let enum_errs: Vec<_> = diags
421            .iter()
422            .filter(|d| d.severity == Severity::Error && d.message.contains("children_style"))
423            .collect();
424        assert!(enum_errs.is_empty());
425    }
426
427    #[test]
428    fn test_enum_summary_valid() {
429        let schema = builtin_schema();
430        let fm = make_fm(&[
431            ("title", str_val("Test")),
432            ("children_style", str_val("summary")),
433        ]);
434
435        let diags = validate_frontmatter(&fm, &schema);
436        let enum_errs: Vec<_> = diags
437            .iter()
438            .filter(|d| d.severity == Severity::Error && d.message.contains("children_style"))
439            .collect();
440        assert!(enum_errs.is_empty());
441    }
442
443    #[test]
444    fn test_enum_card_now_invalid() {
445        let schema = builtin_schema();
446        let fm = make_fm(&[
447            ("title", str_val("Test")),
448            ("children_style", str_val("card")), // was valid, now invalid
449        ]);
450
451        let diags = validate_frontmatter(&fm, &schema);
452        let enum_errs: Vec<_> = diags
453            .iter()
454            .filter(|d| d.severity == Severity::Error && d.message.contains("children_style"))
455            .collect();
456        assert_eq!(enum_errs.len(), 1);
457        assert!(enum_errs[0].message.contains("card"));
458    }
459
460    #[test]
461    fn test_invalid_date_format() {
462        let schema = builtin_schema();
463        let fm = make_fm(&[
464            ("title", str_val("Test")),
465            ("date", str_val("01/15/2024")), // wrong format
466        ]);
467
468        let diags = validate_frontmatter(&fm, &schema);
469        let date_warns: Vec<_> = diags
470            .iter()
471            .filter(|d| d.severity == Severity::Warning && d.message.contains("date"))
472            .collect();
473        assert_eq!(date_warns.len(), 1);
474    }
475
476    #[test]
477    fn test_valid_date_format() {
478        let schema = builtin_schema();
479        let fm = make_fm(&[
480            ("title", str_val("Test")),
481            ("date", str_val("2024-02-29")), // leap year
482        ]);
483
484        let diags = validate_frontmatter(&fm, &schema);
485        let date_warns: Vec<_> = diags
486            .iter()
487            .filter(|d| d.severity == Severity::Warning && d.message.contains("date"))
488            .collect();
489        assert!(date_warns.is_empty());
490    }
491
492    #[test]
493    fn test_invalid_leap_year() {
494        let schema = builtin_schema();
495        let fm = make_fm(&[
496            ("title", str_val("Test")),
497            ("date", str_val("2023-02-29")), // not a leap year
498        ]);
499
500        let diags = validate_frontmatter(&fm, &schema);
501        let date_warns: Vec<_> = diags
502            .iter()
503            .filter(|d| d.severity == Severity::Warning && d.message.contains("date"))
504            .collect();
505        assert_eq!(date_warns.len(), 1);
506    }
507
508    #[test]
509    fn test_unknown_fields_are_hints() {
510        let schema = builtin_schema();
511        let fm = make_fm(&[
512            ("title", str_val("Test")),
513            ("custom_field", str_val("value")),
514            ("another_unknown", int_val(42)),
515        ]);
516
517        let diags = validate_frontmatter(&fm, &schema);
518        let hints: Vec<_> = diags
519            .iter()
520            .filter(|d| d.severity == Severity::Hint)
521            .collect();
522        assert_eq!(hints.len(), 2);
523    }
524
525    #[test]
526    fn test_array_item_type_validation() {
527        let schema = builtin_schema();
528        let fm = make_fm(&[
529            ("title", str_val("Test")),
530            (
531                "also_in",
532                serde_yaml::Value::Sequence(vec![
533                    str_val("section-a"),
534                    serde_yaml::Value::Number(serde_yaml::Number::from(42)), // wrong type
535                ]),
536            ),
537        ]);
538
539        let diags = validate_frontmatter(&fm, &schema);
540        let arr_errs: Vec<_> = diags
541            .iter()
542            .filter(|d| d.severity == Severity::Error && d.message.contains("also_in[1]"))
543            .collect();
544        assert_eq!(arr_errs.len(), 1);
545    }
546
547    #[test]
548    fn test_valid_integer_field() {
549        let schema = builtin_schema();
550        let fm = make_fm(&[
551            ("title", str_val("Test")),
552            ("weight", int_val(10)),
553        ]);
554
555        let diags = validate_frontmatter(&fm, &schema);
556        let errors: Vec<_> = diags.iter().filter(|d| d.severity == Severity::Error).collect();
557        assert!(errors.is_empty(), "Unexpected errors: {:?}", errors);
558    }
559
560    #[test]
561    fn test_empty_frontmatter_only_required_errors() {
562        let schema = builtin_schema();
563        let fm = HashMap::new();
564
565        let diags = validate_frontmatter(&fm, &schema);
566        // Only "title" is required in the builtin schema
567        let errors: Vec<_> = diags
568            .iter()
569            .filter(|d| d.severity == Severity::Error)
570            .collect();
571        assert_eq!(errors.len(), 1);
572        assert!(errors[0].message.contains("title"));
573    }
574
575    // --- Date validation unit tests ---
576
577    #[test]
578    fn test_is_valid_date() {
579        assert!(is_valid_date("2024-01-15"));
580        assert!(is_valid_date("2024-02-29")); // leap year
581        assert!(is_valid_date("2024-12-31"));
582        assert!(is_valid_date("2000-02-29")); // century leap year
583
584        assert!(!is_valid_date("2023-02-29")); // not leap year
585        assert!(!is_valid_date("2024-13-01")); // month > 12
586        assert!(!is_valid_date("2024-00-01")); // month 0
587        assert!(!is_valid_date("2024-01-32")); // day > 31
588        assert!(!is_valid_date("2024-04-31")); // April has 30 days
589        assert!(!is_valid_date("not-a-date"));
590        assert!(!is_valid_date("2024/01/15")); // wrong separator
591        assert!(!is_valid_date("2024-1-5")); // this passes since parse() accepts it
592        assert!(!is_valid_date("1900-02-29")); // not a leap year (divisible by 100 but not 400)
593    }
594}