1use anyhow::{Context, Result};
39use log::info;
40use serde::Deserialize;
41use std::{
42 collections::HashMap,
43 fmt, fs,
44 path::{Path, PathBuf},
45};
46
47use crate::plugin::{Plugin, PluginContext};
48
49#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum FieldType {
60 String,
62 Date,
64 Bool,
66 Integer,
68 Float,
70 List,
72 Enum(Vec<String>),
74}
75
76impl fmt::Display for FieldType {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 match self {
79 Self::String => write!(f, "string"),
80 Self::Date => write!(f, "date"),
81 Self::Bool => write!(f, "bool"),
82 Self::Integer => write!(f, "integer"),
83 Self::Float => write!(f, "float"),
84 Self::List => write!(f, "list"),
85 Self::Enum(variants) => write!(f, "enum({})", variants.join(",")),
86 }
87 }
88}
89
90fn parse_field_type(s: &str) -> Result<FieldType, String> {
95 match s.trim() {
96 "string" => Ok(FieldType::String),
97 "date" => Ok(FieldType::Date),
98 "bool" => Ok(FieldType::Bool),
99 "integer" => Ok(FieldType::Integer),
100 "float" => Ok(FieldType::Float),
101 "list" => Ok(FieldType::List),
102 other if other.starts_with("enum(") && other.ends_with(')') => {
103 let inner = &other[5..other.len() - 1];
104 let variants: Vec<String> =
105 inner.split(',').map(|v| v.trim().to_owned()).collect();
106 if variants.is_empty() || variants.iter().any(String::is_empty) {
107 return Err(format!(
108 "enum type must have non-empty variants: {other}"
109 ));
110 }
111 Ok(FieldType::Enum(variants))
112 }
113 _ => Err(format!("unknown field type: {s}")),
114 }
115}
116
117#[derive(Debug, Clone)]
119pub struct FieldDef {
120 pub name: String,
122 pub field_type: FieldType,
124 pub required: bool,
126 pub default: Option<String>,
128}
129
130#[derive(Debug, Clone)]
132pub struct ContentSchema {
133 pub name: String,
136 pub fields: Vec<FieldDef>,
138}
139
140#[derive(Deserialize)]
145struct SchemaFile {
146 schemas: Vec<RawSchema>,
147}
148
149#[derive(Deserialize)]
150struct RawSchema {
151 name: String,
152 fields: Vec<RawField>,
153}
154
155#[derive(Deserialize)]
156struct RawField {
157 name: String,
158 #[serde(rename = "type")]
159 field_type: String,
160 #[serde(default)]
161 required: bool,
162 default: Option<String>,
163}
164
165pub fn load_schemas(path: &Path) -> Result<Vec<ContentSchema>> {
174 if !path.exists() {
175 return Ok(Vec::new());
176 }
177
178 let text = fs::read_to_string(path).with_context(|| {
179 format!("failed to read schema file: {}", path.display())
180 })?;
181
182 parse_schemas(&text)
183}
184
185pub fn parse_schemas(toml_text: &str) -> Result<Vec<ContentSchema>> {
187 let raw: SchemaFile = toml::from_str(toml_text)
188 .context("failed to parse content.schema.toml")?;
189
190 raw.schemas
191 .into_iter()
192 .map(|rs| {
193 let fields = rs
194 .fields
195 .into_iter()
196 .map(|rf| {
197 let ft = parse_field_type(&rf.field_type).map_err(|e| {
198 anyhow::anyhow!(
199 "schema '{}', field '{}': {}",
200 rs.name,
201 rf.name,
202 e
203 )
204 })?;
205 Ok(FieldDef {
206 name: rf.name,
207 field_type: ft,
208 required: rf.required,
209 default: rf.default,
210 })
211 })
212 .collect::<Result<Vec<_>>>()?;
213 Ok(ContentSchema {
214 name: rs.name,
215 fields,
216 })
217 })
218 .collect()
219}
220
221#[derive(Debug, Clone)]
227pub struct ValidationError {
228 pub file: PathBuf,
230 pub line: usize,
232 pub message: String,
234}
235
236impl fmt::Display for ValidationError {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 write!(f, "{}:{}: {}", self.file.display(), self.line, self.message)
239 }
240}
241
242#[must_use]
247pub fn validate_frontmatter(
248 fields: &HashMap<String, String>,
249 schema: &ContentSchema,
250 file_path: &Path,
251 fm_start_line: usize,
252) -> Vec<ValidationError> {
253 let mut errors = Vec::new();
254
255 for field_def in &schema.fields {
256 match fields.get(&field_def.name) {
257 Some(value) => {
258 if let Err(msg) = validate_value(value, &field_def.field_type) {
259 errors.push(ValidationError {
260 file: file_path.to_path_buf(),
261 line: fm_start_line,
262 message: format!(
263 "field '{}': {msg} (expected {})",
264 field_def.name, field_def.field_type
265 ),
266 });
267 }
268 }
269 None => {
270 if field_def.required && field_def.default.is_none() {
271 errors.push(ValidationError {
272 file: file_path.to_path_buf(),
273 line: fm_start_line,
274 message: format!(
275 "required field '{}' is missing",
276 field_def.name
277 ),
278 });
279 }
280 }
281 }
282 }
283
284 errors
285}
286
287fn is_valid_date(value: &str) -> bool {
289 let parts: Vec<&str> = value.split('-').collect();
290 parts.len() == 3
291 && parts[0].len() == 4
292 && parts[1].len() == 2
293 && parts[2].len() == 2
294 && parts[0].chars().all(|c| c.is_ascii_digit())
295 && parts[1].chars().all(|c| c.is_ascii_digit())
296 && parts[2].chars().all(|c| c.is_ascii_digit())
297}
298
299fn validate_value(value: &str, ft: &FieldType) -> Result<(), String> {
301 match ft {
302 FieldType::String => Ok(()),
303 FieldType::Date => {
304 if is_valid_date(value) {
306 Ok(())
307 } else {
308 Err(format!(
309 "'{value}' is not a valid date (expected YYYY-MM-DD)"
310 ))
311 }
312 }
313 FieldType::Bool => match value {
314 "true" | "false" => Ok(()),
315 _ => Err(format!("'{value}' is not a valid bool")),
316 },
317 FieldType::Integer => {
318 let _: i64 = value
319 .parse::<i64>()
320 .map_err(|_| format!("'{value}' is not a valid integer"))?;
321 Ok(())
322 }
323 FieldType::Float => {
324 let _: f64 = value
325 .parse::<f64>()
326 .map_err(|_| format!("'{value}' is not a valid float"))?;
327 Ok(())
328 }
329 FieldType::List => {
330 Ok(())
333 }
334 FieldType::Enum(variants) => {
335 if variants.iter().any(|v| v == value) {
336 Ok(())
337 } else {
338 Err(format!(
339 "'{value}' is not one of the allowed values: {}",
340 variants.join(", ")
341 ))
342 }
343 }
344 }
345}
346
347fn extract_frontmatter_map(
356 content: &str,
357) -> Option<(HashMap<String, String>, usize)> {
358 let fm_result = frontmatter_gen::extract(content);
359 let Ok((fm, _body)) = fm_result else {
360 return None;
361 };
362
363 let mut map = HashMap::new();
364 for (key, value) in &fm.0 {
365 let _ = map.insert(key.clone(), fm_value_to_string(value));
366 }
367
368 Some((map, 1))
370}
371
372fn fm_value_to_string(value: &frontmatter_gen::Value) -> String {
374 match value {
375 frontmatter_gen::Value::String(s) => s.clone(),
376 frontmatter_gen::Value::Number(n) => format!("{n}"),
377 frontmatter_gen::Value::Boolean(b) => format!("{b}"),
378 frontmatter_gen::Value::Array(arr) => arr
379 .iter()
380 .map(fm_value_to_string)
381 .collect::<Vec<_>>()
382 .join(","),
383 frontmatter_gen::Value::Null => String::new(),
384 other => format!("{other:?}"),
385 }
386}
387
388pub fn validate_content_dir(
399 content_dir: &Path,
400 schemas: &[ContentSchema],
401) -> Result<Vec<ValidationError>> {
402 if schemas.is_empty() {
403 return Ok(Vec::new());
404 }
405
406 let schema_map: HashMap<&str, &ContentSchema> =
407 schemas.iter().map(|s| (s.name.as_str(), s)).collect();
408
409 let md_files = crate::walk::walk_files_bounded_depth(
410 content_dir,
411 "md",
412 crate::MAX_DIR_DEPTH,
413 )?;
414
415 let mut all_errors = Vec::new();
416
417 for md_path in &md_files {
418 let content = fs::read_to_string(md_path)
419 .with_context(|| format!("failed to read {}", md_path.display()))?;
420
421 let Some((fields, fm_line)) = extract_frontmatter_map(&content) else {
422 continue;
423 };
424
425 let schema_name = match fields.get("schema") {
427 Some(name) => name.as_str(),
428 None => continue, };
430
431 let Some(schema) = schema_map.get(schema_name) else {
432 all_errors.push(ValidationError {
433 file: md_path.clone(),
434 line: fm_line,
435 message: format!("unknown schema '{schema_name}'"),
436 });
437 continue;
438 };
439
440 let mut errs = validate_frontmatter(&fields, schema, md_path, fm_line);
441 all_errors.append(&mut errs);
442 }
443
444 Ok(all_errors)
445}
446
447#[derive(Debug, Clone, Copy)]
454pub struct ContentValidationPlugin;
455
456impl Plugin for ContentValidationPlugin {
457 fn name(&self) -> &'static str {
458 "content-validation"
459 }
460
461 fn before_compile(&self, ctx: &PluginContext) -> Result<()> {
462 let schema_path = ctx.content_dir.join("content.schema.toml");
463 let schemas = load_schemas(&schema_path)?;
464
465 if schemas.is_empty() {
466 info!("No content schemas found — skipping validation");
467 return Ok(());
468 }
469
470 info!(
471 "Loaded {} content schema(s), validating {}",
472 schemas.len(),
473 ctx.content_dir.display()
474 );
475
476 let errors = validate_content_dir(&ctx.content_dir, &schemas)?;
477
478 if errors.is_empty() {
479 info!("All content files passed schema validation");
480 Ok(())
481 } else {
482 let mut msg = format!(
483 "Content validation failed with {} error(s):\n",
484 errors.len()
485 );
486 for err in &errors {
487 msg.push_str(&format!(" {err}\n"));
488 }
489 Err(anyhow::anyhow!("{msg}"))
490 }
491 }
492}
493
494pub fn validate_only(content_dir: &Path) -> Result<()> {
503 let schema_path = content_dir.join("content.schema.toml");
504 validate_with_schema(content_dir, &schema_path)
505}
506
507pub fn validate_with_schema(
519 content_dir: &Path,
520 schema_path: &Path,
521) -> Result<()> {
522 let schemas = load_schemas(schema_path)?;
523
524 if schemas.is_empty() {
525 println!("No content schemas found in {}", schema_path.display());
526 return Ok(());
527 }
528
529 println!("Loaded {} schema(s)", schemas.len());
530
531 let errors = validate_content_dir(content_dir, &schemas)?;
532
533 if errors.is_empty() {
534 println!("All content files passed schema validation.");
535 Ok(())
536 } else {
537 eprintln!("Validation failed with {} error(s):", errors.len());
538 for err in &errors {
539 eprintln!(" {err}");
540 }
541 Err(anyhow::anyhow!(
542 "{} content validation error(s)",
543 errors.len()
544 ))
545 }
546}
547
548#[cfg(test)]
553#[allow(clippy::unwrap_used, clippy::expect_used)]
554mod tests {
555 use super::*;
556 use std::fs;
557 use tempfile::tempdir;
558
559 #[test]
564 fn parse_field_type_string() {
565 assert_eq!(parse_field_type("string").unwrap(), FieldType::String);
566 }
567
568 #[test]
569 fn parse_field_type_date() {
570 assert_eq!(parse_field_type("date").unwrap(), FieldType::Date);
571 }
572
573 #[test]
574 fn parse_field_type_bool() {
575 assert_eq!(parse_field_type("bool").unwrap(), FieldType::Bool);
576 }
577
578 #[test]
579 fn parse_field_type_integer() {
580 assert_eq!(parse_field_type("integer").unwrap(), FieldType::Integer);
581 }
582
583 #[test]
584 fn parse_field_type_float() {
585 assert_eq!(parse_field_type("float").unwrap(), FieldType::Float);
586 }
587
588 #[test]
589 fn parse_field_type_list() {
590 assert_eq!(parse_field_type("list").unwrap(), FieldType::List);
591 }
592
593 #[test]
594 fn parse_field_type_enum() {
595 let ft = parse_field_type("enum(draft,published,archived)").unwrap();
596 assert_eq!(
597 ft,
598 FieldType::Enum(vec![
599 "draft".to_owned(),
600 "published".to_owned(),
601 "archived".to_owned(),
602 ])
603 );
604 }
605
606 #[test]
607 fn parse_field_type_enum_trimmed() {
608 let ft = parse_field_type("enum( a , b )").unwrap();
609 assert_eq!(ft, FieldType::Enum(vec!["a".to_owned(), "b".to_owned()]));
610 }
611
612 #[test]
613 fn parse_field_type_unknown() {
614 assert!(parse_field_type("foobar").is_err());
615 }
616
617 #[test]
618 fn parse_field_type_enum_empty_variants() {
619 assert!(parse_field_type("enum()").is_err());
620 }
621
622 #[test]
627 fn field_type_display() {
628 assert_eq!(FieldType::String.to_string(), "string");
629 assert_eq!(FieldType::Date.to_string(), "date");
630 assert_eq!(FieldType::Bool.to_string(), "bool");
631 assert_eq!(FieldType::Integer.to_string(), "integer");
632 assert_eq!(FieldType::Float.to_string(), "float");
633 assert_eq!(FieldType::List.to_string(), "list");
634 assert_eq!(
635 FieldType::Enum(vec!["a".into(), "b".into()]).to_string(),
636 "enum(a,b)"
637 );
638 }
639
640 #[test]
645 fn validate_string_always_ok() {
646 assert!(validate_value("anything", &FieldType::String).is_ok());
647 assert!(validate_value("", &FieldType::String).is_ok());
648 }
649
650 #[test]
651 fn validate_date_ok() {
652 assert!(validate_value("2024-01-15", &FieldType::Date).is_ok());
653 }
654
655 #[test]
656 fn validate_date_bad() {
657 assert!(validate_value("not-a-date", &FieldType::Date).is_err());
658 assert!(validate_value("2024/01/15", &FieldType::Date).is_err());
659 }
660
661 #[test]
662 fn validate_bool_ok() {
663 assert!(validate_value("true", &FieldType::Bool).is_ok());
664 assert!(validate_value("false", &FieldType::Bool).is_ok());
665 }
666
667 #[test]
668 fn validate_bool_bad() {
669 assert!(validate_value("yes", &FieldType::Bool).is_err());
670 assert!(validate_value("1", &FieldType::Bool).is_err());
671 }
672
673 #[test]
674 fn validate_integer_ok() {
675 assert!(validate_value("42", &FieldType::Integer).is_ok());
676 assert!(validate_value("-7", &FieldType::Integer).is_ok());
677 assert!(validate_value("0", &FieldType::Integer).is_ok());
678 }
679
680 #[test]
681 fn validate_integer_bad() {
682 assert!(validate_value("3.14", &FieldType::Integer).is_err());
683 assert!(validate_value("abc", &FieldType::Integer).is_err());
684 }
685
686 #[test]
687 fn validate_float_ok() {
688 assert!(validate_value("3.14", &FieldType::Float).is_ok());
689 assert!(validate_value("-1.0", &FieldType::Float).is_ok());
690 assert!(validate_value("42", &FieldType::Float).is_ok());
691 }
692
693 #[test]
694 fn validate_float_bad() {
695 assert!(validate_value("abc", &FieldType::Float).is_err());
696 }
697
698 #[test]
699 fn validate_list_always_ok() {
700 assert!(validate_value("a,b,c", &FieldType::List).is_ok());
701 assert!(validate_value("", &FieldType::List).is_ok());
702 }
703
704 #[test]
705 fn validate_enum_ok() {
706 let ft = FieldType::Enum(vec!["draft".into(), "published".into()]);
707 assert!(validate_value("draft", &ft).is_ok());
708 assert!(validate_value("published", &ft).is_ok());
709 }
710
711 #[test]
712 fn validate_enum_bad() {
713 let ft = FieldType::Enum(vec!["draft".into(), "published".into()]);
714 assert!(validate_value("archived", &ft).is_err());
715 }
716
717 #[test]
722 fn validate_frontmatter_all_present() {
723 let schema = ContentSchema {
724 name: "post".into(),
725 fields: vec![
726 FieldDef {
727 name: "title".into(),
728 field_type: FieldType::String,
729 required: true,
730 default: None,
731 },
732 FieldDef {
733 name: "date".into(),
734 field_type: FieldType::Date,
735 required: true,
736 default: None,
737 },
738 ],
739 };
740
741 let mut fields = HashMap::new();
742 let _ = fields.insert("title".into(), "Hello".into());
743 let _ = fields.insert("date".into(), "2024-06-01".into());
744
745 let errors =
746 validate_frontmatter(&fields, &schema, Path::new("test.md"), 1);
747 assert!(errors.is_empty());
748 }
749
750 #[test]
751 fn validate_frontmatter_missing_required() {
752 let schema = ContentSchema {
753 name: "post".into(),
754 fields: vec![FieldDef {
755 name: "title".into(),
756 field_type: FieldType::String,
757 required: true,
758 default: None,
759 }],
760 };
761
762 let fields: HashMap<String, String> = HashMap::new();
763 let errors =
764 validate_frontmatter(&fields, &schema, Path::new("test.md"), 1);
765 assert_eq!(errors.len(), 1);
766 assert!(errors[0].message.contains("required"));
767 }
768
769 #[test]
770 fn validate_frontmatter_missing_with_default() {
771 let schema = ContentSchema {
772 name: "post".into(),
773 fields: vec![FieldDef {
774 name: "draft".into(),
775 field_type: FieldType::Bool,
776 required: true,
777 default: Some("false".into()),
778 }],
779 };
780
781 let fields: HashMap<String, String> = HashMap::new();
782 let errors =
783 validate_frontmatter(&fields, &schema, Path::new("test.md"), 1);
784 assert!(errors.is_empty());
786 }
787
788 #[test]
789 fn validate_frontmatter_wrong_type() {
790 let schema = ContentSchema {
791 name: "post".into(),
792 fields: vec![FieldDef {
793 name: "date".into(),
794 field_type: FieldType::Date,
795 required: true,
796 default: None,
797 }],
798 };
799
800 let mut fields = HashMap::new();
801 let _ = fields.insert("date".into(), "not-a-date".into());
802
803 let errors =
804 validate_frontmatter(&fields, &schema, Path::new("test.md"), 1);
805 assert_eq!(errors.len(), 1);
806 assert!(errors[0].message.contains("date"));
807 }
808
809 #[test]
810 fn validate_frontmatter_optional_missing_ok() {
811 let schema = ContentSchema {
812 name: "post".into(),
813 fields: vec![FieldDef {
814 name: "subtitle".into(),
815 field_type: FieldType::String,
816 required: false,
817 default: None,
818 }],
819 };
820
821 let fields: HashMap<String, String> = HashMap::new();
822 let errors =
823 validate_frontmatter(&fields, &schema, Path::new("test.md"), 1);
824 assert!(errors.is_empty());
825 }
826
827 #[test]
832 fn validation_error_display() {
833 let err = ValidationError {
834 file: PathBuf::from("content/post.md"),
835 line: 3,
836 message: "field 'title': missing".into(),
837 };
838 assert_eq!(
839 err.to_string(),
840 "content/post.md:3: field 'title': missing"
841 );
842 }
843
844 #[test]
849 fn parse_schemas_basic() {
850 let toml = r#"
851[[schemas]]
852name = "post"
853
854[[schemas.fields]]
855name = "title"
856type = "string"
857required = true
858
859[[schemas.fields]]
860name = "date"
861type = "date"
862required = true
863
864[[schemas.fields]]
865name = "draft"
866type = "bool"
867required = false
868default = "false"
869"#;
870 let schemas = parse_schemas(toml).unwrap();
871 assert_eq!(schemas.len(), 1);
872 assert_eq!(schemas[0].name, "post");
873 assert_eq!(schemas[0].fields.len(), 3);
874 assert_eq!(schemas[0].fields[0].name, "title");
875 assert!(schemas[0].fields[0].required);
876 assert_eq!(schemas[0].fields[2].default, Some("false".to_owned()));
877 }
878
879 #[test]
880 fn parse_schemas_multiple() {
881 let toml = r#"
882[[schemas]]
883name = "post"
884
885[[schemas.fields]]
886name = "title"
887type = "string"
888required = true
889
890[[schemas]]
891name = "page"
892
893[[schemas.fields]]
894name = "heading"
895type = "string"
896required = true
897"#;
898 let schemas = parse_schemas(toml).unwrap();
899 assert_eq!(schemas.len(), 2);
900 assert_eq!(schemas[0].name, "post");
901 assert_eq!(schemas[1].name, "page");
902 }
903
904 #[test]
905 fn parse_schemas_enum_field() {
906 let toml = r#"
907[[schemas]]
908name = "post"
909
910[[schemas.fields]]
911name = "status"
912type = "enum(draft,published,archived)"
913required = true
914"#;
915 let schemas = parse_schemas(toml).unwrap();
916 assert_eq!(
917 schemas[0].fields[0].field_type,
918 FieldType::Enum(vec![
919 "draft".into(),
920 "published".into(),
921 "archived".into()
922 ])
923 );
924 }
925
926 #[test]
927 fn parse_schemas_bad_type() {
928 let toml = r#"
929[[schemas]]
930name = "post"
931
932[[schemas.fields]]
933name = "x"
934type = "unknown_type"
935required = true
936"#;
937 assert!(parse_schemas(toml).is_err());
938 }
939
940 #[test]
941 fn parse_schemas_bad_toml() {
942 assert!(parse_schemas("not valid toml {{{}}}").is_err());
943 }
944
945 #[test]
950 fn load_schemas_nonexistent_file() {
951 let schemas =
952 load_schemas(Path::new("/tmp/does-not-exist/content.schema.toml"))
953 .unwrap();
954 assert!(schemas.is_empty());
955 }
956
957 #[test]
958 fn load_schemas_from_file() {
959 let dir = tempdir().unwrap();
960 let path = dir.path().join("content.schema.toml");
961 fs::write(
962 &path,
963 r#"
964[[schemas]]
965name = "post"
966
967[[schemas.fields]]
968name = "title"
969type = "string"
970required = true
971"#,
972 )
973 .unwrap();
974
975 let schemas = load_schemas(&path).unwrap();
976 assert_eq!(schemas.len(), 1);
977 }
978
979 #[test]
984 fn extract_fm_from_yaml() {
985 let content = "---\ntitle: Hello\ndate: 2024-01-01\n---\n\nBody text";
986 let (map, line) = extract_frontmatter_map(content).unwrap();
987 assert_eq!(map.get("title").unwrap(), "Hello");
988 assert_eq!(map.get("date").unwrap(), "2024-01-01");
989 assert_eq!(line, 1);
990 }
991
992 #[test]
993 fn extract_fm_no_frontmatter() {
994 let content = "Just plain text without frontmatter.";
995 assert!(extract_frontmatter_map(content).is_none());
996 }
997
998 #[test]
1003 fn fm_value_string() {
1004 let v = frontmatter_gen::Value::String("hello".into());
1005 assert_eq!(fm_value_to_string(&v), "hello");
1006 }
1007
1008 #[test]
1009 fn fm_value_number() {
1010 let v = frontmatter_gen::Value::Number(42.0);
1011 assert_eq!(fm_value_to_string(&v), "42");
1012 }
1013
1014 #[test]
1015 fn fm_value_bool() {
1016 let v = frontmatter_gen::Value::Boolean(true);
1017 assert_eq!(fm_value_to_string(&v), "true");
1018 }
1019
1020 #[test]
1021 fn fm_value_null() {
1022 let v = frontmatter_gen::Value::Null;
1023 assert_eq!(fm_value_to_string(&v), "");
1024 }
1025
1026 #[test]
1027 fn fm_value_array() {
1028 let v = frontmatter_gen::Value::Array(vec![
1029 frontmatter_gen::Value::String("a".into()),
1030 frontmatter_gen::Value::String("b".into()),
1031 ]);
1032 assert_eq!(fm_value_to_string(&v), "a,b");
1033 }
1034
1035 #[test]
1040 fn validate_content_dir_empty_schemas() {
1041 let dir = tempdir().unwrap();
1042 let errors = validate_content_dir(dir.path(), &[]).unwrap();
1043 assert!(errors.is_empty());
1044 }
1045
1046 #[test]
1047 fn validate_content_dir_no_md_files() {
1048 let dir = tempdir().unwrap();
1049 let schema = ContentSchema {
1050 name: "post".into(),
1051 fields: vec![FieldDef {
1052 name: "title".into(),
1053 field_type: FieldType::String,
1054 required: true,
1055 default: None,
1056 }],
1057 };
1058 let errors = validate_content_dir(dir.path(), &[schema]).unwrap();
1059 assert!(errors.is_empty());
1060 }
1061
1062 #[test]
1063 fn validate_content_dir_valid_file() {
1064 let dir = tempdir().unwrap();
1065 let md =
1066 "---\ntitle: Hello\nschema: post\ndate: 2024-06-01\n---\n\nBody";
1067 fs::write(dir.path().join("hello.md"), md).unwrap();
1068
1069 let schema = ContentSchema {
1070 name: "post".into(),
1071 fields: vec![
1072 FieldDef {
1073 name: "title".into(),
1074 field_type: FieldType::String,
1075 required: true,
1076 default: None,
1077 },
1078 FieldDef {
1079 name: "date".into(),
1080 field_type: FieldType::Date,
1081 required: true,
1082 default: None,
1083 },
1084 ],
1085 };
1086 let errors = validate_content_dir(dir.path(), &[schema]).unwrap();
1087 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1088 }
1089
1090 #[test]
1091 fn validate_content_dir_invalid_file() {
1092 let dir = tempdir().unwrap();
1093 let md = "---\nschema: post\n---\n\nBody without title";
1094 fs::write(dir.path().join("bad.md"), md).unwrap();
1095
1096 let schema = ContentSchema {
1097 name: "post".into(),
1098 fields: vec![FieldDef {
1099 name: "title".into(),
1100 field_type: FieldType::String,
1101 required: true,
1102 default: None,
1103 }],
1104 };
1105 let errors = validate_content_dir(dir.path(), &[schema]).unwrap();
1106 assert_eq!(errors.len(), 1);
1107 assert!(errors[0].message.contains("title"));
1108 }
1109
1110 #[test]
1111 fn validate_content_dir_unknown_schema() {
1112 let dir = tempdir().unwrap();
1113 let md = "---\nschema: nonexistent\ntitle: X\n---\n\nBody";
1114 fs::write(dir.path().join("x.md"), md).unwrap();
1115
1116 let schema = ContentSchema {
1117 name: "post".into(),
1118 fields: vec![],
1119 };
1120 let errors = validate_content_dir(dir.path(), &[schema]).unwrap();
1121 assert_eq!(errors.len(), 1);
1122 assert!(errors[0].message.contains("unknown schema"));
1123 }
1124
1125 #[test]
1126 fn validate_content_dir_file_without_schema_key() {
1127 let dir = tempdir().unwrap();
1128 let md = "---\ntitle: No Schema\n---\n\nBody";
1129 fs::write(dir.path().join("no_schema.md"), md).unwrap();
1130
1131 let schema = ContentSchema {
1132 name: "post".into(),
1133 fields: vec![FieldDef {
1134 name: "title".into(),
1135 field_type: FieldType::String,
1136 required: true,
1137 default: None,
1138 }],
1139 };
1140 let errors = validate_content_dir(dir.path(), &[schema]).unwrap();
1142 assert!(errors.is_empty());
1143 }
1144
1145 #[test]
1150 fn plugin_name() {
1151 let plugin = ContentValidationPlugin;
1152 assert_eq!(plugin.name(), "content-validation");
1153 }
1154
1155 #[test]
1156 fn plugin_before_compile_no_schema_file() {
1157 let dir = tempdir().unwrap();
1158 let ctx = PluginContext::new(
1159 dir.path(),
1160 Path::new("build"),
1161 Path::new("public"),
1162 Path::new("templates"),
1163 );
1164 assert!(ContentValidationPlugin.before_compile(&ctx).is_ok());
1166 }
1167
1168 #[test]
1169 fn plugin_before_compile_with_valid_content() {
1170 let dir = tempdir().unwrap();
1171 let content_dir = dir.path();
1172
1173 fs::write(
1175 content_dir.join("content.schema.toml"),
1176 r#"
1177[[schemas]]
1178name = "post"
1179
1180[[schemas.fields]]
1181name = "title"
1182type = "string"
1183required = true
1184"#,
1185 )
1186 .unwrap();
1187
1188 fs::write(
1190 content_dir.join("valid.md"),
1191 "---\ntitle: Hello World\nschema: post\n---\n\nContent here.",
1192 )
1193 .unwrap();
1194
1195 let ctx = PluginContext::new(
1196 content_dir,
1197 Path::new("build"),
1198 Path::new("public"),
1199 Path::new("templates"),
1200 );
1201 assert!(ContentValidationPlugin.before_compile(&ctx).is_ok());
1202 }
1203
1204 #[test]
1205 fn plugin_before_compile_with_invalid_content() {
1206 let dir = tempdir().unwrap();
1207 let content_dir = dir.path();
1208
1209 fs::write(
1211 content_dir.join("content.schema.toml"),
1212 r#"
1213[[schemas]]
1214name = "post"
1215
1216[[schemas.fields]]
1217name = "title"
1218type = "string"
1219required = true
1220"#,
1221 )
1222 .unwrap();
1223
1224 fs::write(
1226 content_dir.join("invalid.md"),
1227 "---\nschema: post\n---\n\nNo title here.",
1228 )
1229 .unwrap();
1230
1231 let ctx = PluginContext::new(
1232 content_dir,
1233 Path::new("build"),
1234 Path::new("public"),
1235 Path::new("templates"),
1236 );
1237 let result = ContentValidationPlugin.before_compile(&ctx);
1238 assert!(result.is_err());
1239 let err_msg = result.unwrap_err().to_string();
1240 assert!(
1241 err_msg.contains("title"),
1242 "error should mention 'title': {err_msg}"
1243 );
1244 }
1245
1246 #[test]
1251 fn validate_only_no_schemas() {
1252 let dir = tempdir().unwrap();
1253 assert!(validate_only(dir.path()).is_ok());
1254 }
1255
1256 #[test]
1257 fn validate_only_with_errors() {
1258 let dir = tempdir().unwrap();
1259 let content_dir = dir.path();
1260
1261 fs::write(
1262 content_dir.join("content.schema.toml"),
1263 r#"
1264[[schemas]]
1265name = "post"
1266
1267[[schemas.fields]]
1268name = "title"
1269type = "string"
1270required = true
1271"#,
1272 )
1273 .unwrap();
1274
1275 fs::write(
1276 content_dir.join("bad.md"),
1277 "---\nschema: post\n---\n\nMissing title.",
1278 )
1279 .unwrap();
1280
1281 assert!(validate_only(content_dir).is_err());
1282 }
1283
1284 #[test]
1285 fn validate_only_passes() {
1286 let dir = tempdir().unwrap();
1287 let content_dir = dir.path();
1288
1289 fs::write(
1290 content_dir.join("content.schema.toml"),
1291 r#"
1292[[schemas]]
1293name = "post"
1294
1295[[schemas.fields]]
1296name = "title"
1297type = "string"
1298required = true
1299"#,
1300 )
1301 .unwrap();
1302
1303 fs::write(
1304 content_dir.join("good.md"),
1305 "---\ntitle: Valid\nschema: post\n---\n\nGood content.",
1306 )
1307 .unwrap();
1308
1309 assert!(validate_only(content_dir).is_ok());
1310 }
1311
1312 #[test]
1317 fn validate_multiple_errors_in_one_file() {
1318 let schema = ContentSchema {
1319 name: "post".into(),
1320 fields: vec![
1321 FieldDef {
1322 name: "title".into(),
1323 field_type: FieldType::String,
1324 required: true,
1325 default: None,
1326 },
1327 FieldDef {
1328 name: "date".into(),
1329 field_type: FieldType::Date,
1330 required: true,
1331 default: None,
1332 },
1333 FieldDef {
1334 name: "count".into(),
1335 field_type: FieldType::Integer,
1336 required: true,
1337 default: None,
1338 },
1339 ],
1340 };
1341
1342 let fields: HashMap<String, String> = HashMap::new();
1344 let errors =
1345 validate_frontmatter(&fields, &schema, Path::new("test.md"), 1);
1346 assert_eq!(errors.len(), 3);
1347 }
1348
1349 #[test]
1350 fn validate_enum_field_in_frontmatter() {
1351 let schema = ContentSchema {
1352 name: "post".into(),
1353 fields: vec![FieldDef {
1354 name: "status".into(),
1355 field_type: FieldType::Enum(vec![
1356 "draft".into(),
1357 "published".into(),
1358 ]),
1359 required: true,
1360 default: None,
1361 }],
1362 };
1363
1364 let mut ok_fields = HashMap::new();
1365 let _ = ok_fields.insert("status".into(), "draft".into());
1366 assert!(validate_frontmatter(
1367 &ok_fields,
1368 &schema,
1369 Path::new("t.md"),
1370 1
1371 )
1372 .is_empty());
1373
1374 let mut bad_fields = HashMap::new();
1375 let _ = bad_fields.insert("status".into(), "unknown".into());
1376 let errors =
1377 validate_frontmatter(&bad_fields, &schema, Path::new("t.md"), 1);
1378 assert_eq!(errors.len(), 1);
1379 assert!(errors[0].message.contains("allowed values"));
1380 }
1381
1382 #[test]
1383 fn content_schema_clone_and_debug() {
1384 let schema = ContentSchema {
1385 name: "post".into(),
1386 fields: vec![FieldDef {
1387 name: "title".into(),
1388 field_type: FieldType::String,
1389 required: true,
1390 default: None,
1391 }],
1392 };
1393 let cloned = schema.clone();
1394 assert_eq!(cloned.name, "post");
1395 let debug = format!("{schema:?}");
1396 assert!(debug.contains("post"));
1397 }
1398
1399 #[test]
1400 fn field_def_clone_and_debug() {
1401 let fd = FieldDef {
1402 name: "x".into(),
1403 field_type: FieldType::Bool,
1404 required: false,
1405 default: Some("true".into()),
1406 };
1407 let cloned = fd.clone();
1408 assert_eq!(cloned.name, "x");
1409 let debug = format!("{fd:?}");
1410 assert!(debug.contains("Bool"));
1411 }
1412
1413 #[test]
1414 fn validation_error_clone_and_debug() {
1415 let err = ValidationError {
1416 file: PathBuf::from("x.md"),
1417 line: 5,
1418 message: "bad".into(),
1419 };
1420 let cloned = err.clone();
1421 assert_eq!(cloned.line, 5);
1422 let debug = format!("{err:?}");
1423 assert!(debug.contains("bad"));
1424 }
1425
1426 #[test]
1427 fn field_type_clone_and_eq() {
1428 let a = FieldType::Enum(vec!["x".into()]);
1429 let b = a.clone();
1430 assert_eq!(a, b);
1431 assert_ne!(FieldType::String, FieldType::Bool);
1432 }
1433}