1use crate::schema::{ContentSchema, FieldType};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum Severity {
19 Error = 1,
21 Warning = 2,
23 Info = 3,
25 Hint = 4,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Diagnostic {
32 pub severity: Severity,
34 pub message: String,
36 pub path: Option<String>,
38 pub line: Option<usize>,
40 pub column: Option<usize>,
42}
43
44pub fn validate_frontmatter(
48 fm: &HashMap<String, serde_yaml::Value>,
49 schema: &ContentSchema,
50) -> Vec<Diagnostic> {
51 let mut diags = Vec::new();
52
53 for (name, def) in &schema.frontmatter.fields {
55 match fm.get(name) {
56 None => {
57 if def.required {
58 diags.push(Diagnostic {
59 severity: Severity::Error,
60 message: format!("required field '{}' is missing", name),
61 path: Some(name.clone()),
62 line: None,
63 column: None,
64 });
65 }
66 }
67 Some(value) => {
68 if !value_matches_def(value, def) {
70 diags.push(Diagnostic {
71 severity: Severity::Error,
72 message: format!(
73 "field '{}' has wrong type: expected {}, got {}",
74 name,
75 type_name(&def.field_type),
76 yaml_type_name(value),
77 ),
78 path: Some(name.clone()),
79 line: None,
80 column: None,
81 });
82 }
83
84 if let Some(ref allowed) = def.enum_values {
86 if let Some(s) = value.as_str() {
87 if !allowed.contains(&s.to_string()) {
88 diags.push(Diagnostic {
89 severity: Severity::Error,
90 message: format!(
91 "field '{}' has invalid value '{}'; allowed: {:?}",
92 name, s, allowed
93 ),
94 path: Some(name.clone()),
95 line: None,
96 column: None,
97 });
98 }
99 }
100 }
101
102 if def.format.as_deref() == Some("date") {
104 if let Some(s) = value.as_str() {
105 if !is_valid_date(s) {
106 diags.push(Diagnostic {
107 severity: Severity::Warning,
108 message: format!(
109 "field '{}' has invalid date format '{}'; expected YYYY-MM-DD",
110 name, s
111 ),
112 path: Some(name.clone()),
113 line: None,
114 column: None,
115 });
116 }
117 }
118 }
119
120 if def.field_type == FieldType::Array {
122 if let (Some(items_def), Some(seq)) = (&def.items, value.as_sequence()) {
123 for (i, item) in seq.iter().enumerate() {
124 if !value_matches_type(item, &items_def.field_type) {
125 diags.push(Diagnostic {
126 severity: Severity::Error,
127 message: format!(
128 "field '{}[{}]' has wrong type: expected {}, got {}",
129 name,
130 i,
131 type_name(&items_def.field_type),
132 yaml_type_name(item),
133 ),
134 path: Some(format!("{}[{}]", name, i)),
135 line: None,
136 column: None,
137 });
138 }
139 }
140 }
141 }
142 }
143 }
144 }
145
146 for key in fm.keys() {
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
162fn 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 None => true,
171 };
172 }
173 value_matches_type(value, &def.field_type)
174}
175
176fn 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 value.is_i64() || value.is_u64()
184 }
185 FieldType::Number => {
186 value.is_number()
188 }
189 FieldType::Array => value.is_sequence(),
190 FieldType::Object => value.is_mapping(),
191 FieldType::OneOf => true,
194 }
195}
196
197fn 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
210fn 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
229fn is_valid_date(s: &str) -> bool {
233 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 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 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#[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")), ]);
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)), ]);
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")), ]);
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")), ]);
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")), ]);
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")), ]);
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")), ]);
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")), ]);
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)), ]),
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 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 #[test]
578 fn test_is_valid_date() {
579 assert!(is_valid_date("2024-01-15"));
580 assert!(is_valid_date("2024-02-29")); assert!(is_valid_date("2024-12-31"));
582 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"));
590 assert!(!is_valid_date("2024/01/15")); assert!(!is_valid_date("2024-1-5")); assert!(!is_valid_date("1900-02-29")); }
594}