Skip to main content

ssg/
content.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Typed content collections with frontmatter schema validation
5//!
6//! Defines content schemas in `content.schema.toml` and validates
7//! every Markdown file's frontmatter against the matching schema
8//! before compilation begins.
9//!
10//! ## Schema file format
11//!
12//! ```toml
13//! [[schemas]]
14//! name = "post"
15//!
16//! [[schemas.fields]]
17//! name = "title"
18//! type = "string"
19//! required = true
20//!
21//! [[schemas.fields]]
22//! name = "date"
23//! type = "date"
24//! required = true
25//!
26//! [[schemas.fields]]
27//! name = "draft"
28//! type = "bool"
29//! required = false
30//! default = "false"
31//! ```
32//!
33//! ## Supported field types
34//!
35//! `string`, `date`, `bool`, `integer`, `float`, `list`,
36//! `enum(value1,value2,...)`.
37
38use 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// -----------------------------------------------------------------------
50// Schema types
51// -----------------------------------------------------------------------
52
53/// The type of a frontmatter field.
54///
55/// Marked `#[non_exhaustive]` so additional validators (`Uuid`, `Slug`,
56/// `Email`, `Url`) can land without a breaking release.
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum FieldType {
60    /// Free-form string.
61    String,
62    /// ISO-8601 date (`YYYY-MM-DD`).
63    Date,
64    /// Boolean (`true` / `false`).
65    Bool,
66    /// Signed integer.
67    Integer,
68    /// Floating-point number.
69    Float,
70    /// YAML/TOML array.
71    List,
72    /// One of a fixed set of allowed values.
73    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
90/// Parses a type string (from the TOML schema) into a [`FieldType`].
91///
92/// Accepts `"string"`, `"date"`, `"bool"`, `"integer"`, `"float"`,
93/// `"list"`, and `"enum(a,b,c)"`.
94fn 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/// Definition of a single frontmatter field.
118#[derive(Debug, Clone)]
119pub struct FieldDef {
120    /// Field name as it appears in frontmatter.
121    pub name: String,
122    /// Expected type of the field value.
123    pub field_type: FieldType,
124    /// Whether the field must be present.
125    pub required: bool,
126    /// Optional default value (serialized as a string).
127    pub default: Option<String>,
128}
129
130/// A named collection of field definitions.
131#[derive(Debug, Clone)]
132pub struct ContentSchema {
133    /// Schema name (e.g. `"post"`). Content files opt in via a
134    /// `schema = "post"` frontmatter key.
135    pub name: String,
136    /// Expected fields.
137    pub fields: Vec<FieldDef>,
138}
139
140// -----------------------------------------------------------------------
141// TOML deserialization helpers (intermediate representation)
142// -----------------------------------------------------------------------
143
144#[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
165// -----------------------------------------------------------------------
166// Loading schemas
167// -----------------------------------------------------------------------
168
169/// Loads all content schemas from a `content.schema.toml` file.
170///
171/// Returns an empty `Vec` if the file does not exist — schemas are
172/// opt-in.
173pub 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
185/// Parses the TOML text of a schema file into [`ContentSchema`] values.
186pub 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// -----------------------------------------------------------------------
222// Validation
223// -----------------------------------------------------------------------
224
225/// A single validation error with file context.
226#[derive(Debug, Clone)]
227pub struct ValidationError {
228    /// Path to the content file.
229    pub file: PathBuf,
230    /// One-based line number of the frontmatter block (or 1 if unknown).
231    pub line: usize,
232    /// Human-readable error message.
233    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/// Validates a frontmatter map against a [`ContentSchema`].
243///
244/// Returns a list of errors (empty on success). `file_path` and
245/// `fm_start_line` are used only for error reporting.
246#[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
287/// Returns `true` if `value` matches the YYYY-MM-DD date format.
288fn 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
299/// Checks that `value` is valid for the given [`FieldType`].
300fn validate_value(value: &str, ft: &FieldType) -> Result<(), String> {
301    match ft {
302        FieldType::String => Ok(()),
303        FieldType::Date => {
304            // Accept YYYY-MM-DD
305            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            // Accept comma-separated or YAML-style bracket syntax
331            // Anything non-empty is accepted; empty is fine too.
332            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
347// -----------------------------------------------------------------------
348// Helpers: extract frontmatter as flat HashMap<String, String>
349// -----------------------------------------------------------------------
350
351/// Extracts frontmatter from Markdown content as a flat string map.
352///
353/// Returns `(fields, frontmatter_start_line)`. The start line is 1 for
354/// files beginning with `---`.
355fn 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    // Frontmatter starts at line 1 for `---` delimited blocks.
369    Some((map, 1))
370}
371
372/// Converts a frontmatter value to its string representation.
373fn 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
388// -----------------------------------------------------------------------
389// Validate all content files against schemas
390// -----------------------------------------------------------------------
391
392/// Validates every `.md` file in `content_dir` against the loaded schemas.
393///
394/// Files opt in by including a `schema = "<name>"` field in their
395/// frontmatter. Files without the field are silently skipped.
396///
397/// Returns all validation errors found across all files.
398pub 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        // Determine which schema applies
426        let schema_name = match fields.get("schema") {
427            Some(name) => name.as_str(),
428            None => continue, // no schema declared — skip
429        };
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// -----------------------------------------------------------------------
448// Plugin
449// -----------------------------------------------------------------------
450
451/// Plugin that validates content frontmatter against schemas defined in
452/// `content.schema.toml` during the `before_compile` hook.
453#[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
494// -----------------------------------------------------------------------
495// Standalone validate-only entry point (for --validate flag)
496// -----------------------------------------------------------------------
497
498/// Runs content schema validation without building the site.
499///
500/// Returns `Ok(())` when all files pass, or an error listing every
501/// validation failure.
502pub 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
507/// Validates every `.md` file under `content_dir` against the schema at
508/// `schema_path`.
509///
510/// Use this entry point when the schema must live *outside* `content_dir`
511/// — for example, because `staticdatagen::compile` reads every file in
512/// `content_dir` and would fail to parse a non-Markdown schema file as
513/// content.
514///
515/// # Errors
516/// Returns `Err` if the schema can't be loaded or any content file fails
517/// validation.
518pub 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// -----------------------------------------------------------------------
549// Tests
550// -----------------------------------------------------------------------
551
552#[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    // -------------------------------------------------------------------
560    // FieldType parsing
561    // -------------------------------------------------------------------
562
563    #[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    // -------------------------------------------------------------------
623    // FieldType Display
624    // -------------------------------------------------------------------
625
626    #[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    // -------------------------------------------------------------------
641    // validate_value
642    // -------------------------------------------------------------------
643
644    #[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    // -------------------------------------------------------------------
718    // validate_frontmatter
719    // -------------------------------------------------------------------
720
721    #[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        // Has a default — no error even though required
785        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    // -------------------------------------------------------------------
828    // ValidationError Display
829    // -------------------------------------------------------------------
830
831    #[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    // -------------------------------------------------------------------
845    // Schema loading (parse_schemas)
846    // -------------------------------------------------------------------
847
848    #[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    // -------------------------------------------------------------------
946    // load_schemas from file
947    // -------------------------------------------------------------------
948
949    #[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    // -------------------------------------------------------------------
980    // extract_frontmatter_map
981    // -------------------------------------------------------------------
982
983    #[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    // -------------------------------------------------------------------
999    // fm_value_to_string
1000    // -------------------------------------------------------------------
1001
1002    #[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    // -------------------------------------------------------------------
1036    // validate_content_dir integration
1037    // -------------------------------------------------------------------
1038
1039    #[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        // Files without `schema` key are silently skipped
1141        let errors = validate_content_dir(dir.path(), &[schema]).unwrap();
1142        assert!(errors.is_empty());
1143    }
1144
1145    // -------------------------------------------------------------------
1146    // Plugin trait
1147    // -------------------------------------------------------------------
1148
1149    #[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        // No schema file — should succeed silently
1165        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        // Write schema
1174        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        // Write valid content
1189        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        // Write schema
1210        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        // Write content missing required field
1225        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    // -------------------------------------------------------------------
1247    // validate_only
1248    // -------------------------------------------------------------------
1249
1250    #[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    // -------------------------------------------------------------------
1313    // Edge cases
1314    // -------------------------------------------------------------------
1315
1316    #[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        // All fields missing
1343        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}