use anyhow::{anyhow, Context, Result};
use chrono::Local;
use regex::Regex;
use std::{
ffi::OsStr,
fs,
fs::File,
io::{BufRead, BufReader, Write},
path::{Path, PathBuf},
};
use super::AdrRepository;
use crate::domain::AdrMeta;
pub struct FsAdrRepository {
root: PathBuf,
}
impl FsAdrRepository {
pub fn new<P: Into<PathBuf>>(root: P) -> Self {
Self { root: root.into() }
}
fn parse_adr_file(&self, path: &Path) -> Result<AdrMeta> {
let mut number = self.number_from_filename(path).unwrap_or(0);
let mut title = String::new();
let mut status = String::from("Accepted");
let mut date = String::new();
let mut supersedes: Option<u32> = None;
let mut superseded_by: Option<u32> = None;
let raw = fs::read_to_string(path)?;
if let Some(stripped) = raw.strip_prefix("---\n") {
if let Some(end) = stripped.find("\n---\n") {
let fm_block = &stripped[..end];
#[derive(serde::Deserialize)]
struct FM {
title: Option<String>,
date: Option<String>,
status: Option<String>,
number: Option<u32>,
supersedes: Option<u32>,
superseded_by: Option<u32>,
}
if let Ok(fm) = serde_yaml::from_str::<FM>(fm_block) {
if let Some(n) = fm.number {
number = n;
}
if let Some(t) = fm.title {
title = t;
}
if let Some(d) = fm.date {
date = d;
}
if let Some(s) = fm.status {
status = s;
}
if let Some(su) = fm.supersedes {
supersedes = Some(su);
}
if let Some(sb) = fm.superseded_by {
superseded_by = Some(sb);
}
}
}
}
if title.is_empty() || date.is_empty() || status.is_empty() {
let reader = BufReader::new(raw.as_bytes());
for (i, line) in reader.lines().take(200).enumerate() {
let line = line?;
if i == 0 {
if let Some(idx) = line.find(": ") {
let head = &line[..idx];
if let Some(num_idx) = head.rfind(' ') {
if let Ok(n) = head[num_idx + 1..].parse::<u32>() {
number = n;
}
}
title = line[idx + 2..].trim().to_string();
}
}
if let Some(stripped) = line.strip_prefix("Title:") {
title = stripped.trim().to_string();
}
if let Some(stripped) = line.strip_prefix("Date:") {
date = stripped.trim().to_string();
}
if let Some(stripped) = line.strip_prefix("Status:") {
status = stripped.trim().to_string();
}
if let Some(stripped) = line.strip_prefix("Supersedes:") {
let v = stripped.trim();
if let Ok(n) = v.parse::<u32>() {
supersedes = Some(n);
}
}
if let Some(stripped) = line.strip_prefix("Superseded-by:") {
let v = stripped.trim();
if let Ok(n) = v.parse::<u32>() {
superseded_by = Some(n);
}
}
}
}
if title.is_empty() {
title = self
.title_from_filename(path)
.unwrap_or_else(|| "Untitled".to_string());
}
if date.is_empty() {
date = Local::now().format("%Y-%m-%d").to_string();
}
Ok(AdrMeta {
number,
title,
status,
date,
supersedes,
superseded_by,
path: path.to_path_buf(),
})
}
fn number_from_filename(&self, path: &Path) -> Option<u32> {
let fname = path.file_name()?.to_str()?;
let re = Regex::new(r"^(\d{4})-").ok()?;
let caps = re.captures(fname)?;
caps.get(1)?.as_str().parse::<u32>().ok()
}
fn title_from_filename(&self, path: &Path) -> Option<String> {
let fname = path.file_stem()?.to_str()?;
let mut parts = fname.splitn(2, '-');
parts.next()?;
let slug = parts.next().unwrap_or("");
if slug.is_empty() {
return None;
}
let title = slug
.split('-')
.filter(|s| !s.is_empty())
.map(|w| {
let mut cs = w.chars();
match cs.next() {
Some(f) => f.to_ascii_uppercase().to_string() + cs.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ");
Some(title)
}
}
impl AdrRepository for FsAdrRepository {
fn adr_dir(&self) -> &Path {
&self.root
}
fn list(&self) -> Result<Vec<AdrMeta>> {
let mut res = Vec::new();
if !self.root.exists() {
return Ok(res);
}
let re = Regex::new(r"^\d{4}-.*\.(md|mdx)$")
.map_err(|e| anyhow!("invalid ADR filename regex: {}", e))?;
for entry in fs::read_dir(&self.root)
.with_context(|| format!("Reading ADR directory at {}", self.root.display()))?
{
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let fname = path.file_name().and_then(OsStr::to_str).unwrap_or("");
if !re.is_match(fname) {
continue;
}
let meta = self.parse_adr_file(&path)?;
res.push(meta);
}
res.sort_by_key(|a| a.number);
Ok(res)
}
fn read_string(&self, path: &Path) -> Result<String> {
let content = fs::read_to_string(path)?;
Ok(content)
}
fn write_string(&self, path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut f = File::create(path)?;
f.write_all(content.as_bytes())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_empty_list_ok() {
let dir = tempdir().unwrap();
let repo = FsAdrRepository::new(dir.path());
let list = repo.list().unwrap();
assert!(list.is_empty());
}
#[test]
fn test_ignores_non_matching_and_fallbacks() {
let dir = tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("README.md"), "hello").unwrap();
let adr_path = root.join("0007-no-status.md");
std::fs::write(&adr_path, "# minimal file\n\nBody\n").unwrap();
let repo = FsAdrRepository::new(root);
let list = repo.list().unwrap();
assert_eq!(list.len(), 1);
let a = &list[0];
assert_eq!(a.number, 7);
assert_eq!(a.title, "No Status");
assert_eq!(a.status, "Accepted");
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
assert_eq!(a.date, today);
}
#[test]
fn test_parse_fields_from_content() {
let dir = tempdir().unwrap();
let root = dir.path();
let p = root.join("0010-detailed.md");
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
let content = format!(
"# ADR 0010: Header Title\n\nTitle: Overridden Title\nDate: {}\nStatus: Proposed\nSupersedes: 0002\nSuperseded-by: 0011\n\nBody\n",
today
);
std::fs::write(&p, content).unwrap();
let repo = FsAdrRepository::new(root);
let list = repo.list().unwrap();
assert_eq!(list.len(), 1);
let a = &list[0];
assert_eq!(a.number, 10);
assert_eq!(a.title, "Overridden Title");
assert_eq!(a.date, today);
assert_eq!(a.status, "Proposed");
assert_eq!(a.supersedes, Some(2));
assert_eq!(a.superseded_by, Some(11));
}
#[test]
fn test_untitled_when_empty_slug() {
let dir = tempdir().unwrap();
let root = dir.path();
let p = root.join("0008-.md");
std::fs::write(&p, "# No header number or title\n").unwrap();
let repo = FsAdrRepository::new(root);
let list = repo.list().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].title, "Untitled");
assert_eq!(list[0].number, 8);
}
#[test]
fn test_parse_front_matter_only_mdx() {
let dir = tempdir().unwrap();
let root = dir.path();
let p = root.join("0001-front.mdx");
let content = "---\n\
title: Front Matter Title\n\
date: 2025-01-02\n\
status: Proposed\n\
number: 1\n\
supersedes: 3\n\
superseded_by: 5\n\
---\n\nBody\n";
std::fs::write(&p, content).unwrap();
let repo = FsAdrRepository::new(root);
let list = repo.list().unwrap();
assert_eq!(list.len(), 1);
let a = &list[0];
assert_eq!(a.number, 1);
assert_eq!(a.title, "Front Matter Title");
assert_eq!(a.date, "2025-01-02");
assert_eq!(a.status, "Proposed");
assert_eq!(a.supersedes, Some(3));
assert_eq!(a.superseded_by, Some(5));
}
#[test]
fn test_list_includes_mdx_and_md() {
let dir = tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("0001-a.mdx"), "# ADR 0001: A\n\n").unwrap();
std::fs::write(root.join("0002-b.md"), "# ADR 0002: B\n\n").unwrap();
let repo = FsAdrRepository::new(root);
let list = repo.list().unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].number, 1);
assert_eq!(list[1].number, 2);
}
}