1use std::path::Path;
13
14use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
15use serde::Deserialize;
16
17#[derive(Debug, Clone, Deserialize)]
20pub struct FrontmatterConfig {
21 #[serde(default = "default_order")]
24 pub order: Vec<String>,
25
26 #[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
52const 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#[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#[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 let Some(frontmatter) = extract_frontmatter(source) else {
89 return;
90 };
91
92 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 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 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 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#[derive(Default)]
197pub struct Frontmatter {
198 config: FrontmatterConfig,
199}
200
201impl Frontmatter {
202 #[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#[must_use]
234pub fn init_decree() -> BoxDecree {
235 Box::new(Frontmatter::default())
236}
237
238#[must_use]
240pub fn init_decree_with_config(config: FrontmatterConfig) -> BoxDecree {
241 Box::new(Frontmatter::with_config(config))
242}
243
244pub 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 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 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 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 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 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 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 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 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 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 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 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}