Skip to main content

ssg/core/
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::error::SsgError;
48use crate::plugin::{Plugin, PluginContext};
49
50// -----------------------------------------------------------------------
51// Schema types
52// -----------------------------------------------------------------------
53
54/// The type of a frontmatter field.
55///
56/// Marked `#[non_exhaustive]` so additional validators (`Uuid`, `Slug`,
57/// `Email`, `Url`) can land without a breaking release.
58#[derive(Debug, Clone, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum FieldType {
61    /// Free-form string.
62    String,
63    /// ISO-8601 date (`YYYY-MM-DD`).
64    Date,
65    /// Boolean (`true` / `false`).
66    Bool,
67    /// Signed integer.
68    Integer,
69    /// Floating-point number.
70    Float,
71    /// YAML/TOML array.
72    List,
73    /// One of a fixed set of allowed values.
74    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
91/// Parses a type string (from the TOML schema) into a [`FieldType`].
92///
93/// Accepts `"string"`, `"date"`, `"bool"`, `"integer"`, `"float"`,
94/// `"list"`, and `"enum(a,b,c)"`.
95fn 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/// Definition of a single frontmatter field.
119#[derive(Debug, Clone)]
120pub struct FieldDef {
121    /// Field name as it appears in frontmatter.
122    pub name: String,
123    /// Expected type of the field value.
124    pub field_type: FieldType,
125    /// Whether the field must be present.
126    pub required: bool,
127    /// Optional default value (serialized as a string).
128    pub default: Option<String>,
129}
130
131/// A named collection of field definitions.
132#[derive(Debug, Clone)]
133pub struct ContentSchema {
134    /// Schema name (e.g. `"post"`). Content files opt in via a
135    /// `schema = "post"` frontmatter key.
136    pub name: String,
137    /// Expected fields.
138    pub fields: Vec<FieldDef>,
139}
140
141// -----------------------------------------------------------------------
142// TOML deserialization helpers (intermediate representation)
143// -----------------------------------------------------------------------
144
145#[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
166// -----------------------------------------------------------------------
167// Loading schemas
168// -----------------------------------------------------------------------
169
170/// Loads all content schemas from a `content.schema.toml` file.
171///
172/// Returns an empty `Vec` if the file does not exist — schemas are
173/// opt-in.
174///
175/// # Examples
176///
177/// ```rust
178/// use ssg::content::load_schemas;
179/// use std::path::Path;
180///
181/// // Missing file ⇒ empty Vec, never an error.
182/// let schemas = load_schemas(Path::new("./does-not-exist.toml")).unwrap();
183/// assert!(schemas.is_empty());
184/// ```
185pub 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
197/// Parses the TOML text of a schema file into [`ContentSchema`] values.
198///
199/// # Examples
200///
201/// ```rust
202/// use ssg::content::parse_schemas;
203///
204/// let toml = r#"
205/// [[schemas]]
206/// name = "post"
207/// fields = [
208///   { name = "title", type = "string", required = true },
209/// ]
210/// "#;
211/// let schemas = parse_schemas(toml).unwrap();
212/// assert_eq!(schemas.len(), 1);
213/// assert_eq!(schemas[0].name, "post");
214/// ```
215pub 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// -----------------------------------------------------------------------
251// Validation
252// -----------------------------------------------------------------------
253
254/// A single validation error with file context.
255#[derive(Debug, Clone)]
256pub struct ValidationError {
257    /// Path to the content file.
258    pub file: PathBuf,
259    /// One-based line number of the frontmatter block (or 1 if unknown).
260    pub line: usize,
261    /// Human-readable error message.
262    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/// Validates a frontmatter map against a [`ContentSchema`].
272///
273/// Returns a list of errors (empty on success). `file_path` and
274/// `fm_start_line` are used only for error reporting.
275///
276/// # Examples
277///
278/// ```rust
279/// use ssg::content::{parse_schemas, validate_frontmatter};
280/// use std::collections::HashMap;
281/// use std::path::Path;
282///
283/// let schemas = parse_schemas(r#"
284/// [[schemas]]
285/// name = "post"
286/// fields = [
287///   { name = "title", type = "string", required = true },
288/// ]
289/// "#).unwrap();
290/// let mut fm = HashMap::new();
291/// fm.insert("title".to_string(), "Hello".to_string());
292/// let errs = validate_frontmatter(&fm, &schemas[0], Path::new("a.md"), 1);
293/// assert!(errs.is_empty());
294/// ```
295#[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
336/// Returns `true` if `value` matches the YYYY-MM-DD date format.
337fn 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
348/// Checks that `value` is valid for the given [`FieldType`].
349fn validate_value(value: &str, ft: &FieldType) -> Result<(), String> {
350    match ft {
351        FieldType::String => Ok(()),
352        FieldType::Date => {
353            // Accept YYYY-MM-DD
354            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            // Accept comma-separated or YAML-style bracket syntax
380            // Anything non-empty is accepted; empty is fine too.
381            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
396// -----------------------------------------------------------------------
397// Helpers: extract frontmatter as flat HashMap<String, String>
398// -----------------------------------------------------------------------
399
400/// Extracts frontmatter from Markdown content as a flat string map.
401///
402/// Returns `(fields, frontmatter_start_line)`. The start line is 1 for
403/// files beginning with `---`.
404fn 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    // Frontmatter starts at line 1 for `---` delimited blocks.
418    Some((map, 1))
419}
420
421/// Converts a frontmatter value to its string representation.
422fn 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
437// -----------------------------------------------------------------------
438// Validate all content files against schemas
439// -----------------------------------------------------------------------
440
441/// Validates every `.md` file in `content_dir` against the loaded schemas.
442///
443/// Files opt in by including a `schema = "<name>"` field in their
444/// frontmatter. Files without the field are silently skipped.
445///
446/// Returns all validation errors found across all files.
447///
448/// # Examples
449///
450/// ```rust
451/// use ssg::content::validate_content_dir;
452/// use tempfile::tempdir;
453///
454/// let dir = tempdir().unwrap();
455/// // With no schemas to enforce, the result is always empty.
456/// let errs = validate_content_dir(dir.path(), &[]).unwrap();
457/// assert!(errs.is_empty());
458/// ```
459pub 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        // Determine which schema applies
487        let schema_name = match fields.get("schema") {
488            Some(name) => name.as_str(),
489            None => continue, // no schema declared — skip
490        };
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// -----------------------------------------------------------------------
509// Plugin
510// -----------------------------------------------------------------------
511
512/// Plugin that validates content frontmatter against schemas defined in
513/// `content.schema.toml` during the `before_compile` hook.
514#[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        // Runs first so a theme that needs a newer generator says so once,
524        // clearly, instead of producing a silently wrong site.
525        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
566// -----------------------------------------------------------------------
567// Standalone validate-only entry point (for --validate flag)
568// -----------------------------------------------------------------------
569
570/// Runs content schema validation without building the site.
571///
572/// Returns `Ok(())` when all files pass, or an error listing every
573/// validation failure.
574///
575/// # Examples
576///
577/// ```rust
578/// use ssg::content::validate_only;
579/// use tempfile::tempdir;
580///
581/// // No schema file ⇒ trivial pass.
582/// let dir = tempdir().unwrap();
583/// assert!(validate_only(dir.path()).is_ok());
584/// ```
585pub 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
590/// Validates every `.md` file under `content_dir` against the schema at
591/// `schema_path`.
592///
593/// Use this entry point when the schema must live *outside* `content_dir`
594/// — for example, because `staticdatagen::compile` reads every file in
595/// `content_dir` and would fail to parse a non-Markdown schema file as
596/// content.
597///
598/// # Examples
599///
600/// ```rust
601/// use ssg::content::validate_with_schema;
602/// use tempfile::tempdir;
603///
604/// let dir = tempdir().unwrap();
605/// // Missing schema path is treated as "nothing to enforce".
606/// assert!(validate_with_schema(dir.path(), &dir.path().join("schema.toml")).is_ok());
607/// ```
608///
609/// # Errors
610/// Returns `Err` if the schema can't be loaded or any content file fails
611/// validation.
612pub 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// -----------------------------------------------------------------------
643// Tests
644// -----------------------------------------------------------------------
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649    use std::fs;
650    use tempfile::tempdir;
651
652    // -------------------------------------------------------------------
653    // FieldType parsing
654    // -------------------------------------------------------------------
655
656    #[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    // -------------------------------------------------------------------
716    // FieldType Display
717    // -------------------------------------------------------------------
718
719    #[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    // -------------------------------------------------------------------
734    // validate_value
735    // -------------------------------------------------------------------
736
737    #[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    // -------------------------------------------------------------------
811    // validate_frontmatter
812    // -------------------------------------------------------------------
813
814    #[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        // Has a default — no error even though required
878        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    // -------------------------------------------------------------------
921    // ValidationError Display
922    // -------------------------------------------------------------------
923
924    #[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    // -------------------------------------------------------------------
938    // Schema loading (parse_schemas)
939    // -------------------------------------------------------------------
940
941    #[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    // -------------------------------------------------------------------
1039    // load_schemas from file
1040    // -------------------------------------------------------------------
1041
1042    #[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    // -------------------------------------------------------------------
1073    // extract_frontmatter_map
1074    // -------------------------------------------------------------------
1075
1076    #[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    // -------------------------------------------------------------------
1092    // fm_value_to_string
1093    // -------------------------------------------------------------------
1094
1095    #[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    // -------------------------------------------------------------------
1129    // validate_content_dir integration
1130    // -------------------------------------------------------------------
1131
1132    #[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        // Files without `schema` key are silently skipped
1234        let errors = validate_content_dir(dir.path(), &[schema]).unwrap();
1235        assert!(errors.is_empty());
1236    }
1237
1238    // -------------------------------------------------------------------
1239    // Plugin trait
1240    // -------------------------------------------------------------------
1241
1242    #[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        // No schema file — should succeed silently
1258        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        // Write schema
1267        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        // Write valid content
1282        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        // Write schema
1303        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        // Write content missing required field
1318        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    // -------------------------------------------------------------------
1340    // validate_only
1341    // -------------------------------------------------------------------
1342
1343    #[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    // -------------------------------------------------------------------
1406    // Edge cases
1407    // -------------------------------------------------------------------
1408
1409    #[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        // All fields missing
1436        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    // -------------------------------------------------------------------
1528    // Error paths: unreadable inputs and fallback formatting
1529    // -------------------------------------------------------------------
1530
1531    /// Minimal valid schema TOML used by the error-path tests.
1532    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        // Non-UTF-8 bytes make `read_to_string` fail, exercising the
1545        // `with_context` closure on lines 190-192.
1546        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        // Root bypasses permissions on some CI runners, so tolerate
1586        // Ok; when the failure fired, the error must render non-empty.
1587        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        // A non-UTF-8 `.md` file exercises the `with_context` closure
1593        // on line 480.
1594        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        // No frontmatter block ⇒ `continue` on line 483.
1608        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        // Unreadable schema file drives the `map_err` closure on
1619        // line 525.
1620        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        // With the logger raised to Trace, the `info!` format
1636        // arguments on lines 534-535 execute.
1637        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        // Valid schema + unreadable content file drives the second
1659        // `map_err` closure (line 539).
1660        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        // The `?` on line 610.
1677        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        // The `?` on line 619.
1687        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}