use crate::adr::format::{AdrFormat, detect_format, is_adr_document};
use crate::adr::frontmatter::parse_frontmatter;
use comrak::nodes::AstNode;
use mdbook_lint_core::Document;
use mdbook_lint_core::rule::{Rule, RuleCategory, RuleMetadata};
use mdbook_lint_core::violation::{Severity, Violation};
use regex::Regex;
use std::sync::LazyLock;
static DATE_LINE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^date\s*:\s*(.+)$").expect("Invalid regex"));
pub struct Adr003 {
format: AdrFormat,
}
impl Default for Adr003 {
fn default() -> Self {
Self {
format: AdrFormat::Auto,
}
}
}
impl Adr003 {
pub fn from_config(config: &toml::Value) -> Self {
let mut rule = Self::default();
if let Some(format) = config
.get("format")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<AdrFormat>().ok())
{
rule.format = format;
}
rule
}
#[allow(dead_code)]
pub fn with_format(format: AdrFormat) -> Self {
Self { format }
}
fn effective_format(&self, content: &str) -> AdrFormat {
match self.format {
AdrFormat::Auto => detect_format(content),
other => other,
}
}
}
impl Rule for Adr003 {
fn id(&self) -> &'static str {
"ADR003"
}
fn name(&self) -> &'static str {
"adr-required-date"
}
fn description(&self) -> &'static str {
"ADR must have a date defined"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Structure).introduced_in("mdbook-lint v0.14.0")
}
fn check_with_ast<'a>(
&self,
document: &Document,
_ast: Option<&'a AstNode<'a>>,
) -> mdbook_lint_core::error::Result<Vec<Violation>> {
if !is_adr_document(&document.content, Some(&document.path)) {
return Ok(Vec::new());
}
let mut violations = Vec::new();
let format = self.effective_format(&document.content);
match format {
AdrFormat::Madr4 => {
match parse_frontmatter(&document.content) {
Some(result) => {
if let Some(ref error) = result.error {
violations.push(self.create_violation(
format!("Cannot check date: {}", error),
result.start_line,
1,
Severity::Warning,
));
} else if let Some(ref fm) = result.frontmatter {
if fm.date.is_none() {
violations.push(
self.create_violation(
"MADR format ADR is missing 'date' field in frontmatter"
.to_string(),
result.start_line,
1,
Severity::Error,
),
);
}
} else {
violations.push(
self.create_violation(
"MADR format ADR is missing 'date' field in frontmatter"
.to_string(),
result.start_line,
1,
Severity::Error,
),
);
}
}
None => {
violations.push(self.create_violation(
"MADR format ADR is missing frontmatter with 'date' field".to_string(),
1,
1,
Severity::Error,
));
}
}
}
AdrFormat::Nygard | AdrFormat::Auto => {
let mut found_date = false;
let skip_lines = parse_frontmatter(&document.content)
.map(|r| r.end_line)
.unwrap_or(0);
for line in document.lines.iter().skip(skip_lines) {
if DATE_LINE_REGEX.is_match(line) {
found_date = true;
break;
}
}
if !found_date {
violations.push(self.create_violation(
"Nygard format ADR is missing 'Date:' line".to_string(),
1,
1,
Severity::Error,
));
}
}
}
Ok(violations)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn create_test_document(content: &str) -> Document {
Document::new(content.to_string(), PathBuf::from("adr/0001-test-adr.md")).unwrap()
}
#[test]
fn test_valid_nygard_date() {
let content = r#"# 1. Use Rust for implementation
Date: 2024-01-15
## Status
Accepted
"#;
let doc = create_test_document(content);
let rule = Adr003::default();
let violations = rule.check(&doc).unwrap();
assert!(
violations.is_empty(),
"Expected no violations for valid Nygard date"
);
}
#[test]
fn test_missing_nygard_date() {
let content = r#"# 1. Use Rust for implementation
## Status
Accepted
## Context
We need a language.
"#;
let doc = create_test_document(content);
let rule = Adr003::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("missing 'Date:' line"));
}
#[test]
fn test_valid_madr_date() {
let content = r#"---
status: accepted
date: 2024-01-15
---
# Use PostgreSQL for persistence
## Context and Problem Statement
We need a database.
"#;
let doc = create_test_document(content);
let rule = Adr003::default();
let violations = rule.check(&doc).unwrap();
assert!(
violations.is_empty(),
"Expected no violations for valid MADR date"
);
}
#[test]
fn test_missing_madr_date() {
let content = r#"---
status: accepted
---
# Use PostgreSQL for persistence
## Context and Problem Statement
We need a database.
"#;
let doc = create_test_document(content);
let rule = Adr003::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("missing 'date' field"));
}
#[test]
fn test_date_case_insensitive() {
let content = r#"# 1. Use Rust
DATE: 2024-01-15
## Status
Accepted
"#;
let doc = create_test_document(content);
let rule = Adr003::default();
let violations = rule.check(&doc).unwrap();
assert!(
violations.is_empty(),
"Date line should be case-insensitive"
);
}
#[test]
fn test_date_without_space() {
let content = r#"# 1. Use Rust
Date:2024-01-15
## Status
Accepted
"#;
let doc = create_test_document(content);
let rule = Adr003::default();
let violations = rule.check(&doc).unwrap();
assert!(
violations.is_empty(),
"Date line should work without space after colon"
);
}
#[test]
fn test_date_with_extra_whitespace() {
let content = r#"# 1. Use Rust
Date: 2024-01-15
## Status
Accepted
"#;
let doc = create_test_document(content);
let rule = Adr003::default();
let violations = rule.check(&doc).unwrap();
assert!(
violations.is_empty(),
"Date line should work with extra whitespace"
);
}
#[test]
fn test_empty_frontmatter_missing_date() {
let content = r#"---
status: accepted
---
# Title
"#;
let doc = create_test_document(content);
let rule = Adr003::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("missing 'date' field"));
}
#[test]
fn test_force_nygard_format() {
let content = r#"---
date: 2024-01-15
---
# Title
## Status
Accepted
"#;
let doc = create_test_document(content);
let rule = Adr003::with_format(AdrFormat::Nygard);
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("missing 'Date:' line"));
}
#[test]
fn test_date_in_different_formats() {
let dates = vec![
"2024-01-15",
"January 15, 2024",
"2024/01/15",
"15-01-2024",
"2024.01.15",
];
for date in dates {
let content = format!(
r#"# 1. Use Rust
Date: {}
## Status
Accepted
"#,
date
);
let doc = create_test_document(&content);
let rule = Adr003::default();
let violations = rule.check(&doc).unwrap();
assert!(
violations.is_empty(),
"Date format '{}' should be accepted",
date
);
}
}
}