Skip to main content

dictator_frontmatter/
lib.rs

1//! decree.frontmatter - YAML frontmatter structural rules.
2//!
3//! Applies to files with `---` delimited YAML frontmatter:
4//! - Markdown (.md)
5//! - MDX (.mdx)
6//!
7//! Does NOT handle:
8//! - Astro (.astro) - uses JS/TS frontmatter, not YAML
9//! - Standalone YAML files - use decree.yaml
10//! - TOML files - use decree.toml
11
12use std::path::Path;
13
14use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
15use serde::Deserialize;
16
17/// Configuration for the frontmatter decree.
18/// Parsed from `.dictate.toml` under `[decree.frontmatter]`.
19#[derive(Debug, Clone, Deserialize)]
20pub struct FrontmatterConfig {
21    /// Expected field order in frontmatter.
22    /// Fields not in this list are allowed but not order-checked.
23    #[serde(default = "default_order")]
24    pub order: Vec<String>,
25
26    /// Required fields that must be present.
27    #[serde(default = "default_required")]
28    pub required: Vec<String>,
29}
30
31fn default_order() -> Vec<String> {
32    vec![
33        "title".to_string(),
34        "description".to_string(),
35        "pubDate".to_string(),
36    ]
37}
38
39fn default_required() -> Vec<String> {
40    vec!["title".to_string()]
41}
42
43impl Default for FrontmatterConfig {
44    fn default() -> Self {
45        Self {
46            order: default_order(),
47            required: default_required(),
48        }
49    }
50}
51
52/// Supported YAML frontmatter file extensions.
53const FRONTMATTER_EXTENSIONS: &[&str] = &["md", "mdx"];
54
55fn has_frontmatter_extension(file_path: &str) -> bool {
56    Path::new(file_path).extension().is_some_and(|ext| {
57        let ext_lower = ext.to_ascii_lowercase();
58        FRONTMATTER_EXTENSIONS
59            .iter()
60            .any(|&supported| supported == ext_lower)
61    })
62}
63
64/// Lint source with default config (for backwards compatibility).
65#[must_use]
66pub fn lint_source(source: &str, file_path: &str) -> Diagnostics {
67    lint_source_with_config(source, file_path, &FrontmatterConfig::default())
68}
69
70/// Lint source with custom config.
71#[must_use]
72pub fn lint_source_with_config(
73    source: &str,
74    file_path: &str,
75    config: &FrontmatterConfig,
76) -> Diagnostics {
77    let mut diags = Diagnostics::new();
78
79    if has_frontmatter_extension(file_path) {
80        check_frontmatter(source, config, &mut diags);
81    }
82
83    diags
84}
85
86fn check_frontmatter(source: &str, config: &FrontmatterConfig, diags: &mut Diagnostics) {
87    // Extract frontmatter between --- markers
88    let Some(frontmatter) = extract_frontmatter(source) else {
89        return;
90    };
91
92    // Parse YAML to get field order
93    let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&frontmatter.content);
94    match parsed {
95        Ok(serde_yaml::Value::Mapping(ref mapping)) => {
96            check_frontmatter_fields(mapping, frontmatter.start_offset, config, diags);
97        }
98        Err(e) => {
99            diags.push(Diagnostic {
100                rule: "decree.frontmatter/invalid-yaml".to_string(),
101                message: format!("Invalid YAML frontmatter: {e}"),
102                enforced: false,
103                span: Span::new(frontmatter.start_offset, frontmatter.end_offset),
104            });
105        }
106        _ => {
107            diags.push(Diagnostic {
108                rule: "decree.frontmatter/invalid-yaml".to_string(),
109                message: "Frontmatter must be a YAML mapping".to_string(),
110                enforced: false,
111                span: Span::new(frontmatter.start_offset, frontmatter.end_offset),
112            });
113        }
114    }
115}
116
117struct ExtractedFrontmatter {
118    content: String,
119    start_offset: usize,
120    end_offset: usize,
121}
122
123fn extract_frontmatter(source: &str) -> Option<ExtractedFrontmatter> {
124    if !source.starts_with("---") {
125        return None;
126    }
127
128    let rest = &source[3..];
129    let newline_pos = rest.find('\n')?;
130    let after_first_marker = &rest[newline_pos + 1..];
131
132    // Find closing marker
133    after_first_marker.find("---").map(|closing_pos| {
134        let content = after_first_marker[..closing_pos].to_string();
135        let start_offset = 3 + newline_pos + 1;
136        let end_offset = start_offset + closing_pos;
137
138        ExtractedFrontmatter {
139            content,
140            start_offset,
141            end_offset,
142        }
143    })
144}
145
146fn check_frontmatter_fields(
147    mapping: &serde_yaml::Mapping,
148    start_offset: usize,
149    config: &FrontmatterConfig,
150    diags: &mut Diagnostics,
151) {
152    // Check required fields from config
153    for field in &config.required {
154        let key = serde_yaml::Value::String(field.clone());
155        if !mapping.contains_key(&key) {
156            diags.push(Diagnostic {
157                rule: "decree.frontmatter/missing-required-field".to_string(),
158                message: format!("Missing required field: {field}"),
159                enforced: false,
160                span: Span::new(start_offset, start_offset),
161            });
162        }
163    }
164
165    // Check field order from config
166    if config.order.is_empty() {
167        return;
168    }
169
170    let mut last_order_index: Option<usize> = None;
171    for (key, _value) in mapping {
172        if let serde_yaml::Value::String(key_str) = key
173            && let Some(order_index) = config.order.iter().position(|f| f == key_str)
174        {
175            if let Some(last_idx) = last_order_index
176                && order_index < last_idx
177            {
178                diags.push(Diagnostic {
179                    rule: "decree.frontmatter/field-order".to_string(),
180                    message: format!(
181                        "Field '{}' should come before '{}' (expected order: {})",
182                        key_str,
183                        config.order[last_idx],
184                        config.order.join(", ")
185                    ),
186                    enforced: true,
187                    span: Span::new(start_offset, start_offset),
188                });
189            }
190            last_order_index = Some(order_index);
191        }
192    }
193}
194
195/// The frontmatter decree plugin.
196#[derive(Default)]
197pub struct Frontmatter {
198    config: FrontmatterConfig,
199}
200
201impl Frontmatter {
202    /// Create a new frontmatter plugin with custom config.
203    #[must_use]
204    pub const fn with_config(config: FrontmatterConfig) -> Self {
205        Self { config }
206    }
207}
208
209impl Decree for Frontmatter {
210    fn name(&self) -> &'static str {
211        "frontmatter"
212    }
213
214    fn lint(&self, path: &str, source: &str) -> Diagnostics {
215        lint_source_with_config(source, path, &self.config)
216    }
217
218    fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
219        dictator_decree_abi::DecreeMetadata {
220            abi_version: dictator_decree_abi::ABI_VERSION.to_string(),
221            decree_version: env!("CARGO_PKG_VERSION").to_string(),
222            description: "Frontmatter field ordering and validation".to_string(),
223            dectauthors: Some(env!("CARGO_PKG_AUTHORS").to_string()),
224            supported_extensions: vec!["md".to_string(), "mdx".to_string(), "astro".to_string()],
225            supported_filenames: vec![],
226            skip_filenames: vec![],
227            capabilities: vec![dictator_decree_abi::Capability::Lint],
228        }
229    }
230}
231
232/// Create plugin with default config.
233#[must_use]
234pub fn init_decree() -> BoxDecree {
235    Box::new(Frontmatter::default())
236}
237
238/// Create plugin with custom config.
239#[must_use]
240pub fn init_decree_with_config(config: FrontmatterConfig) -> BoxDecree {
241    Box::new(Frontmatter::with_config(config))
242}
243
244/// Convert `DecreeSettings` from .dictate.toml to `FrontmatterConfig`.
245pub fn config_from_decree_settings(settings: &dictator_core::DecreeSettings) -> FrontmatterConfig {
246    FrontmatterConfig {
247        order: settings.order.clone().unwrap_or_else(default_order),
248        required: settings.required.clone().unwrap_or_else(default_required),
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn valid_frontmatter_order() {
258        // Default order is: title, description, pubDate
259        let src =
260            "---\ntitle: Test\ndescription: A description\npubDate: 2024-01-01\n---\n# Content\n";
261        let diags = lint_source(src, "test.md");
262        assert!(
263            diags.is_empty(),
264            "Expected no diagnostics for valid frontmatter"
265        );
266    }
267
268    #[test]
269    fn detects_wrong_field_order() {
270        // Default order is: title, description, pubDate
271        // This has pubDate before title - wrong order
272        let src = "---\npubDate: 2024-01-01\ndescription: Test desc\ntitle: Test\n---\n# Content\n";
273        let diags = lint_source(src, "test.md");
274        assert!(
275            !diags.is_empty(),
276            "Expected diagnostics for wrong field order"
277        );
278        assert_eq!(diags[0].rule, "decree.frontmatter/field-order");
279    }
280
281    #[test]
282    fn detects_missing_required_fields() {
283        // Use custom config that requires both title and slug
284        let config = FrontmatterConfig {
285            order: vec!["title".to_string(), "slug".to_string()],
286            required: vec!["title".to_string(), "slug".to_string()],
287        };
288        let src = "---\ntitle: Test\n---\n# Content\n";
289        let diags = lint_source_with_config(src, "test.md", &config);
290        assert!(
291            !diags.is_empty(),
292            "Expected diagnostics for missing required field"
293        );
294        let has_missing_slug = diags.iter().any(|d| {
295            d.rule == "decree.frontmatter/missing-required-field" && d.message.contains("slug")
296        });
297        assert!(has_missing_slug);
298    }
299
300    #[test]
301    fn respects_custom_config() {
302        // Custom config with different field order
303        let config = FrontmatterConfig {
304            order: vec![
305                "title".to_string(),
306                "description".to_string(),
307                "pubDate".to_string(),
308                "author".to_string(),
309            ],
310            required: vec!["title".to_string(), "description".to_string()],
311        };
312
313        // Valid order per custom config
314        let src = "---\ntitle: Test\ndescription: A test\npubDate: 2024-01-01\n---\n# Content\n";
315        let diags = lint_source_with_config(src, "test.md", &config);
316        assert!(
317            diags.is_empty(),
318            "Expected no errors for valid custom order"
319        );
320
321        // Wrong order per custom config
322        let src_wrong = "---\npubDate: 2024-01-01\ntitle: Test\n---\n# Content\n";
323        let diags_wrong = lint_source_with_config(src_wrong, "test.md", &config);
324        assert!(
325            diags_wrong
326                .iter()
327                .any(|d| d.rule == "decree.frontmatter/field-order"),
328            "Expected field order violation"
329        );
330
331        // Missing required field
332        let src_missing = "---\ntitle: Test\n---\n# Content\n";
333        let diags_missing = lint_source_with_config(src_missing, "test.md", &config);
334        assert!(
335            diags_missing
336                .iter()
337                .any(|d| d.rule == "decree.frontmatter/missing-required-field"
338                    && d.message.contains("description")),
339            "Expected missing description error"
340        );
341    }
342
343    #[test]
344    fn ignores_non_markdown_files() {
345        let src = "title: Test\nslug: test\n";
346        let diags = lint_source(src, "test.txt");
347        assert!(diags.is_empty());
348    }
349
350    #[test]
351    fn supports_mdx_files() {
352        let src = "---\ntitle: Test\nslug: test-slug\npubDate: 2024-01-01\n---\n\n\
353                   import Component from './Component';\n\n# Content\n";
354        let diags = lint_source(src, "test.mdx");
355        assert!(
356            diags.is_empty(),
357            "Expected no diagnostics for valid MDX frontmatter"
358        );
359    }
360
361    #[test]
362    fn ignores_yaml_files() {
363        // YAML files are NOT frontmatter - they're standalone config files
364        let src = "---\ntitle: Test\nslug: test\n---\n";
365        let diags = lint_source(src, "config.yml");
366        assert!(
367            diags.is_empty(),
368            "decree.frontmatter should not lint .yml files"
369        );
370    }
371
372    #[test]
373    fn ignores_toml_files() {
374        // TOML files are NOT frontmatter
375        let src = "[package]\nname = \"test\"\n";
376        let diags = lint_source(src, "Cargo.toml");
377        assert!(
378            diags.is_empty(),
379            "decree.frontmatter should not lint .toml files"
380        );
381    }
382
383    #[test]
384    fn ignores_astro_files() {
385        // Astro files have JS/TS frontmatter, not YAML
386        let src = "---\nconst title = 'Test';\n---\n<html>{title}</html>\n";
387        let diags = lint_source(src, "page.astro");
388        assert!(
389            diags.is_empty(),
390            "decree.frontmatter should not lint .astro files"
391        );
392    }
393
394    #[test]
395    fn handles_markdown_without_frontmatter() {
396        let src = "# Content\nNo frontmatter here\n";
397        let diags = lint_source(src, "test.md");
398        assert!(diags.is_empty());
399    }
400
401    #[test]
402    fn detects_invalid_yaml() {
403        let src = "---\ntitle: [broken yaml\n---\n# Content\n";
404        let diags = lint_source(src, "test.md");
405        assert!(!diags.is_empty());
406        assert_eq!(diags[0].rule, "decree.frontmatter/invalid-yaml");
407    }
408
409    #[test]
410    fn sandbox_blog_wrong_order() {
411        // Test the actual sandbox file case: pubDate comes before title
412        // Default order: title, description, pubDate
413        // This frontmatter has: pubDate, description, title (wrong!)
414        let src = "---\npubDate: 2024-12-01\n\
415                   description: This blog post has wrong frontmatter ordering\n\
416                   title: Blog Post With Wrong Frontmatter Order\n\
417                   author: John Doe\n---\n\n# Blog Post Content\n";
418        let diags = lint_source(src, "blog-wrong-frontmatter-order.md");
419        assert!(
420            !diags.is_empty(),
421            "Expected to detect field order violation"
422        );
423
424        assert!(
425            diags
426                .iter()
427                .any(|d| d.rule == "decree.frontmatter/field-order"),
428            "Expected field order violation diagnostic"
429        );
430    }
431}