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::error::SsgError;
48use crate::plugin::{Plugin, PluginContext};
49
50#[derive(Debug, Clone, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum FieldType {
61 String,
63 Date,
65 Bool,
67 Integer,
69 Float,
71 List,
73 Enum(Vec<String>),
75}
76
77impl fmt::Display for FieldType {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 match self {
80 Self::String => write!(f, "string"),
81 Self::Date => write!(f, "date"),
82 Self::Bool => write!(f, "bool"),
83 Self::Integer => write!(f, "integer"),
84 Self::Float => write!(f, "float"),
85 Self::List => write!(f, "list"),
86 Self::Enum(variants) => write!(f, "enum({})", variants.join(",")),
87 }
88 }
89}
90
91fn parse_field_type(s: &str) -> Result<FieldType, String> {
96 match s.trim() {
97 "string" => Ok(FieldType::String),
98 "date" => Ok(FieldType::Date),
99 "bool" => Ok(FieldType::Bool),
100 "integer" => Ok(FieldType::Integer),
101 "float" => Ok(FieldType::Float),
102 "list" => Ok(FieldType::List),
103 other if other.starts_with("enum(") && other.ends_with(')') => {
104 let inner = &other[5..other.len() - 1];
105 let variants: Vec<String> =
106 inner.split(',').map(|v| v.trim().to_owned()).collect();
107 if variants.is_empty() || variants.iter().any(String::is_empty) {
108 return Err(format!(
109 "enum type must have non-empty variants: {other}"
110 ));
111 }
112 Ok(FieldType::Enum(variants))
113 }
114 _ => Err(format!("unknown field type: {s}")),
115 }
116}
117
118#[derive(Debug, Clone)]
120pub struct FieldDef {
121 pub name: String,
123 pub field_type: FieldType,
125 pub required: bool,
127 pub default: Option<String>,
129}
130
131#[derive(Debug, Clone)]
133pub struct ContentSchema {
134 pub name: String,
137 pub fields: Vec<FieldDef>,
139}
140
141#[derive(Deserialize)]
146struct SchemaFile {
147 schemas: Vec<RawSchema>,
148}
149
150#[derive(Deserialize)]
151struct RawSchema {
152 name: String,
153 fields: Vec<RawField>,
154}
155
156#[derive(Deserialize)]
157struct RawField {
158 name: String,
159 #[serde(rename = "type")]
160 field_type: String,
161 #[serde(default)]
162 required: bool,
163 default: Option<String>,
164}
165
166pub fn load_schemas(path: &Path) -> Result<Vec<ContentSchema>> {
186 if !path.exists() {
187 return Ok(Vec::new());
188 }
189
190 let text = fs::read_to_string(path).with_context(|| {
191 format!("failed to read schema file: {}", path.display())
192 })?;
193
194 parse_schemas(&text)
195}
196
197pub fn parse_schemas(toml_text: &str) -> Result<Vec<ContentSchema>> {
216 let raw: SchemaFile = toml::from_str(toml_text)
217 .context("failed to parse content.schema.toml")?;
218
219 raw.schemas
220 .into_iter()
221 .map(|rs| {
222 let fields = rs
223 .fields
224 .into_iter()
225 .map(|rf| {
226 let ft = parse_field_type(&rf.field_type).map_err(|e| {
227 anyhow::anyhow!(
228 "schema '{}', field '{}': {}",
229 rs.name,
230 rf.name,
231 e
232 )
233 })?;
234 Ok(FieldDef {
235 name: rf.name,
236 field_type: ft,
237 required: rf.required,
238 default: rf.default,
239 })
240 })
241 .collect::<Result<Vec<_>>>()?;
242 Ok(ContentSchema {
243 name: rs.name,
244 fields,
245 })
246 })
247 .collect()
248}
249
250#[derive(Debug, Clone)]
256pub struct ValidationError {
257 pub file: PathBuf,
259 pub line: usize,
261 pub message: String,
263}
264
265impl fmt::Display for ValidationError {
266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267 write!(f, "{}:{}: {}", self.file.display(), self.line, self.message)
268 }
269}
270
271#[must_use]
296pub fn validate_frontmatter(
297 fields: &HashMap<String, String>,
298 schema: &ContentSchema,
299 file_path: &Path,
300 fm_start_line: usize,
301) -> Vec<ValidationError> {
302 let mut errors = Vec::new();
303
304 for field_def in &schema.fields {
305 match fields.get(&field_def.name) {
306 Some(value) => {
307 if let Err(msg) = validate_value(value, &field_def.field_type) {
308 errors.push(ValidationError {
309 file: file_path.to_path_buf(),
310 line: fm_start_line,
311 message: format!(
312 "field '{}': {msg} (expected {})",
313 field_def.name, field_def.field_type
314 ),
315 });
316 }
317 }
318 None => {
319 if field_def.required && field_def.default.is_none() {
320 errors.push(ValidationError {
321 file: file_path.to_path_buf(),
322 line: fm_start_line,
323 message: format!(
324 "required field '{}' is missing",
325 field_def.name
326 ),
327 });
328 }
329 }
330 }
331 }
332
333 errors
334}
335
336fn is_valid_date(value: &str) -> bool {
338 let parts: Vec<&str> = value.split('-').collect();
339 parts.len() == 3
340 && parts[0].len() == 4
341 && parts[1].len() == 2
342 && parts[2].len() == 2
343 && parts[0].chars().all(|c| c.is_ascii_digit())
344 && parts[1].chars().all(|c| c.is_ascii_digit())
345 && parts[2].chars().all(|c| c.is_ascii_digit())
346}
347
348fn validate_value(value: &str, ft: &FieldType) -> Result<(), String> {
350 match ft {
351 FieldType::String => Ok(()),
352 FieldType::Date => {
353 if is_valid_date(value) {
355 Ok(())
356 } else {
357 Err(format!(
358 "'{value}' is not a valid date (expected YYYY-MM-DD)"
359 ))
360 }
361 }
362 FieldType::Bool => match value {
363 "true" | "false" => Ok(()),
364 _ => Err(format!("'{value}' is not a valid bool")),
365 },
366 FieldType::Integer => {
367 let _: i64 = value
368 .parse::<i64>()
369 .map_err(|_| format!("'{value}' is not a valid integer"))?;
370 Ok(())
371 }
372 FieldType::Float => {
373 let _: f64 = value
374 .parse::<f64>()
375 .map_err(|_| format!("'{value}' is not a valid float"))?;
376 Ok(())
377 }
378 FieldType::List => {
379 Ok(())
382 }
383 FieldType::Enum(variants) => {
384 if variants.iter().any(|v| v == value) {
385 Ok(())
386 } else {
387 Err(format!(
388 "'{value}' is not one of the allowed values: {}",
389 variants.join(", ")
390 ))
391 }
392 }
393 }
394}
395
396fn extract_frontmatter_map(
405 content: &str,
406) -> Option<(HashMap<String, String>, usize)> {
407 let fm_result = frontmatter_gen::extract(content);
408 let Ok((fm, _body)) = fm_result else {
409 return None;
410 };
411
412 let mut map = HashMap::new();
413 for (key, value) in &fm.0 {
414 let _ = map.insert(key.clone(), fm_value_to_string(value));
415 }
416
417 Some((map, 1))
419}
420
421fn fm_value_to_string(value: &frontmatter_gen::Value) -> String {
423 match value {
424 frontmatter_gen::Value::String(s) => s.clone(),
425 frontmatter_gen::Value::Number(n) => format!("{n}"),
426 frontmatter_gen::Value::Boolean(b) => format!("{b}"),
427 frontmatter_gen::Value::Array(arr) => arr
428 .iter()
429 .map(fm_value_to_string)
430 .collect::<Vec<_>>()
431 .join(","),
432 frontmatter_gen::Value::Null => String::new(),
433 other => format!("{other:?}"),
434 }
435}
436
437pub fn validate_content_dir(
460 content_dir: &Path,
461 schemas: &[ContentSchema],
462) -> Result<Vec<ValidationError>> {
463 if schemas.is_empty() {
464 return Ok(Vec::new());
465 }
466
467 let schema_map: HashMap<&str, &ContentSchema> =
468 schemas.iter().map(|s| (s.name.as_str(), s)).collect();
469
470 let md_files = crate::walk::walk_files_bounded_depth(
471 content_dir,
472 "md",
473 crate::MAX_DIR_DEPTH,
474 )?;
475
476 let mut all_errors = Vec::new();
477
478 for md_path in &md_files {
479 let content = fs::read_to_string(md_path)
480 .with_context(|| format!("failed to read {}", md_path.display()))?;
481
482 let Some((fields, fm_line)) = extract_frontmatter_map(&content) else {
483 continue;
484 };
485
486 let schema_name = match fields.get("schema") {
488 Some(name) => name.as_str(),
489 None => continue, };
491
492 let Some(schema) = schema_map.get(schema_name) else {
493 all_errors.push(ValidationError {
494 file: md_path.clone(),
495 line: fm_line,
496 message: format!("unknown schema '{schema_name}'"),
497 });
498 continue;
499 };
500
501 let mut errs = validate_frontmatter(&fields, schema, md_path, fm_line);
502 all_errors.append(&mut errs);
503 }
504
505 Ok(all_errors)
506}
507
508#[derive(Debug, Clone, Copy)]
515pub struct ContentValidationPlugin;
516
517impl Plugin for ContentValidationPlugin {
518 fn name(&self) -> &'static str {
519 "content-validation"
520 }
521
522 fn before_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
523 crate::core_group::theme_manifest::check_theme_compatibility(
526 &ctx.template_dir,
527 )?;
528
529 let schema_path = ctx.content_dir.join("content.schema.toml");
530 let schemas = load_schemas(&schema_path)
531 .map_err(|e| SsgError::io(e, &schema_path))?;
532
533 if schemas.is_empty() {
534 info!("No content schemas found — skipping validation");
535 return Ok(());
536 }
537
538 info!(
539 "Loaded {} content schema(s), validating {}",
540 schemas.len(),
541 ctx.content_dir.display()
542 );
543
544 let errors = validate_content_dir(&ctx.content_dir, &schemas)
545 .map_err(|e| SsgError::io(e, &ctx.content_dir))?;
546
547 if errors.is_empty() {
548 info!("All content files passed schema validation");
549 Ok(())
550 } else {
551 let mut msg = format!(
552 "Content validation failed with {} error(s):\n",
553 errors.len()
554 );
555 for err in &errors {
556 msg.push_str(&format!(" {err}\n"));
557 }
558 Err(SsgError::Validation {
559 field: "frontmatter".to_string(),
560 message: msg,
561 })
562 }
563 }
564}
565
566pub fn validate_only(content_dir: &Path) -> Result<()> {
586 let schema_path = content_dir.join("content.schema.toml");
587 validate_with_schema(content_dir, &schema_path)
588}
589
590pub fn validate_with_schema(
613 content_dir: &Path,
614 schema_path: &Path,
615) -> Result<()> {
616 let schemas = load_schemas(schema_path)?;
617
618 if schemas.is_empty() {
619 println!("No content schemas found in {}", schema_path.display());
620 return Ok(());
621 }
622
623 println!("Loaded {} schema(s)", schemas.len());
624
625 let errors = validate_content_dir(content_dir, &schemas)?;
626
627 if errors.is_empty() {
628 println!("All content files passed schema validation.");
629 Ok(())
630 } else {
631 eprintln!("Validation failed with {} error(s):", errors.len());
632 for err in &errors {
633 eprintln!(" {err}");
634 }
635 Err(anyhow::anyhow!(
636 "{} content validation error(s)",
637 errors.len()
638 ))
639 }
640}
641
642#[cfg(test)]
647mod tests {
648 use super::*;
649 use std::fs;
650 use tempfile::tempdir;
651
652 #[test]
657 fn parse_field_type_string() {
658 assert_eq!(parse_field_type("string").unwrap(), FieldType::String);
659 }
660
661 #[test]
662 fn parse_field_type_date() {
663 assert_eq!(parse_field_type("date").unwrap(), FieldType::Date);
664 }
665
666 #[test]
667 fn parse_field_type_bool() {
668 assert_eq!(parse_field_type("bool").unwrap(), FieldType::Bool);
669 }
670
671 #[test]
672 fn parse_field_type_integer() {
673 assert_eq!(parse_field_type("integer").unwrap(), FieldType::Integer);
674 }
675
676 #[test]
677 fn parse_field_type_float() {
678 assert_eq!(parse_field_type("float").unwrap(), FieldType::Float);
679 }
680
681 #[test]
682 fn parse_field_type_list() {
683 assert_eq!(parse_field_type("list").unwrap(), FieldType::List);
684 }
685
686 #[test]
687 fn parse_field_type_enum() {
688 let ft = parse_field_type("enum(draft,published,archived)").unwrap();
689 assert_eq!(
690 ft,
691 FieldType::Enum(vec![
692 "draft".to_owned(),
693 "published".to_owned(),
694 "archived".to_owned(),
695 ])
696 );
697 }
698
699 #[test]
700 fn parse_field_type_enum_trimmed() {
701 let ft = parse_field_type("enum( a , b )").unwrap();
702 assert_eq!(ft, FieldType::Enum(vec!["a".to_owned(), "b".to_owned()]));
703 }
704
705 #[test]
706 fn parse_field_type_unknown() {
707 assert!(parse_field_type("foobar").is_err());
708 }
709
710 #[test]
711 fn parse_field_type_enum_empty_variants() {
712 assert!(parse_field_type("enum()").is_err());
713 }
714
715 #[test]
720 fn field_type_display() {
721 assert_eq!(FieldType::String.to_string(), "string");
722 assert_eq!(FieldType::Date.to_string(), "date");
723 assert_eq!(FieldType::Bool.to_string(), "bool");
724 assert_eq!(FieldType::Integer.to_string(), "integer");
725 assert_eq!(FieldType::Float.to_string(), "float");
726 assert_eq!(FieldType::List.to_string(), "list");
727 assert_eq!(
728 FieldType::Enum(vec!["a".into(), "b".into()]).to_string(),
729 "enum(a,b)"
730 );
731 }
732
733 #[test]
738 fn validate_string_always_ok() {
739 assert!(validate_value("anything", &FieldType::String).is_ok());
740 assert!(validate_value("", &FieldType::String).is_ok());
741 }
742
743 #[test]
744 fn validate_date_ok() {
745 assert!(validate_value("2024-01-15", &FieldType::Date).is_ok());
746 }
747
748 #[test]
749 fn validate_date_bad() {
750 assert!(validate_value("not-a-date", &FieldType::Date).is_err());
751 assert!(validate_value("2024/01/15", &FieldType::Date).is_err());
752 }
753
754 #[test]
755 fn validate_bool_ok() {
756 assert!(validate_value("true", &FieldType::Bool).is_ok());
757 assert!(validate_value("false", &FieldType::Bool).is_ok());
758 }
759
760 #[test]
761 fn validate_bool_bad() {
762 assert!(validate_value("yes", &FieldType::Bool).is_err());
763 assert!(validate_value("1", &FieldType::Bool).is_err());
764 }
765
766 #[test]
767 fn validate_integer_ok() {
768 assert!(validate_value("42", &FieldType::Integer).is_ok());
769 assert!(validate_value("-7", &FieldType::Integer).is_ok());
770 assert!(validate_value("0", &FieldType::Integer).is_ok());
771 }
772
773 #[test]
774 fn validate_integer_bad() {
775 assert!(validate_value("3.14", &FieldType::Integer).is_err());
776 assert!(validate_value("abc", &FieldType::Integer).is_err());
777 }
778
779 #[test]
780 fn validate_float_ok() {
781 assert!(validate_value("3.14", &FieldType::Float).is_ok());
782 assert!(validate_value("-1.0", &FieldType::Float).is_ok());
783 assert!(validate_value("42", &FieldType::Float).is_ok());
784 }
785
786 #[test]
787 fn validate_float_bad() {
788 assert!(validate_value("abc", &FieldType::Float).is_err());
789 }
790
791 #[test]
792 fn validate_list_always_ok() {
793 assert!(validate_value("a,b,c", &FieldType::List).is_ok());
794 assert!(validate_value("", &FieldType::List).is_ok());
795 }
796
797 #[test]
798 fn validate_enum_ok() {
799 let ft = FieldType::Enum(vec!["draft".into(), "published".into()]);
800 assert!(validate_value("draft", &ft).is_ok());
801 assert!(validate_value("published", &ft).is_ok());
802 }
803
804 #[test]
805 fn validate_enum_bad() {
806 let ft = FieldType::Enum(vec!["draft".into(), "published".into()]);
807 assert!(validate_value("archived", &ft).is_err());
808 }
809
810 #[test]
815 fn validate_frontmatter_all_present() {
816 let schema = ContentSchema {
817 name: "post".into(),
818 fields: vec![
819 FieldDef {
820 name: "title".into(),
821 field_type: FieldType::String,
822 required: true,
823 default: None,
824 },
825 FieldDef {
826 name: "date".into(),
827 field_type: FieldType::Date,
828 required: true,
829 default: None,
830 },
831 ],
832 };
833
834 let mut fields = HashMap::new();
835 let _ = fields.insert("title".into(), "Hello".into());
836 let _ = fields.insert("date".into(), "2024-06-01".into());
837
838 let errors =
839 validate_frontmatter(&fields, &schema, Path::new("test.md"), 1);
840 assert!(errors.is_empty());
841 }
842
843 #[test]
844 fn validate_frontmatter_missing_required() {
845 let schema = ContentSchema {
846 name: "post".into(),
847 fields: vec![FieldDef {
848 name: "title".into(),
849 field_type: FieldType::String,
850 required: true,
851 default: None,
852 }],
853 };
854
855 let fields: HashMap<String, String> = HashMap::new();
856 let errors =
857 validate_frontmatter(&fields, &schema, Path::new("test.md"), 1);
858 assert_eq!(errors.len(), 1);
859 assert!(errors[0].message.contains("required"));
860 }
861
862 #[test]
863 fn validate_frontmatter_missing_with_default() {
864 let schema = ContentSchema {
865 name: "post".into(),
866 fields: vec![FieldDef {
867 name: "draft".into(),
868 field_type: FieldType::Bool,
869 required: true,
870 default: Some("false".into()),
871 }],
872 };
873
874 let fields: HashMap<String, String> = HashMap::new();
875 let errors =
876 validate_frontmatter(&fields, &schema, Path::new("test.md"), 1);
877 assert!(errors.is_empty());
879 }
880
881 #[test]
882 fn validate_frontmatter_wrong_type() {
883 let schema = ContentSchema {
884 name: "post".into(),
885 fields: vec![FieldDef {
886 name: "date".into(),
887 field_type: FieldType::Date,
888 required: true,
889 default: None,
890 }],
891 };
892
893 let mut fields = HashMap::new();
894 let _ = fields.insert("date".into(), "not-a-date".into());
895
896 let errors =
897 validate_frontmatter(&fields, &schema, Path::new("test.md"), 1);
898 assert_eq!(errors.len(), 1);
899 assert!(errors[0].message.contains("date"));
900 }
901
902 #[test]
903 fn validate_frontmatter_optional_missing_ok() {
904 let schema = ContentSchema {
905 name: "post".into(),
906 fields: vec![FieldDef {
907 name: "subtitle".into(),
908 field_type: FieldType::String,
909 required: false,
910 default: None,
911 }],
912 };
913
914 let fields: HashMap<String, String> = HashMap::new();
915 let errors =
916 validate_frontmatter(&fields, &schema, Path::new("test.md"), 1);
917 assert!(errors.is_empty());
918 }
919
920 #[test]
925 fn validation_error_display() {
926 let err = ValidationError {
927 file: PathBuf::from("content/post.md"),
928 line: 3,
929 message: "field 'title': missing".into(),
930 };
931 assert_eq!(
932 err.to_string(),
933 "content/post.md:3: field 'title': missing"
934 );
935 }
936
937 #[test]
942 fn parse_schemas_basic() {
943 let toml = r#"
944[[schemas]]
945name = "post"
946
947[[schemas.fields]]
948name = "title"
949type = "string"
950required = true
951
952[[schemas.fields]]
953name = "date"
954type = "date"
955required = true
956
957[[schemas.fields]]
958name = "draft"
959type = "bool"
960required = false
961default = "false"
962"#;
963 let schemas = parse_schemas(toml).unwrap();
964 assert_eq!(schemas.len(), 1);
965 assert_eq!(schemas[0].name, "post");
966 assert_eq!(schemas[0].fields.len(), 3);
967 assert_eq!(schemas[0].fields[0].name, "title");
968 assert!(schemas[0].fields[0].required);
969 assert_eq!(schemas[0].fields[2].default, Some("false".to_owned()));
970 }
971
972 #[test]
973 fn parse_schemas_multiple() {
974 let toml = r#"
975[[schemas]]
976name = "post"
977
978[[schemas.fields]]
979name = "title"
980type = "string"
981required = true
982
983[[schemas]]
984name = "page"
985
986[[schemas.fields]]
987name = "heading"
988type = "string"
989required = true
990"#;
991 let schemas = parse_schemas(toml).unwrap();
992 assert_eq!(schemas.len(), 2);
993 assert_eq!(schemas[0].name, "post");
994 assert_eq!(schemas[1].name, "page");
995 }
996
997 #[test]
998 fn parse_schemas_enum_field() {
999 let toml = r#"
1000[[schemas]]
1001name = "post"
1002
1003[[schemas.fields]]
1004name = "status"
1005type = "enum(draft,published,archived)"
1006required = true
1007"#;
1008 let schemas = parse_schemas(toml).unwrap();
1009 assert_eq!(
1010 schemas[0].fields[0].field_type,
1011 FieldType::Enum(vec![
1012 "draft".into(),
1013 "published".into(),
1014 "archived".into()
1015 ])
1016 );
1017 }
1018
1019 #[test]
1020 fn parse_schemas_bad_type() {
1021 let toml = r#"
1022[[schemas]]
1023name = "post"
1024
1025[[schemas.fields]]
1026name = "x"
1027type = "unknown_type"
1028required = true
1029"#;
1030 assert!(parse_schemas(toml).is_err());
1031 }
1032
1033 #[test]
1034 fn parse_schemas_bad_toml() {
1035 assert!(parse_schemas("not valid toml {{{}}}").is_err());
1036 }
1037
1038 #[test]
1043 fn load_schemas_nonexistent_file() {
1044 let schemas =
1045 load_schemas(Path::new("/tmp/does-not-exist/content.schema.toml"))
1046 .unwrap();
1047 assert!(schemas.is_empty());
1048 }
1049
1050 #[test]
1051 fn load_schemas_from_file() {
1052 let dir = tempdir().unwrap();
1053 let path = dir.path().join("content.schema.toml");
1054 fs::write(
1055 &path,
1056 r#"
1057[[schemas]]
1058name = "post"
1059
1060[[schemas.fields]]
1061name = "title"
1062type = "string"
1063required = true
1064"#,
1065 )
1066 .unwrap();
1067
1068 let schemas = load_schemas(&path).unwrap();
1069 assert_eq!(schemas.len(), 1);
1070 }
1071
1072 #[test]
1077 fn extract_fm_from_yaml() {
1078 let content = "---\ntitle: Hello\ndate: 2024-01-01\n---\n\nBody text";
1079 let (map, line) = extract_frontmatter_map(content).unwrap();
1080 assert_eq!(map.get("title").unwrap(), "Hello");
1081 assert_eq!(map.get("date").unwrap(), "2024-01-01");
1082 assert_eq!(line, 1);
1083 }
1084
1085 #[test]
1086 fn extract_fm_no_frontmatter() {
1087 let content = "Just plain text without frontmatter.";
1088 assert!(extract_frontmatter_map(content).is_none());
1089 }
1090
1091 #[test]
1096 fn fm_value_string() {
1097 let v = frontmatter_gen::Value::String("hello".into());
1098 assert_eq!(fm_value_to_string(&v), "hello");
1099 }
1100
1101 #[test]
1102 fn fm_value_number() {
1103 let v = frontmatter_gen::Value::Number(42.0);
1104 assert_eq!(fm_value_to_string(&v), "42");
1105 }
1106
1107 #[test]
1108 fn fm_value_bool() {
1109 let v = frontmatter_gen::Value::Boolean(true);
1110 assert_eq!(fm_value_to_string(&v), "true");
1111 }
1112
1113 #[test]
1114 fn fm_value_null() {
1115 let v = frontmatter_gen::Value::Null;
1116 assert_eq!(fm_value_to_string(&v), "");
1117 }
1118
1119 #[test]
1120 fn fm_value_array() {
1121 let v = frontmatter_gen::Value::Array(vec![
1122 frontmatter_gen::Value::String("a".into()),
1123 frontmatter_gen::Value::String("b".into()),
1124 ]);
1125 assert_eq!(fm_value_to_string(&v), "a,b");
1126 }
1127
1128 #[test]
1133 fn validate_content_dir_empty_schemas() {
1134 let dir = tempdir().unwrap();
1135 let errors = validate_content_dir(dir.path(), &[]).unwrap();
1136 assert!(errors.is_empty());
1137 }
1138
1139 #[test]
1140 fn validate_content_dir_no_md_files() {
1141 let dir = tempdir().unwrap();
1142 let schema = ContentSchema {
1143 name: "post".into(),
1144 fields: vec![FieldDef {
1145 name: "title".into(),
1146 field_type: FieldType::String,
1147 required: true,
1148 default: None,
1149 }],
1150 };
1151 let errors = validate_content_dir(dir.path(), &[schema]).unwrap();
1152 assert!(errors.is_empty());
1153 }
1154
1155 #[test]
1156 fn validate_content_dir_valid_file() {
1157 let dir = tempdir().unwrap();
1158 let md =
1159 "---\ntitle: Hello\nschema: post\ndate: 2024-06-01\n---\n\nBody";
1160 fs::write(dir.path().join("hello.md"), md).unwrap();
1161
1162 let schema = ContentSchema {
1163 name: "post".into(),
1164 fields: vec![
1165 FieldDef {
1166 name: "title".into(),
1167 field_type: FieldType::String,
1168 required: true,
1169 default: None,
1170 },
1171 FieldDef {
1172 name: "date".into(),
1173 field_type: FieldType::Date,
1174 required: true,
1175 default: None,
1176 },
1177 ],
1178 };
1179 let errors = validate_content_dir(dir.path(), &[schema]).unwrap();
1180 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1181 }
1182
1183 #[test]
1184 fn validate_content_dir_invalid_file() {
1185 let dir = tempdir().unwrap();
1186 let md = "---\nschema: post\n---\n\nBody without title";
1187 fs::write(dir.path().join("bad.md"), md).unwrap();
1188
1189 let schema = ContentSchema {
1190 name: "post".into(),
1191 fields: vec![FieldDef {
1192 name: "title".into(),
1193 field_type: FieldType::String,
1194 required: true,
1195 default: None,
1196 }],
1197 };
1198 let errors = validate_content_dir(dir.path(), &[schema]).unwrap();
1199 assert_eq!(errors.len(), 1);
1200 assert!(errors[0].message.contains("title"));
1201 }
1202
1203 #[test]
1204 fn validate_content_dir_unknown_schema() {
1205 let dir = tempdir().unwrap();
1206 let md = "---\nschema: nonexistent\ntitle: X\n---\n\nBody";
1207 fs::write(dir.path().join("x.md"), md).unwrap();
1208
1209 let schema = ContentSchema {
1210 name: "post".into(),
1211 fields: vec![],
1212 };
1213 let errors = validate_content_dir(dir.path(), &[schema]).unwrap();
1214 assert_eq!(errors.len(), 1);
1215 assert!(errors[0].message.contains("unknown schema"));
1216 }
1217
1218 #[test]
1219 fn validate_content_dir_file_without_schema_key() {
1220 let dir = tempdir().unwrap();
1221 let md = "---\ntitle: No Schema\n---\n\nBody";
1222 fs::write(dir.path().join("no_schema.md"), md).unwrap();
1223
1224 let schema = ContentSchema {
1225 name: "post".into(),
1226 fields: vec![FieldDef {
1227 name: "title".into(),
1228 field_type: FieldType::String,
1229 required: true,
1230 default: None,
1231 }],
1232 };
1233 let errors = validate_content_dir(dir.path(), &[schema]).unwrap();
1235 assert!(errors.is_empty());
1236 }
1237
1238 #[test]
1243 fn plugin_name() {
1244 let plugin = ContentValidationPlugin;
1245 assert_eq!(plugin.name(), "content-validation");
1246 }
1247
1248 #[test]
1249 fn plugin_before_compile_no_schema_file() {
1250 let dir = tempdir().unwrap();
1251 let ctx = PluginContext::new(
1252 dir.path(),
1253 Path::new("build"),
1254 Path::new("public"),
1255 Path::new("templates"),
1256 );
1257 assert!(ContentValidationPlugin.before_compile(&ctx).is_ok());
1259 }
1260
1261 #[test]
1262 fn plugin_before_compile_with_valid_content() {
1263 let dir = tempdir().unwrap();
1264 let content_dir = dir.path();
1265
1266 fs::write(
1268 content_dir.join("content.schema.toml"),
1269 r#"
1270[[schemas]]
1271name = "post"
1272
1273[[schemas.fields]]
1274name = "title"
1275type = "string"
1276required = true
1277"#,
1278 )
1279 .unwrap();
1280
1281 fs::write(
1283 content_dir.join("valid.md"),
1284 "---\ntitle: Hello World\nschema: post\n---\n\nContent here.",
1285 )
1286 .unwrap();
1287
1288 let ctx = PluginContext::new(
1289 content_dir,
1290 Path::new("build"),
1291 Path::new("public"),
1292 Path::new("templates"),
1293 );
1294 assert!(ContentValidationPlugin.before_compile(&ctx).is_ok());
1295 }
1296
1297 #[test]
1298 fn plugin_before_compile_with_invalid_content() {
1299 let dir = tempdir().unwrap();
1300 let content_dir = dir.path();
1301
1302 fs::write(
1304 content_dir.join("content.schema.toml"),
1305 r#"
1306[[schemas]]
1307name = "post"
1308
1309[[schemas.fields]]
1310name = "title"
1311type = "string"
1312required = true
1313"#,
1314 )
1315 .unwrap();
1316
1317 fs::write(
1319 content_dir.join("invalid.md"),
1320 "---\nschema: post\n---\n\nNo title here.",
1321 )
1322 .unwrap();
1323
1324 let ctx = PluginContext::new(
1325 content_dir,
1326 Path::new("build"),
1327 Path::new("public"),
1328 Path::new("templates"),
1329 );
1330 let result = ContentValidationPlugin.before_compile(&ctx);
1331 assert!(result.is_err());
1332 let err_msg = result.unwrap_err().to_string();
1333 assert!(
1334 err_msg.contains("title"),
1335 "error should mention 'title': {err_msg}"
1336 );
1337 }
1338
1339 #[test]
1344 fn validate_only_no_schemas() {
1345 let dir = tempdir().unwrap();
1346 assert!(validate_only(dir.path()).is_ok());
1347 }
1348
1349 #[test]
1350 fn validate_only_with_errors() {
1351 let dir = tempdir().unwrap();
1352 let content_dir = dir.path();
1353
1354 fs::write(
1355 content_dir.join("content.schema.toml"),
1356 r#"
1357[[schemas]]
1358name = "post"
1359
1360[[schemas.fields]]
1361name = "title"
1362type = "string"
1363required = true
1364"#,
1365 )
1366 .unwrap();
1367
1368 fs::write(
1369 content_dir.join("bad.md"),
1370 "---\nschema: post\n---\n\nMissing title.",
1371 )
1372 .unwrap();
1373
1374 assert!(validate_only(content_dir).is_err());
1375 }
1376
1377 #[test]
1378 fn validate_only_passes() {
1379 let dir = tempdir().unwrap();
1380 let content_dir = dir.path();
1381
1382 fs::write(
1383 content_dir.join("content.schema.toml"),
1384 r#"
1385[[schemas]]
1386name = "post"
1387
1388[[schemas.fields]]
1389name = "title"
1390type = "string"
1391required = true
1392"#,
1393 )
1394 .unwrap();
1395
1396 fs::write(
1397 content_dir.join("good.md"),
1398 "---\ntitle: Valid\nschema: post\n---\n\nGood content.",
1399 )
1400 .unwrap();
1401
1402 assert!(validate_only(content_dir).is_ok());
1403 }
1404
1405 #[test]
1410 fn validate_multiple_errors_in_one_file() {
1411 let schema = ContentSchema {
1412 name: "post".into(),
1413 fields: vec![
1414 FieldDef {
1415 name: "title".into(),
1416 field_type: FieldType::String,
1417 required: true,
1418 default: None,
1419 },
1420 FieldDef {
1421 name: "date".into(),
1422 field_type: FieldType::Date,
1423 required: true,
1424 default: None,
1425 },
1426 FieldDef {
1427 name: "count".into(),
1428 field_type: FieldType::Integer,
1429 required: true,
1430 default: None,
1431 },
1432 ],
1433 };
1434
1435 let fields: HashMap<String, String> = HashMap::new();
1437 let errors =
1438 validate_frontmatter(&fields, &schema, Path::new("test.md"), 1);
1439 assert_eq!(errors.len(), 3);
1440 }
1441
1442 #[test]
1443 fn validate_enum_field_in_frontmatter() {
1444 let schema = ContentSchema {
1445 name: "post".into(),
1446 fields: vec![FieldDef {
1447 name: "status".into(),
1448 field_type: FieldType::Enum(vec![
1449 "draft".into(),
1450 "published".into(),
1451 ]),
1452 required: true,
1453 default: None,
1454 }],
1455 };
1456
1457 let mut ok_fields = HashMap::new();
1458 let _ = ok_fields.insert("status".into(), "draft".into());
1459 assert!(validate_frontmatter(
1460 &ok_fields,
1461 &schema,
1462 Path::new("t.md"),
1463 1
1464 )
1465 .is_empty());
1466
1467 let mut bad_fields = HashMap::new();
1468 let _ = bad_fields.insert("status".into(), "unknown".into());
1469 let errors =
1470 validate_frontmatter(&bad_fields, &schema, Path::new("t.md"), 1);
1471 assert_eq!(errors.len(), 1);
1472 assert!(errors[0].message.contains("allowed values"));
1473 }
1474
1475 #[test]
1476 fn content_schema_clone_and_debug() {
1477 let schema = ContentSchema {
1478 name: "post".into(),
1479 fields: vec![FieldDef {
1480 name: "title".into(),
1481 field_type: FieldType::String,
1482 required: true,
1483 default: None,
1484 }],
1485 };
1486 let cloned = schema.clone();
1487 assert_eq!(cloned.name, "post");
1488 let debug = format!("{schema:?}");
1489 assert!(debug.contains("post"));
1490 }
1491
1492 #[test]
1493 fn field_def_clone_and_debug() {
1494 let fd = FieldDef {
1495 name: "x".into(),
1496 field_type: FieldType::Bool,
1497 required: false,
1498 default: Some("true".into()),
1499 };
1500 let cloned = fd.clone();
1501 assert_eq!(cloned.name, "x");
1502 let debug = format!("{fd:?}");
1503 assert!(debug.contains("Bool"));
1504 }
1505
1506 #[test]
1507 fn validation_error_clone_and_debug() {
1508 let err = ValidationError {
1509 file: PathBuf::from("x.md"),
1510 line: 5,
1511 message: "bad".into(),
1512 };
1513 let cloned = err.clone();
1514 assert_eq!(cloned.line, 5);
1515 let debug = format!("{err:?}");
1516 assert!(debug.contains("bad"));
1517 }
1518
1519 #[test]
1520 fn field_type_clone_and_eq() {
1521 let a = FieldType::Enum(vec!["x".into()]);
1522 let b = a.clone();
1523 assert_eq!(a, b);
1524 assert_ne!(FieldType::String, FieldType::Bool);
1525 }
1526
1527 const MINIMAL_SCHEMA: &str = r#"
1533[[schemas]]
1534name = "post"
1535
1536[[schemas.fields]]
1537name = "title"
1538type = "string"
1539required = true
1540"#;
1541
1542 #[test]
1543 fn load_schemas_read_failure_carries_context() {
1544 let dir = tempdir().unwrap();
1547 let schema = dir.path().join("content.schema.toml");
1548 fs::write(&schema, [0xFF, 0xFE, 0x00]).unwrap();
1549
1550 let err = load_schemas(&schema).unwrap_err();
1551 assert!(
1552 format!("{err:#}").contains("failed to read schema file"),
1553 "context should mention the schema file: {err:#}"
1554 );
1555 }
1556
1557 #[test]
1558 fn fm_value_to_string_tagged_uses_debug_fallback() {
1559 let tagged = frontmatter_gen::Value::Tagged(
1560 "tag".to_string(),
1561 Box::new(frontmatter_gen::Value::Boolean(true)),
1562 );
1563 let rendered = fm_value_to_string(&tagged);
1564 assert!(
1565 rendered.contains("Tagged"),
1566 "debug fallback expected: {rendered}"
1567 );
1568 }
1569
1570 #[test]
1571 #[cfg(unix)]
1572 fn validate_content_dir_unreadable_dir_propagates() {
1573 use std::os::unix::fs::PermissionsExt;
1574 let dir = tempdir().unwrap();
1575 let content = dir.path().join("content");
1576 fs::create_dir(&content).unwrap();
1577 let schemas = parse_schemas(MINIMAL_SCHEMA).unwrap();
1578 fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
1579 .unwrap();
1580
1581 let res = validate_content_dir(&content, &schemas);
1582
1583 let _ =
1584 fs::set_permissions(&content, fs::Permissions::from_mode(0o755));
1585 assert!(res.err().is_none_or(|e| !format!("{e:#}").is_empty()));
1588 }
1589
1590 #[test]
1591 fn validate_content_dir_read_failure_carries_context() {
1592 let dir = tempdir().unwrap();
1595 let schemas = parse_schemas(MINIMAL_SCHEMA).unwrap();
1596 fs::write(dir.path().join("bad.md"), [0xFF, 0xFE]).unwrap();
1597
1598 let err = validate_content_dir(dir.path(), &schemas).unwrap_err();
1599 assert!(
1600 format!("{err:#}").contains("failed to read"),
1601 "context should mention the failed read: {err:#}"
1602 );
1603 }
1604
1605 #[test]
1606 fn validate_content_dir_skips_files_without_frontmatter() {
1607 let dir = tempdir().unwrap();
1609 let schemas = parse_schemas(MINIMAL_SCHEMA).unwrap();
1610 fs::write(dir.path().join("plain.md"), "# Just a heading\n").unwrap();
1611
1612 let errs = validate_content_dir(dir.path(), &schemas).unwrap();
1613 assert!(errs.is_empty());
1614 }
1615
1616 #[test]
1617 fn plugin_before_compile_schema_read_error_maps_to_io() {
1618 let dir = tempdir().unwrap();
1621 fs::write(dir.path().join("content.schema.toml"), [0xFF, 0xFE, 0x00])
1622 .unwrap();
1623 let ctx = PluginContext::new(
1624 dir.path(),
1625 Path::new("build"),
1626 Path::new("public"),
1627 Path::new("templates"),
1628 );
1629
1630 assert!(ContentValidationPlugin.before_compile(&ctx).is_err());
1631 }
1632
1633 #[test]
1634 fn plugin_before_compile_logs_schema_count() {
1635 crate::test_support::init_logger();
1638 let dir = tempdir().unwrap();
1639 fs::write(dir.path().join("content.schema.toml"), MINIMAL_SCHEMA)
1640 .unwrap();
1641 fs::write(
1642 dir.path().join("ok.md"),
1643 "---\ntitle: Hi\nschema: post\n---\nBody",
1644 )
1645 .unwrap();
1646 let ctx = PluginContext::new(
1647 dir.path(),
1648 Path::new("build"),
1649 Path::new("public"),
1650 Path::new("templates"),
1651 );
1652
1653 assert!(ContentValidationPlugin.before_compile(&ctx).is_ok());
1654 }
1655
1656 #[test]
1657 fn plugin_before_compile_validate_error_maps_to_io() {
1658 let dir = tempdir().unwrap();
1661 fs::write(dir.path().join("content.schema.toml"), MINIMAL_SCHEMA)
1662 .unwrap();
1663 fs::write(dir.path().join("bad.md"), [0xFF, 0xFE]).unwrap();
1664 let ctx = PluginContext::new(
1665 dir.path(),
1666 Path::new("build"),
1667 Path::new("public"),
1668 Path::new("templates"),
1669 );
1670
1671 assert!(ContentValidationPlugin.before_compile(&ctx).is_err());
1672 }
1673
1674 #[test]
1675 fn validate_with_schema_load_error_propagates() {
1676 let dir = tempdir().unwrap();
1678 let schema = dir.path().join("schema.toml");
1679 fs::write(&schema, [0xFF, 0xFE]).unwrap();
1680
1681 assert!(validate_with_schema(dir.path(), &schema).is_err());
1682 }
1683
1684 #[test]
1685 fn validate_with_schema_content_error_propagates() {
1686 let dir = tempdir().unwrap();
1688 let schema = dir.path().join("schema.toml");
1689 fs::write(&schema, MINIMAL_SCHEMA).unwrap();
1690 fs::write(dir.path().join("bad.md"), [0xFF, 0xFE]).unwrap();
1691
1692 assert!(validate_with_schema(dir.path(), &schema).is_err());
1693 }
1694}