use regex::Regex;
use std::sync::LazyLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AdrFormat {
Nygard,
Madr4,
#[default]
Auto,
}
impl std::fmt::Display for AdrFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AdrFormat::Nygard => write!(f, "nygard"),
AdrFormat::Madr4 => write!(f, "madr"),
AdrFormat::Auto => write!(f, "auto"),
}
}
}
impl std::str::FromStr for AdrFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"nygard" => Ok(AdrFormat::Nygard),
"madr" | "madr4" => Ok(AdrFormat::Madr4),
"auto" => Ok(AdrFormat::Auto),
_ => Err(format!("Unknown ADR format: {}", s)),
}
}
}
static NYGARD_TITLE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^#\s+(\d+)[.\-\s]+\s*(.+)$").expect("Invalid regex"));
pub fn detect_format(content: &str) -> AdrFormat {
let trimmed = content.trim_start();
if trimmed.starts_with("---") {
let headings = section_headings(content);
let has_madr = headings
.iter()
.any(|h| h == "context and problem statement" || h == "decision outcome");
let has_nygard = headings.iter().any(|h| h == "consequences")
|| (headings.iter().any(|h| h == "context")
&& headings.iter().any(|h| h == "decision"));
if has_nygard && !has_madr {
return AdrFormat::Nygard;
}
return AdrFormat::Madr4;
}
AdrFormat::Nygard
}
fn section_headings(content: &str) -> Vec<String> {
content
.lines()
.filter_map(|line| {
let trimmed = line.trim_start();
if trimmed.starts_with("##") {
let title = trimmed.trim_start_matches('#').trim().to_lowercase();
if title.is_empty() { None } else { Some(title) }
} else {
None
}
})
.collect()
}
pub fn is_adr_document(content: &str, file_path: Option<&std::path::Path>) -> bool {
if let Some(path) = file_path
&& path_in_adr_dir(path)
{
return true;
}
let trimmed = content.trim_start();
if let Some(after_open) = trimmed.strip_prefix("---")
&& let Some(end) = after_open.find("---")
{
let frontmatter = &after_open[..end];
if frontmatter.lines().any(|line| {
let line = line.trim();
line.starts_with("status:") || line.starts_with("status :")
}) {
return true;
}
}
has_nygard_title_near_top(content)
}
const ADR_DIRECTORY_NAMES: &[&str] = &["adr", "adrs", "decisions", "architecture-decisions"];
fn path_in_adr_dir(path: &std::path::Path) -> bool {
let normalized = path.to_string_lossy().replace('\\', "/").to_lowercase();
normalized
.split('/')
.any(|segment| ADR_DIRECTORY_NAMES.contains(&segment))
}
fn has_nygard_title_near_top(content: &str) -> bool {
let mut in_comment = false;
for line in content.lines().take(30) {
let trimmed = line.trim();
if in_comment {
if trimmed.contains("-->") {
in_comment = false;
}
continue;
}
if trimmed.starts_with("<!--") {
if !trimmed.contains("-->") {
in_comment = true;
}
continue;
}
if trimmed.is_empty() {
continue;
}
if is_nygard_title(trimmed) {
return true;
}
}
false
}
pub fn extract_nygard_number(title_line: &str) -> Option<u32> {
NYGARD_TITLE_REGEX
.captures(title_line)
.and_then(|caps| caps.get(1))
.and_then(|m| m.as_str().parse().ok())
}
pub fn extract_nygard_title(title_line: &str) -> Option<&str> {
NYGARD_TITLE_REGEX
.captures(title_line)
.and_then(|caps| caps.get(2))
.map(|m| m.as_str().trim())
}
pub fn is_nygard_title(line: &str) -> bool {
NYGARD_TITLE_REGEX.is_match(line)
}
pub fn extract_madr_number(content: &str) -> Option<u32> {
let trimmed = content.trim_start();
let after_open = trimmed.strip_prefix("---")?;
let end = after_open.find("---")?;
let frontmatter = &after_open[..end];
for line in frontmatter.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("number:")
&& let Ok(n) = rest.trim().parse::<u32>()
{
return Some(n);
}
}
None
}
#[derive(Debug, Clone)]
pub struct ParsedAdr {
pub format: AdrFormat,
pub number: Option<u32>,
pub title: Option<String>,
pub status: Option<String>,
pub date: Option<String>,
pub title_line: Option<usize>,
pub status_line: Option<usize>,
pub date_line: Option<usize>,
}
impl ParsedAdr {
pub fn new(format: AdrFormat) -> Self {
Self {
format,
number: None,
title: None,
status: None,
date: None,
title_line: None,
status_line: None,
date_line: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_format_madr() {
let content = r#"---
status: accepted
date: 2024-01-15
---
# Use PostgreSQL
"#;
assert_eq!(detect_format(content), AdrFormat::Madr4);
}
#[test]
fn test_detect_format_nygard() {
let content = r#"# 1. Use Rust for implementation
Date: 2024-01-15
## Status
Accepted
"#;
assert_eq!(detect_format(content), AdrFormat::Nygard);
}
#[test]
fn test_detect_format_with_leading_whitespace() {
let content = " \n\n---\nstatus: accepted\n---\n";
assert_eq!(detect_format(content), AdrFormat::Madr4);
}
#[test]
fn test_detect_format_ng_nygard() {
let content = r#"---
status: accepted
date: 2024-01-15
---
# 1. Use PostgreSQL
## Context
We need a database.
## Decision
Use PostgreSQL.
## Consequences
It works.
"#;
assert_eq!(detect_format(content), AdrFormat::Nygard);
}
#[test]
fn test_detect_format_madr_sections() {
let content = r#"---
status: accepted
---
# Use PostgreSQL
## Context and Problem Statement
We need a database.
## Decision Outcome
Chosen option: PostgreSQL.
"#;
assert_eq!(detect_format(content), AdrFormat::Madr4);
}
#[test]
fn test_detect_format_frontmatter_without_known_sections() {
let content = "---\nstatus: accepted\n---\n\n# Use PostgreSQL\n";
assert_eq!(detect_format(content), AdrFormat::Madr4);
}
#[test]
fn test_is_adr_document_decisions_dir() {
let path = std::path::PathBuf::from("docs/decisions/0001-use-postgres.md");
assert!(is_adr_document("# Use PostgreSQL\n", Some(&path)));
let arch = std::path::PathBuf::from("docs/architecture-decisions/0001.md");
assert!(is_adr_document("# Anything\n", Some(&arch)));
}
#[test]
fn test_is_adr_document_title_after_comment_header() {
let content = r#"<!--
SPDX-FileCopyrightText: 2024 Example
SPDX-License-Identifier: CC0-1.0
-->
# 1. Record architecture decisions
## Status
Accepted
"#;
assert!(is_adr_document(content, None));
}
#[test]
fn test_is_adr_document_non_adr() {
let content = "# Just a Guide\n\nSome prose about a topic.\n";
let path = std::path::PathBuf::from("docs/guide.md");
assert!(!is_adr_document(content, Some(&path)));
}
#[test]
fn test_extract_nygard_number() {
assert_eq!(extract_nygard_number("# 1. Use Rust"), Some(1));
assert_eq!(extract_nygard_number("# 42. Some Decision"), Some(42));
assert_eq!(extract_nygard_number("# 1 - Use Rust"), Some(1));
assert_eq!(extract_nygard_number("# Use Rust"), None);
assert_eq!(extract_nygard_number("## 1. Section"), None);
}
#[test]
fn test_extract_nygard_title() {
assert_eq!(extract_nygard_title("# 1. Use Rust"), Some("Use Rust"));
assert_eq!(
extract_nygard_title("# 42. Some Decision"),
Some("Some Decision")
);
assert_eq!(extract_nygard_title("# 1 - Use Rust"), Some("Use Rust"));
assert_eq!(extract_nygard_title("# Use Rust"), None);
}
#[test]
fn test_is_nygard_title() {
assert!(is_nygard_title("# 1. Use Rust"));
assert!(is_nygard_title("# 42. Some Decision"));
assert!(is_nygard_title("# 1 - Use Rust"));
assert!(!is_nygard_title("# Use Rust"));
assert!(!is_nygard_title("## 1. Section"));
}
#[test]
fn test_format_from_str() {
assert_eq!("nygard".parse::<AdrFormat>().unwrap(), AdrFormat::Nygard);
assert_eq!("madr".parse::<AdrFormat>().unwrap(), AdrFormat::Madr4);
assert_eq!("madr4".parse::<AdrFormat>().unwrap(), AdrFormat::Madr4);
assert_eq!("auto".parse::<AdrFormat>().unwrap(), AdrFormat::Auto);
assert_eq!("NYGARD".parse::<AdrFormat>().unwrap(), AdrFormat::Nygard);
assert!("unknown".parse::<AdrFormat>().is_err());
}
#[test]
fn test_format_display() {
assert_eq!(format!("{}", AdrFormat::Nygard), "nygard");
assert_eq!(format!("{}", AdrFormat::Madr4), "madr");
assert_eq!(format!("{}", AdrFormat::Auto), "auto");
}
#[test]
fn test_extract_madr_number_with_number() {
let content =
"---\nnumber: 1\nstatus: accepted\ndate: 2024-01-15\n---\n\n# Use PostgreSQL\n";
assert_eq!(extract_madr_number(content), Some(1));
}
#[test]
fn test_extract_madr_number_without_number() {
let content = "---\nstatus: accepted\ndate: 2024-01-15\n---\n\n# Use PostgreSQL\n";
assert_eq!(extract_madr_number(content), None);
}
#[test]
fn test_extract_madr_number_large_value() {
let content = "---\nnumber: 42\nstatus: accepted\n---\n\n# Use Kubernetes\n";
assert_eq!(extract_madr_number(content), Some(42));
}
#[test]
fn test_extract_madr_number_non_madr_content() {
let content = "# 1. Use Rust\n\nDate: 2024-01-15\n\n## Status\n\nAccepted\n";
assert_eq!(extract_madr_number(content), None);
}
}