use crate::{
Adr, AdrLink, AdrStatus, Config, ConfigMode, Error, LinkKind, Parser, Result, Template,
TemplateEngine, TemplateFormat, TemplateVariant,
};
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use serde_yaml_neo::{Mapping, Value};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BodySectionPatch {
pub context: Option<String>,
pub decision: Option<String>,
pub consequences: Option<String>,
}
impl BodySectionPatch {
pub fn new() -> Self {
Self {
context: None,
decision: None,
consequences: None,
}
}
pub fn is_empty(&self) -> bool {
self.context.is_none() && self.decision.is_none() && self.consequences.is_none()
}
pub fn with_context(mut self, text: impl Into<String>) -> Self {
self.context = Some(text.into());
self
}
pub fn with_decision(mut self, text: impl Into<String>) -> Self {
self.decision = Some(text.into());
self
}
pub fn with_consequences(mut self, text: impl Into<String>) -> Self {
self.consequences = Some(text.into());
self
}
}
#[derive(Debug)]
pub struct Repository {
root: PathBuf,
config: Config,
parser: Parser,
template_engine: TemplateEngine,
}
impl Repository {
pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
let root = root.into();
let config = Config::load(&root)?;
let template_engine = Self::engine_from_config(&config);
Ok(Self {
root,
config,
parser: Parser::new(),
template_engine,
})
}
pub fn open_or_default(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let config = Config::load_or_default(&root);
let template_engine = Self::engine_from_config(&config);
Self {
root,
config,
parser: Parser::new(),
template_engine,
}
}
pub fn init(root: impl Into<PathBuf>, adr_dir: Option<PathBuf>, ng: bool) -> Result<Self> {
let root = root.into();
let adr_dir = adr_dir.unwrap_or_else(|| PathBuf::from(crate::config::DEFAULT_ADR_DIR));
let adr_path = root.join(&adr_dir);
let existing_adrs = if adr_path.exists() {
count_existing_adrs(&adr_path)
} else {
fs::create_dir_all(&adr_path)?;
0
};
let config = Config {
adr_dir,
mode: if ng {
ConfigMode::NextGen
} else {
ConfigMode::Compatible
},
..Default::default()
};
config.save(&root)?;
let template_engine = Self::engine_from_config(&config);
let repo = Self {
root,
config,
parser: Parser::new(),
template_engine,
};
if existing_adrs == 0 {
let mut adr = Adr::new(1, crate::init_adr::TITLE);
adr.status = AdrStatus::Accepted;
adr.context = crate::init_adr::CONTEXT.into();
adr.decision = crate::init_adr::DECISION.into();
adr.consequences = crate::init_adr::CONSEQUENCES.into();
repo.create(&adr)?;
}
Ok(repo)
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn config(&self) -> &Config {
&self.config
}
pub fn adr_path(&self) -> PathBuf {
self.config.adr_path(&self.root)
}
fn engine_from_config(config: &Config) -> TemplateEngine {
let mut engine = TemplateEngine::new();
if let Some(ref fmt) = config.templates.format
&& let Ok(format) = fmt.parse::<TemplateFormat>()
{
engine = engine.with_format(format);
}
engine
}
pub fn with_template_format(mut self, format: TemplateFormat) -> Self {
self.template_engine = self.template_engine.with_format(format);
self
}
pub fn with_template_variant(mut self, variant: TemplateVariant) -> Self {
self.template_engine = self.template_engine.with_variant(variant);
self
}
pub fn with_mode(mut self, mode: ConfigMode) -> Self {
self.config.mode = mode;
self
}
pub fn with_custom_template(mut self, template: Template) -> Self {
self.template_engine = self.template_engine.with_custom_template(template);
self
}
pub fn list(&self) -> Result<Vec<Adr>> {
let adr_path = self.adr_path();
if !adr_path.exists() {
return Err(Error::AdrDirNotFound);
}
let mut adrs: Vec<Adr> = WalkDir::new(&adr_path)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "md")
&& e.path()
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
})
.filter_map(|e| self.parser.parse_file(e.path()).ok())
.collect();
adrs.sort_by_key(|a| a.number);
Ok(adrs)
}
#[allow(clippy::type_complexity)]
pub fn list_with_errors(&self) -> Result<(Vec<Adr>, Vec<(PathBuf, crate::Error)>)> {
let adr_path = self.adr_path();
if !adr_path.exists() {
return Err(Error::AdrDirNotFound);
}
let mut adrs = Vec::new();
let mut errors = Vec::new();
let candidates: Vec<_> = WalkDir::new(&adr_path)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "md")
&& e.path()
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
})
.collect();
for entry in candidates {
match self.parser.parse_file(entry.path()) {
Ok(adr) => adrs.push(adr),
Err(e) => errors.push((entry.path().to_path_buf(), e)),
}
}
adrs.sort_by_key(|a| a.number);
Ok((adrs, errors))
}
pub fn next_number(&self) -> Result<u32> {
let adrs = self.list()?;
Ok(adrs.last().map(|a| a.number + 1).unwrap_or(1))
}
pub fn get(&self, number: u32) -> Result<Adr> {
let adrs = self.list()?;
adrs.into_iter()
.find(|a| a.number == number)
.ok_or_else(|| Error::AdrNotFound(number.to_string()))
}
pub fn find(&self, query: &str) -> Result<Adr> {
if let Ok(number) = query.parse::<u32>() {
return self.get(number);
}
let adrs = self.list()?;
let matcher = SkimMatcherV2::default();
let mut matches: Vec<_> = adrs
.into_iter()
.filter_map(|adr| {
let score = matcher.fuzzy_match(&adr.title, query)?;
Some((adr, score))
})
.collect();
matches.sort_by_key(|m| std::cmp::Reverse(m.1));
match matches.len() {
0 => Err(Error::AdrNotFound(query.to_string())),
1 => Ok(matches.remove(0).0),
_ => {
if matches[0].1 > matches[1].1 * 2 {
Ok(matches.remove(0).0)
} else {
Err(Error::AmbiguousAdr {
query: query.to_string(),
matches: matches
.iter()
.take(5)
.map(|(a, _)| a.title.clone())
.collect(),
})
}
}
}
}
fn resolve_link_titles(&self, adr: &Adr) -> HashMap<u32, (String, String)> {
let mut map = HashMap::new();
for link in &adr.links {
if map.contains_key(&link.target) {
continue;
}
if let Ok(target_adr) = self.get(link.target) {
map.insert(
link.target,
(target_adr.title.clone(), Self::link_href(&target_adr)),
);
}
}
map
}
fn link_href(target_adr: &Adr) -> String {
target_adr
.path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|f| f.to_str())
.map(str::to_string)
.unwrap_or_else(|| target_adr.filename())
}
pub fn create(&self, adr: &Adr) -> Result<PathBuf> {
let path = self.adr_path().join(adr.filename());
let link_titles = self.resolve_link_titles(adr);
let content = self
.template_engine
.render(adr, &self.config, &link_titles)?;
fs::write(&path, content)?;
Ok(path)
}
pub fn new_adr(&self, title: impl Into<String>) -> Result<(Adr, PathBuf)> {
let number = self.next_number()?;
let mut adr = Adr::new(number, title);
if let Some(default_status) = self.config.default_status.as_deref() {
adr.status = default_status.parse::<AdrStatus>().unwrap();
}
let path = self.create(&adr)?;
Ok((adr, path))
}
pub fn supersede(&self, title: impl Into<String>, superseded: u32) -> Result<(Adr, PathBuf)> {
let number = self.next_number()?;
let mut adr = Adr::new(number, title);
adr.add_link(AdrLink::new(superseded, LinkKind::Supersedes));
let path = self.create(&adr)?;
let mut old_adr = self.get(superseded)?;
old_adr.status = AdrStatus::Superseded;
old_adr.add_link(AdrLink::new(number, LinkKind::SupersededBy));
self.update_metadata(&old_adr)?;
Ok((adr, path))
}
pub fn set_status(
&self,
number: u32,
status: AdrStatus,
superseded_by: Option<u32>,
) -> Result<PathBuf> {
if let AdrStatus::Custom(s) = &status
&& s.trim().is_empty()
{
return Err(Error::InvalidStatus(
"status cannot be empty or whitespace-only".to_string(),
));
}
let mut adr = self.get(number)?;
adr.status = status.clone();
if let (AdrStatus::Superseded, Some(by)) = (&status, superseded_by) {
let _ = self.get(by)?;
if !adr
.links
.iter()
.any(|l| matches!(l.kind, LinkKind::SupersededBy) && l.target == by)
{
adr.add_link(AdrLink::new(by, LinkKind::SupersededBy));
}
}
self.update_metadata(&adr)
}
pub fn link(
&self,
source: u32,
target: u32,
source_kind: LinkKind,
target_kind: LinkKind,
) -> Result<()> {
let mut source_adr = self.get(source)?;
let mut target_adr = self.get(target)?;
source_adr.add_link(AdrLink::new(target, source_kind));
target_adr.add_link(AdrLink::new(source, target_kind));
self.update_metadata(&source_adr)?;
self.update_metadata(&target_adr)?;
Ok(())
}
pub fn update(&self, adr: &Adr, body: BodySectionPatch) -> Result<PathBuf> {
let path = adr
.path
.clone()
.unwrap_or_else(|| self.adr_path().join(adr.filename()));
let content = fs::read_to_string(&path)?;
let content = if body.is_empty() {
if content.starts_with("---\n") {
self.update_frontmatter_metadata(adr, &content)?
} else {
self.update_legacy_metadata(adr, &content)?
}
} else {
content
};
let updated = if body.is_empty() {
content
} else {
self.update_body_sections(&content, &body)?
};
fs::write(&path, updated)?;
Ok(path)
}
pub fn read_content(&self, adr: &Adr) -> Result<String> {
let path = adr
.path
.as_ref()
.cloned()
.unwrap_or_else(|| self.adr_path().join(adr.filename()));
Ok(fs::read_to_string(path)?)
}
pub fn write_content(&self, adr: &Adr, content: &str) -> Result<PathBuf> {
let path = adr
.path
.as_ref()
.cloned()
.unwrap_or_else(|| self.adr_path().join(adr.filename()));
fs::write(&path, content)?;
Ok(path)
}
pub fn update_metadata(&self, adr: &Adr) -> Result<PathBuf> {
let path = adr
.path
.clone()
.unwrap_or_else(|| self.adr_path().join(adr.filename()));
let content = fs::read_to_string(&path)?;
let updated = if content.starts_with("---\n") {
self.update_frontmatter_metadata(adr, &content)?
} else {
self.update_legacy_metadata(adr, &content)?
};
fs::write(&path, updated)?;
Ok(path)
}
fn update_frontmatter_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
let Some(rest) = content.strip_prefix("---\n") else {
return Err(Error::InvalidFormat {
path: Default::default(),
reason: "Missing opening frontmatter delimiter".into(),
});
};
let Some(end_idx) = rest.find("\n---\n").or_else(|| {
if rest.ends_with("\n---") {
Some(rest.len() - 3)
} else {
None
}
}) else {
return Err(Error::InvalidFormat {
path: Default::default(),
reason: "Missing closing frontmatter delimiter".into(),
});
};
let yaml_block = &rest[..end_idx + 1]; let after_yaml = &rest[end_idx..];
let parsed: Value = serde_yaml_neo::from_str(yaml_block)?;
let Value::Mapping(mut map) = parsed else {
return Err(Error::InvalidFormat {
path: Default::default(),
reason: "Frontmatter YAML must be a mapping".into(),
});
};
let mut dirty = false;
let status_val = Value::String(adr.status.to_string().to_lowercase());
if map.get(Self::yaml_str_key("status")) != Some(&status_val) {
map.insert(Self::yaml_str_key("status"), status_val);
dirty = true;
}
if Self::set_yaml_sequence_field(&mut map, "links", &adr.links)? {
dirty = true;
}
if !Self::yaml_string_list_matches(&map, "tags", &adr.tags)
&& Self::set_yaml_string_list_field(&mut map, "tags", &adr.tags)?
{
dirty = true;
}
if !Self::yaml_string_list_matches(&map, "decision-makers", &adr.decision_makers)
&& Self::set_yaml_string_list_field(&mut map, "decision-makers", &adr.decision_makers)?
{
dirty = true;
}
if !Self::yaml_string_list_matches(&map, "consulted", &adr.consulted)
&& Self::set_yaml_string_list_field(&mut map, "consulted", &adr.consulted)?
{
dirty = true;
}
if !Self::yaml_string_list_matches(&map, "informed", &adr.informed)
&& Self::set_yaml_string_list_field(&mut map, "informed", &adr.informed)?
{
dirty = true;
}
if !dirty {
return Ok(content.to_string());
}
let new_yaml = serde_yaml_neo::to_string(&Value::Mapping(map))?;
let new_yaml = new_yaml.trim_end_matches('\n');
Ok(format!("---\n{new_yaml}{after_yaml}"))
}
fn update_legacy_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
let lines: Vec<&str> = content.lines().collect();
let mut result = String::with_capacity(content.len());
let status_idx = lines.iter().position(|l| {
l.trim().eq_ignore_ascii_case("## Status") || l.trim().eq_ignore_ascii_case("## STATUS")
});
let Some(status_idx) = status_idx else {
return Ok(content.to_string());
};
let next_heading_idx = lines[status_idx + 1..]
.iter()
.position(|l| l.starts_with("## "))
.map(|i| i + status_idx + 1);
for line in &lines[..=status_idx] {
result.push_str(line);
result.push('\n');
}
result.push('\n');
result.push_str(&adr.status.to_string());
result.push('\n');
let link_titles = self.resolve_link_titles(adr);
for link in &adr.links {
result.push('\n');
if let Some((title, filename)) = link_titles.get(&link.target) {
result.push_str(&format!(
"{} [{}. {}]({})",
link.kind, link.target, title, filename
));
} else {
result.push_str(&format!(
"{} [{}. ...]({:04}-....md)",
link.kind, link.target, link.target
));
}
result.push('\n');
}
if let Some(next_idx) = next_heading_idx {
result.push('\n');
for (i, line) in lines[next_idx..].iter().enumerate() {
result.push_str(line);
if next_idx + i < lines.len() - 1 || content.ends_with('\n') {
result.push('\n');
}
}
} else if content.ends_with('\n') {
}
Ok(result)
}
fn update_body_sections(&self, content: &str, patch: &BodySectionPatch) -> Result<String> {
let lines: Vec<&str> = content.lines().collect();
let mut result = String::with_capacity(content.len());
let mut i = 0;
let mut found_context = patch.context.is_none();
let mut found_decision = patch.decision.is_none();
let mut found_consequences = patch.consequences.is_none();
while i < lines.len() {
let line = lines[i];
if Self::is_h2_outside_fence(&lines, i)
&& let Some(heading_text) = line.strip_prefix("## ")
&& let Some(field) = crate::parse::canonical_section_field(heading_text.trim())
{
result.push_str(line);
result.push('\n');
i += 1;
let body_end = Self::next_h2_index(&lines, i);
match field {
"context" => {
if patch.context.is_some() {
found_context = true;
}
if let Some(ref text) = patch.context {
Self::write_section_body(&mut result, text);
} else {
Self::append_lines(&mut result, &lines, i, body_end, content);
}
}
"decision" => {
let madr_decision_outcome =
heading_text.trim().eq_ignore_ascii_case("Decision Outcome");
let (decision_found, consequences_applied) = Self::patch_decision_section(
&mut result,
&lines,
content,
i,
body_end,
patch,
madr_decision_outcome,
);
if decision_found {
found_decision = true;
}
if consequences_applied {
found_consequences = true;
}
}
"consequences" => {
if patch.consequences.is_some() {
found_consequences = true;
}
if let Some(ref text) = patch.consequences {
Self::write_section_body(&mut result, text);
} else {
Self::append_lines(&mut result, &lines, i, body_end, content);
}
}
_ => {
Self::append_lines(&mut result, &lines, i, body_end, content);
}
}
i = body_end;
continue;
}
result.push_str(line);
if i < lines.len() - 1 || content.ends_with('\n') {
result.push('\n');
}
i += 1;
}
if !found_context {
return Err(Error::InvalidFormat {
path: PathBuf::new(),
reason: "context patch requested but no matching section heading found".into(),
});
}
if !found_decision {
return Err(Error::InvalidFormat {
path: PathBuf::new(),
reason: "decision patch requested but no matching section heading found".into(),
});
}
if !found_consequences {
return Err(Error::InvalidFormat {
path: PathBuf::new(),
reason: "consequences patch requested but no matching section heading found".into(),
});
}
if content.ends_with('\n') && !result.ends_with('\n') {
result.push('\n');
}
Ok(result)
}
fn fence_run(line: &str) -> Option<(char, usize)> {
let indent = line.chars().take_while(|c| *c == ' ').count();
if indent >= 4 {
return None;
}
let rest = &line[indent..];
let ch = rest.chars().next()?;
if ch != '`' && ch != '~' {
return None;
}
let run = rest.chars().take_while(|c| *c == ch).count();
if run < 3 {
return None;
}
Some((ch, run))
}
fn is_fence_close(line: &str, ch: char, open_len: usize) -> bool {
let Some((close_ch, run)) = Self::fence_run(line) else {
return false;
};
if close_ch != ch || run < open_len {
return false;
}
let indent = line.chars().take_while(|c| *c == ' ').count();
let after_run = &line[indent + run..];
after_run.chars().all(|c| c == ' ' || c == '\t')
}
fn in_fence_at_line(lines: &[&str], index: usize) -> bool {
let mut open: Option<(char, usize)> = None;
for line in &lines[..index] {
match open {
None => {
if let Some((ch, run)) = Self::fence_run(line) {
open = Some((ch, run));
}
}
Some((ch, open_len)) => {
if Self::is_fence_close(line, ch, open_len) {
open = None;
}
}
}
}
open.is_some()
}
fn is_h2_outside_fence(lines: &[&str], index: usize) -> bool {
lines[index].starts_with("## ") && !Self::in_fence_at_line(lines, index)
}
fn is_h3_outside_fence(lines: &[&str], index: usize) -> bool {
lines[index].starts_with("### ") && !Self::in_fence_at_line(lines, index)
}
fn next_h2_index(lines: &[&str], start: usize) -> usize {
lines[start..]
.iter()
.enumerate()
.find(|(offset, _)| Self::is_h2_outside_fence(lines, start + offset))
.map(|(offset, _)| start + offset)
.unwrap_or(lines.len())
}
fn append_lines(result: &mut String, lines: &[&str], start: usize, end: usize, content: &str) {
for (offset, line) in lines[start..end].iter().enumerate() {
result.push_str(line);
if start + offset < end - 1 || end < lines.len() || content.ends_with('\n') {
result.push('\n');
}
}
}
fn write_section_body(result: &mut String, text: &str) {
result.push('\n');
result.push_str(text);
if !text.ends_with('\n') {
result.push('\n');
}
}
fn is_consequences_h3(line: &str) -> bool {
line.strip_prefix("### ")
.is_some_and(|title| title.trim().eq_ignore_ascii_case("consequences"))
}
fn is_consequences_h2(line: &str) -> bool {
line.strip_prefix("## ")
.is_some_and(|title| title.trim().eq_ignore_ascii_case("consequences"))
}
fn patch_decision_section(
result: &mut String,
lines: &[&str],
content: &str,
body_start: usize,
body_end: usize,
patch: &BodySectionPatch,
madr_decision_outcome: bool,
) -> (bool, bool) {
if patch.decision.is_none() && patch.consequences.is_none() {
Self::append_lines(result, lines, body_start, body_end, content);
return (false, false);
}
let mut consequences_applied = false;
if patch.decision.is_none()
&& patch.consequences.is_some()
&& Self::has_consequences_h2(lines)
{
Self::append_lines(result, lines, body_start, body_end, content);
return (true, false);
}
let body = &lines[body_start..body_end];
let first_h3 = body
.iter()
.enumerate()
.find(|(offset, _)| Self::is_h3_outside_fence(lines, body_start + offset))
.map(|(offset, _)| body_start + offset);
let intro_end = first_h3.unwrap_or(body_end);
if let Some(ref text) = patch.decision {
Self::write_section_body(result, text);
} else {
Self::append_lines(result, lines, body_start, intro_end, content);
}
if intro_end >= body_end {
if let Some(ref text) = patch.consequences
&& !Self::has_consequences_h2(lines)
&& madr_decision_outcome
{
Self::write_madr_consequences_subsection(result, text);
consequences_applied = true;
}
return (true, consequences_applied);
}
let mut j = intro_end;
while j < body_end {
if !Self::is_h3_outside_fence(lines, j) {
j += 1;
continue;
}
let sub_start = j;
let sub_end = lines[sub_start + 1..body_end]
.iter()
.enumerate()
.find(|(offset, _)| {
let idx = sub_start + 1 + offset;
Self::is_h2_outside_fence(lines, idx) || Self::is_h3_outside_fence(lines, idx)
})
.map(|(offset, _)| sub_start + 1 + offset)
.unwrap_or(body_end);
if Self::is_consequences_h3(lines[sub_start])
&& let Some(ref text) = patch.consequences
{
consequences_applied = true;
result.push_str(lines[sub_start]);
result.push('\n');
Self::write_section_body(result, text);
j = sub_end;
continue;
}
Self::append_lines(result, lines, sub_start, sub_end, content);
j = sub_end;
}
if let Some(ref text) = patch.consequences
&& !consequences_applied
&& !Self::has_consequences_h2(lines)
&& madr_decision_outcome
{
Self::write_madr_consequences_subsection(result, text);
consequences_applied = true;
}
(true, consequences_applied)
}
fn has_consequences_h2(lines: &[&str]) -> bool {
lines.iter().enumerate().any(|(idx, line)| {
Self::is_h2_outside_fence(lines, idx) && Self::is_consequences_h2(line)
})
}
fn write_madr_consequences_subsection(result: &mut String, text: &str) {
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
result.push_str("### Consequences\n");
Self::write_section_body(result, text);
}
fn yaml_str_key(key: &str) -> Value {
Value::String(key.to_string())
}
fn set_yaml_sequence_field<T: serde::Serialize>(
map: &mut Mapping,
key: &str,
values: &[T],
) -> Result<bool> {
let key = Self::yaml_str_key(key);
if values.is_empty() {
return Ok(map.remove(&key).is_some());
}
let desired = serde_yaml_neo::to_value(values)?;
if map.get(&key) == Some(&desired) {
return Ok(false);
}
map.insert(key, desired);
Ok(true)
}
fn set_yaml_string_list_field(map: &mut Mapping, key: &str, values: &[String]) -> Result<bool> {
Self::set_yaml_sequence_field(map, key, values)
}
fn yaml_string_list_matches(map: &Mapping, key: &str, desired: &[String]) -> bool {
match map.get(Self::yaml_str_key(key)) {
None | Some(Value::Null) => desired.is_empty(),
Some(Value::String(s)) => desired.len() == 1 && desired[0] == *s,
Some(Value::Sequence(seq)) => {
let got: Option<Vec<&str>> = seq.iter().map(|v| v.as_str()).collect();
match got {
Some(got) => got == desired.iter().map(String::as_str).collect::<Vec<_>>(),
None => false,
}
}
Some(_) => false,
}
}
}
fn count_existing_adrs(path: &Path) -> usize {
if !path.is_dir() {
return 0;
}
fs::read_dir(path)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| {
let path = e.path();
path.is_file()
&& path.extension().is_some_and(|ext| ext == "md")
&& path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
})
})
.count()
})
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_init_repository() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
assert!(repo.adr_path().exists());
assert!(temp.path().join(".adr-dir").exists());
let adrs = repo.list().unwrap();
assert_eq!(adrs.len(), 1);
assert_eq!(adrs[0].number, 1);
assert_eq!(adrs[0].title, "Record architecture decisions");
}
#[test]
fn test_init_repository_ng() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
assert!(temp.path().join("adrs.toml").exists());
assert!(repo.config().is_next_gen());
}
#[test]
fn test_init_repository_custom_dir() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
assert!(temp.path().join("decisions").exists());
assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
}
#[test]
fn test_init_repository_nested_dir() {
let temp = TempDir::new().unwrap();
let _repo =
Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
assert!(temp.path().join("docs/architecture/adr").exists());
}
#[test]
fn test_init_repository_already_exists_skips_initial_adr() {
let temp = TempDir::new().unwrap();
Repository::init(temp.path(), None, false).unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adrs = repo.list().unwrap();
assert_eq!(adrs.len(), 1); }
#[test]
fn test_init_with_existing_adrs_skips_initial() {
let temp = TempDir::new().unwrap();
let adr_dir = temp.path().join("doc/adr");
fs::create_dir_all(&adr_dir).unwrap();
fs::write(
adr_dir.join("0001-existing-decision.md"),
"# 1. Existing Decision\n\nDate: 2024-01-01\n\n## Status\n\nAccepted\n\n## Context\n\nTest\n\n## Decision\n\nTest\n\n## Consequences\n\nTest\n",
)
.unwrap();
fs::write(
adr_dir.join("0002-another-decision.md"),
"# 2. Another Decision\n\nDate: 2024-01-02\n\n## Status\n\nAccepted\n\n## Context\n\nTest\n\n## Decision\n\nTest\n\n## Consequences\n\nTest\n",
)
.unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adrs = repo.list().unwrap();
assert_eq!(adrs.len(), 2); assert_eq!(adrs[0].title, "Existing Decision");
assert_eq!(adrs[1].title, "Another Decision");
}
#[test]
fn test_init_creates_first_adr() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr = repo.get(1).unwrap();
assert_eq!(adr.title, crate::init_adr::TITLE);
assert_eq!(adr.status, AdrStatus::Accepted);
assert_eq!(adr.context, crate::init_adr::CONTEXT);
assert_eq!(adr.decision, crate::init_adr::DECISION);
assert_eq!(adr.consequences, crate::init_adr::CONSEQUENCES);
}
#[test]
fn test_init_first_adr_has_markdown_links_in_both_modes() {
for ng in [false, true] {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, ng).unwrap();
let path = repo
.adr_path()
.join("0001-record-architecture-decisions.md");
let content = fs::read_to_string(path).unwrap();
assert!(
content.contains(
"[Documenting Architecture Decisions](https://www.cognitect.com/blog/2011/11/15/documenting-architecture-decisions)"
),
"expected Nygard article link (ng={ng})"
);
assert!(
content.contains("[adrs](https://github.com/joshrotenberg/adrs)"),
"expected adrs link (ng={ng})"
);
assert!(
content.contains("[adr-tools](https://github.com/npryce/adr-tools)"),
"expected adr-tools link (ng={ng})"
);
assert!(
content.ends_with('\n'),
"init ADR should end with a newline (ng={ng})"
);
}
}
#[test]
fn test_open_repository() {
let temp = TempDir::new().unwrap();
Repository::init(temp.path(), None, false).unwrap();
let repo = Repository::open(temp.path()).unwrap();
assert_eq!(repo.list().unwrap().len(), 1);
}
#[test]
fn test_open_repository_not_found() {
let temp = TempDir::new().unwrap();
let result = Repository::open(temp.path());
assert!(result.is_err());
}
#[test]
fn test_open_or_default() {
let temp = TempDir::new().unwrap();
let repo = Repository::open_or_default(temp.path());
assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
}
#[test]
fn test_open_or_default_existing() {
let temp = TempDir::new().unwrap();
Repository::init(temp.path(), Some("custom".into()), false).unwrap();
let repo = Repository::open_or_default(temp.path());
assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
}
#[test]
fn test_create_and_list() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let (adr, _) = repo.new_adr("Use Rust").unwrap();
assert_eq!(adr.number, 2);
let adrs = repo.list().unwrap();
assert_eq!(adrs.len(), 2);
}
#[test]
fn test_create_multiple() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("Second").unwrap();
repo.new_adr("Third").unwrap();
repo.new_adr("Fourth").unwrap();
let adrs = repo.list().unwrap();
assert_eq!(adrs.len(), 4);
assert_eq!(adrs[0].number, 1);
assert_eq!(adrs[1].number, 2);
assert_eq!(adrs[2].number, 3);
assert_eq!(adrs[3].number, 4);
}
#[test]
fn test_list_sorted_by_number() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("B").unwrap();
repo.new_adr("A").unwrap();
repo.new_adr("C").unwrap();
let adrs = repo.list().unwrap();
assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
}
#[test]
fn test_next_number() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
assert_eq!(repo.next_number().unwrap(), 2);
repo.new_adr("Second").unwrap();
assert_eq!(repo.next_number().unwrap(), 3);
}
#[test]
fn test_create_file_exists() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let (_, path) = repo.new_adr("Test ADR").unwrap();
assert!(path.exists());
assert!(path.to_string_lossy().contains("0002-test-adr.md"));
}
#[test]
fn test_new_adr_uses_custom_default_status_from_config() {
let temp = TempDir::new().unwrap();
Repository::init(temp.path(), None, false).unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "doc/adr"
mode = "compatible"
default_status = "draft"
"#,
)
.unwrap();
let repo = Repository::open(temp.path()).unwrap();
let (adr, _) = repo.new_adr("Custom status ADR").unwrap();
assert_eq!(adr.status, AdrStatus::Custom("draft".into()));
}
#[test]
fn test_get_by_number() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("Second").unwrap();
let adr = repo.get(2).unwrap();
assert_eq!(adr.title, "Second");
}
#[test]
fn test_get_not_found() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let result = repo.get(99);
assert!(result.is_err());
}
#[test]
fn test_find_by_number() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr = repo.find("1").unwrap();
assert_eq!(adr.number, 1);
}
#[test]
fn test_find_by_title() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr = repo.find("architecture").unwrap();
assert_eq!(adr.number, 1);
}
#[test]
fn test_find_fuzzy_match() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("Use PostgreSQL for database").unwrap();
repo.new_adr("Use Redis for caching").unwrap();
let adr = repo.find("postgres").unwrap();
assert!(adr.title.contains("PostgreSQL"));
}
#[test]
fn test_find_not_found() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let result = repo.find("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_supersede() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
assert_eq!(new_adr.number, 2);
assert_eq!(new_adr.links.len(), 1);
assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
let old_adr = repo.get(1).unwrap();
assert_eq!(old_adr.status, AdrStatus::Superseded);
}
#[test]
fn test_supersede_creates_bidirectional_links() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.supersede("New approach", 1).unwrap();
let old_adr = repo.get(1).unwrap();
assert_eq!(old_adr.links.len(), 1);
assert_eq!(old_adr.links[0].target, 2);
assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
let new_adr = repo.get(2).unwrap();
assert_eq!(new_adr.links.len(), 1);
assert_eq!(new_adr.links[0].target, 1);
assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
}
#[test]
fn test_supersede_not_found() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let result = repo.supersede("New", 99);
assert!(result.is_err());
}
#[test]
fn test_supersede_generates_functional_links() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("Use MySQL for persistence").unwrap();
repo.supersede("Use PostgreSQL instead", 2).unwrap();
let new_content =
fs::read_to_string(repo.adr_path().join("0003-use-postgresql-instead.md")).unwrap();
assert!(
new_content.contains(
"Supersedes [2. Use MySQL for persistence](0002-use-mysql-for-persistence.md)"
),
"New ADR should have functional Supersedes link. Got:\n{new_content}"
);
let old_content =
fs::read_to_string(repo.adr_path().join("0002-use-mysql-for-persistence.md")).unwrap();
assert!(
old_content.contains(
"Superseded by [3. Use PostgreSQL instead](0003-use-postgresql-instead.md)"
),
"Old ADR should have functional Superseded by link. Got:\n{old_content}"
);
}
#[test]
fn test_link_generates_functional_links() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("Use REST API").unwrap();
repo.new_adr("Use JSON for API responses").unwrap();
repo.link(3, 2, LinkKind::Amends, LinkKind::AmendedBy)
.unwrap();
let source_content =
fs::read_to_string(repo.adr_path().join("0003-use-json-for-api-responses.md")).unwrap();
assert!(
source_content.contains("Amends [2. Use REST API](0002-use-rest-api.md)"),
"Source ADR should have functional Amends link. Got:\n{source_content}"
);
let target_content =
fs::read_to_string(repo.adr_path().join("0002-use-rest-api.md")).unwrap();
assert!(
target_content.contains(
"Amended by [3. Use JSON for API responses](0003-use-json-for-api-responses.md)"
),
"Target ADR should have functional Amended by link. Got:\n{target_content}"
);
}
#[test]
fn test_set_status_superseded_generates_functional_link() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("First Decision").unwrap();
repo.new_adr("Second Decision").unwrap();
repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
let content = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
assert!(
content.contains("Superseded by [3. Second Decision](0003-second-decision.md)"),
"ADR should have functional Superseded by link. Got:\n{content}"
);
}
#[test]
fn test_supersede_chain_generates_functional_links() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("Use SQLite").unwrap();
repo.supersede("Use PostgreSQL", 2).unwrap();
repo.supersede("Use CockroachDB", 3).unwrap();
let adr3_content =
fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
assert!(
adr3_content.contains("Supersedes [2. Use SQLite](0002-use-sqlite.md)"),
"ADR 3 should supersede ADR 2. Got:\n{adr3_content}"
);
assert!(
adr3_content.contains("Superseded by [4. Use CockroachDB](0004-use-cockroachdb.md)"),
"ADR 3 should be superseded by ADR 4. Got:\n{adr3_content}"
);
}
#[test]
fn test_ng_mode_supersede_generates_functional_links() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
repo.new_adr("Use MySQL").unwrap();
repo.supersede("Use PostgreSQL", 2).unwrap();
let new_content =
fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
assert!(
new_content.contains("Supersedes [2. Use MySQL](0002-use-mysql.md)"),
"NG mode should have functional link in body. Got:\n{new_content}"
);
assert!(new_content.contains("links:"));
assert!(new_content.contains("target: 2"));
}
#[test]
fn test_set_status_accepted() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("Test Decision").unwrap();
repo.set_status(2, AdrStatus::Accepted, None).unwrap();
let adr = repo.get(2).unwrap();
assert_eq!(adr.status, AdrStatus::Accepted);
}
#[test]
fn test_set_status_deprecated() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("Old Decision").unwrap();
repo.set_status(2, AdrStatus::Deprecated, None).unwrap();
let adr = repo.get(2).unwrap();
assert_eq!(adr.status, AdrStatus::Deprecated);
}
#[test]
fn test_set_status_superseded_with_link() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("First Decision").unwrap();
repo.new_adr("Second Decision").unwrap();
repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
let adr = repo.get(2).unwrap();
assert_eq!(adr.status, AdrStatus::Superseded);
assert_eq!(adr.links.len(), 1);
assert_eq!(adr.links[0].target, 3);
assert_eq!(adr.links[0].kind, LinkKind::SupersededBy);
}
#[test]
fn test_set_status_superseded_without_link() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("Decision").unwrap();
repo.set_status(2, AdrStatus::Superseded, None).unwrap();
let adr = repo.get(2).unwrap();
assert_eq!(adr.status, AdrStatus::Superseded);
assert_eq!(adr.links.len(), 0);
}
#[test]
fn test_set_status_custom() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("Test Decision").unwrap();
repo.set_status(2, AdrStatus::Custom("Draft".into()), None)
.unwrap();
let adr = repo.get(2).unwrap();
assert_eq!(adr.status, AdrStatus::Custom("Draft".into()));
}
#[test]
fn test_set_status_adr_not_found() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let result = repo.set_status(99, AdrStatus::Accepted, None);
assert!(result.is_err());
}
#[test]
fn test_set_status_superseded_by_not_found() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("Decision").unwrap();
let result = repo.set_status(2, AdrStatus::Superseded, Some(99));
assert!(result.is_err());
}
#[test]
fn test_link_adrs() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("Second").unwrap();
repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
.unwrap();
let adr1 = repo.get(1).unwrap();
assert_eq!(adr1.links.len(), 1);
assert_eq!(adr1.links[0].target, 2);
assert_eq!(adr1.links[0].kind, LinkKind::Amends);
let adr2 = repo.get(2).unwrap();
assert_eq!(adr2.links.len(), 1);
assert_eq!(adr2.links[0].target, 1);
assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
}
#[test]
fn test_link_relates_to() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
repo.new_adr("Second").unwrap();
repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
.unwrap();
let adr1 = repo.get(1).unwrap();
assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
let adr2 = repo.get(2).unwrap();
assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
}
#[test]
fn test_update_adr() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let mut adr = repo.get(1).unwrap();
adr.status = AdrStatus::Deprecated;
repo.update(&adr, BodySectionPatch::default()).unwrap();
let updated = repo.get(1).unwrap();
assert_eq!(updated.status, AdrStatus::Deprecated);
}
#[test]
fn test_update_preserves_content() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let mut adr = repo.get(1).unwrap();
let original_title = adr.title.clone();
adr.status = AdrStatus::Deprecated;
repo.update(&adr, BodySectionPatch::default()).unwrap();
let updated = repo.get(1).unwrap();
assert_eq!(updated.title, original_title);
}
#[test]
fn test_read_content() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr = repo.get(1).unwrap();
let content = repo.read_content(&adr).unwrap();
assert!(content.contains("Record architecture decisions"));
assert!(content.contains("## Status"));
}
#[test]
fn test_write_content() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr = repo.get(1).unwrap();
let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
repo.write_content(&adr, new_content).unwrap();
let content = repo.read_content(&adr).unwrap();
assert!(content.contains("Modified"));
}
#[test]
fn test_with_mode_overrides_compatible_to_ng() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false)
.unwrap()
.with_mode(ConfigMode::NextGen);
let (_, path) = repo.new_adr("Mode Override Test").unwrap();
let content = fs::read_to_string(path).unwrap();
assert!(
content.starts_with("---\n"),
"with_mode(NextGen) on compatible repo should produce YAML frontmatter. Got:\n{content}"
);
assert!(content.contains("status: proposed"));
}
#[test]
fn test_with_mode_ng_to_compatible() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true)
.unwrap()
.with_mode(ConfigMode::Compatible);
let (_, path) = repo.new_adr("Downgrade Mode Test").unwrap();
let content = fs::read_to_string(path).unwrap();
assert!(
!content.starts_with("---\n"),
"with_mode(Compatible) on ng repo should NOT produce YAML frontmatter. Got:\n{content}"
);
}
#[test]
fn test_with_template_format() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false)
.unwrap()
.with_template_format(TemplateFormat::Madr);
let (_, path) = repo.new_adr("MADR Test").unwrap();
let content = fs::read_to_string(path).unwrap();
assert!(content.contains("Context and Problem Statement"));
}
#[test]
fn test_with_custom_template() {
let temp = TempDir::new().unwrap();
let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
let repo = Repository::init(temp.path(), None, false)
.unwrap()
.with_custom_template(custom);
let (_, path) = repo.new_adr("Custom Test").unwrap();
let content = fs::read_to_string(path).unwrap();
assert_eq!(content, "# ADR 2: Custom Test\n");
}
#[test]
fn test_root() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
assert_eq!(repo.root(), temp.path());
}
#[test]
fn test_config() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
assert!(repo.config().is_next_gen());
}
#[test]
fn test_adr_path() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
}
#[test]
fn test_ng_mode_creates_frontmatter() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let (_, path) = repo.new_adr("NG Test").unwrap();
let content = fs::read_to_string(path).unwrap();
assert!(content.starts_with("---"));
assert!(content.contains("number: 2"));
assert!(content.contains("title: NG Test"));
}
#[test]
fn test_ng_mode_parses_frontmatter() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
repo.new_adr("NG ADR").unwrap();
let adr = repo.get(2).unwrap();
assert_eq!(adr.title, "NG ADR");
assert_eq!(adr.number, 2);
}
#[test]
fn test_list_empty_after_init_removal() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
fs::remove_file(
repo.adr_path()
.join("0001-record-architecture-decisions.md"),
)
.unwrap();
let adrs = repo.list().unwrap();
assert!(adrs.is_empty());
}
#[test]
fn test_list_ignores_non_adr_files() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
let adrs = repo.list().unwrap();
assert_eq!(adrs.len(), 1); }
#[test]
fn test_special_characters_in_title() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
assert!(path.exists());
assert_eq!(adr.title, "Use C++ & Rust!");
}
#[test]
fn test_set_status_preserves_madr_body() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let madr_content = r#"---
number: 2
title: Use Redis for caching
date: 2026-01-15
status: proposed
---
# Use Redis for caching
## Context and Problem Statement
We need a **fast** caching layer for our [API](https://api.example.com).
## Considered Options
* Redis
* Memcached
* In-memory cache
## Decision Outcome
Chosen option: "Redis", because it supports data structures beyond simple key-value.
### Consequences
* Good, because it provides pub/sub
* Bad, because it adds operational complexity
## Pros and Cons of the Options
### Redis
* Good, because it supports complex data types
* Bad, because it requires a separate server
### Memcached
* Good, because it's simpler
* Bad, because it only supports strings
"#;
let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
fs::write(&adr_path, madr_content).unwrap();
repo.set_status(2, AdrStatus::Accepted, None).unwrap();
let result = fs::read_to_string(&adr_path).unwrap();
assert!(result.contains("status: accepted"));
assert!(!result.contains("status: proposed"));
let body_start = result.find("\n# Use Redis").unwrap();
let original_body_start = madr_content.find("\n# Use Redis").unwrap();
assert_eq!(
&result[body_start..],
&madr_content[original_body_start..],
"Body content was modified"
);
}
#[test]
fn test_set_status_via_mapping_preserves_unknown_keys_and_body() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content_with_comments = r#"---
# SPDX-License-Identifier: MIT
number: 2
title: Use MADR format
date: 2026-01-15
status: proposed
custom-meta: keep-me
---
## Context and Problem Statement
We need a standard ADR format.
## Decision Outcome
Use MADR 4.0.0.
"#;
let adr_path = repo.adr_path().join("0002-use-madr-format.md");
fs::write(&adr_path, content_with_comments).unwrap();
repo.set_status(2, AdrStatus::Accepted, None).unwrap();
let result = fs::read_to_string(&adr_path).unwrap();
assert!(result.contains("status: accepted"));
assert!(
result.contains("custom-meta: keep-me"),
"unknown frontmatter key was dropped\n{result}"
);
assert!(
result.contains("## Decision Outcome") && result.contains("Use MADR 4.0.0."),
"markdown body must survive\n{result}"
);
}
#[test]
fn test_set_status_preserves_markdown_links() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Use PostgreSQL
date: 2026-01-15
status: proposed
---
## Context
See the [PostgreSQL docs](https://www.postgresql.org/docs/) for details.
Also see [RFC 7159](https://tools.ietf.org/html/rfc7159) and `inline code`.
## Decision
We will use **PostgreSQL** version `16.x`.
## Consequences
- [Monitoring guide](https://example.com/monitoring)
- Performance benchmarks in [this report](./benchmarks.md)
"#;
let adr_path = repo.adr_path().join("0002-use-postgresql.md");
fs::write(&adr_path, content).unwrap();
repo.set_status(2, AdrStatus::Accepted, None).unwrap();
let result = fs::read_to_string(&adr_path).unwrap();
assert!(result.contains("[PostgreSQL docs](https://www.postgresql.org/docs/)"));
assert!(result.contains("[RFC 7159](https://tools.ietf.org/html/rfc7159)"));
assert!(result.contains("`inline code`"));
assert!(result.contains("**PostgreSQL**"));
assert!(result.contains("[Monitoring guide](https://example.com/monitoring)"));
assert!(result.contains("[this report](./benchmarks.md)"));
}
#[test]
fn test_link_preserves_body_content() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content_1 = r#"---
number: 2
title: First decision
date: 2026-01-15
status: accepted
---
## Context
Custom context with **bold** and [links](https://example.com).
## Decision
A detailed decision paragraph.
## Consequences
- Important consequence 1
- Important consequence 2
"#;
let content_2 = r#"---
number: 3
title: Second decision
date: 2026-01-16
status: accepted
---
## Context
Different context entirely.
## Decision
Another decision.
## Consequences
None significant.
"#;
fs::write(repo.adr_path().join("0002-first-decision.md"), content_1).unwrap();
fs::write(repo.adr_path().join("0003-second-decision.md"), content_2).unwrap();
repo.link(2, 3, LinkKind::Amends, LinkKind::AmendedBy)
.unwrap();
let result_1 = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
let result_2 = fs::read_to_string(repo.adr_path().join("0003-second-decision.md")).unwrap();
assert!(result_1.contains("Custom context with **bold** and [links](https://example.com)"));
assert!(result_1.contains("A detailed decision paragraph."));
assert!(result_2.contains("Different context entirely."));
assert!(result_2.contains("None significant."));
assert!(result_1.contains("links:"));
assert!(result_1.contains("target: 3"));
assert!(result_2.contains("links:"));
assert!(result_2.contains("target: 2"));
}
#[test]
fn test_supersede_preserves_old_adr_body() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let rich_content = r#"---
number: 2
title: Original approach
date: 2026-01-15
status: accepted
---
## Context and Problem Statement
This has **rich** markdown with [links](https://example.com).
```rust
fn important_code() -> bool {
true
}
```
## Decision Outcome
We chose the original approach.
| Criteria | Score |
|----------|-------|
| Speed | 9/10 |
| Safety | 8/10 |
"#;
fs::write(
repo.adr_path().join("0002-original-approach.md"),
rich_content,
)
.unwrap();
repo.supersede("Better approach", 2).unwrap();
let old_content =
fs::read_to_string(repo.adr_path().join("0002-original-approach.md")).unwrap();
assert!(old_content.contains("```rust"));
assert!(old_content.contains("fn important_code()"));
assert!(old_content.contains("| Criteria | Score |"));
assert!(old_content.contains("[links](https://example.com)"));
assert!(old_content.contains("status: superseded"));
assert!(old_content.contains("target: 3"));
}
#[test]
fn test_set_status_legacy_preserves_sections() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let legacy_content = r#"# 2. Use Rust for backend
Date: 2026-01-15
## Status
Proposed
## Context
We need a fast, safe language for our backend services.
See the [Rust book](https://doc.rust-lang.org/book/) for details.
## Decision
We will use **Rust** with the `tokio` runtime.
```toml
[dependencies]
tokio = { version = "1", features = ["full"] }
```
## Consequences
- Type safety prevents many bugs at compile time
- Learning curve for team members
"#;
let adr_path = repo.adr_path().join("0002-use-rust-for-backend.md");
fs::write(&adr_path, legacy_content).unwrap();
repo.set_status(2, AdrStatus::Accepted, None).unwrap();
let result = fs::read_to_string(&adr_path).unwrap();
assert!(result.contains("Accepted"));
assert!(result.contains("[Rust book](https://doc.rust-lang.org/book/)"));
assert!(result.contains("**Rust**"));
assert!(result.contains("`tokio`"));
assert!(result.contains("```toml"));
assert!(result.contains("tokio = { version = \"1\", features = [\"full\"] }"));
assert!(result.contains("Type safety prevents many bugs"));
}
#[test]
fn test_set_status_frontmatter_with_existing_links() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Updated approach
date: 2026-01-15
status: proposed
links:
- target: 1
kind: amends
---
## Context
Context.
## Decision
Decision.
"#;
let adr_path = repo.adr_path().join("0002-updated-approach.md");
fs::write(&adr_path, content).unwrap();
repo.set_status(2, AdrStatus::Accepted, None).unwrap();
let result = fs::read_to_string(&adr_path).unwrap();
assert!(result.contains("status: accepted"));
assert!(result.contains("links:"));
assert!(result.contains("target: 1"));
assert!(result.contains("kind: amends"));
assert!(
!result.contains("\n\n---"),
"Should not have extra blank line before closing ---: {:?}",
result
);
}
#[test]
fn test_set_status_no_extra_newline_before_separator() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = "---\nnumber: 2\ntitle: Test\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
let adr_path = repo.adr_path().join("0002-test.md");
fs::write(&adr_path, content).unwrap();
repo.set_status(2, AdrStatus::Accepted, None).unwrap();
let result = fs::read_to_string(&adr_path).unwrap();
assert!(result.contains("status: accepted"));
assert!(
result.contains("\n---\n"),
"Should have clean closing separator: {:?}",
result
);
assert!(
!result.contains("\n\n---"),
"Should not have extra blank line before closing ---: {:?}",
result
);
}
#[test]
fn test_set_status_rejects_empty_custom() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = "---\nnumber: 2\ntitle: Test\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
let adr_path = repo.adr_path().join("0002-test.md");
fs::write(&adr_path, content).unwrap();
for bad in ["", " ", " ", "\t"] {
let err = repo
.set_status(2, AdrStatus::Custom(bad.to_string()), None)
.unwrap_err();
assert!(
matches!(err, Error::InvalidStatus(_)),
"expected InvalidStatus for {:?}, got {:?}",
bad,
err
);
}
let result = fs::read_to_string(&adr_path).unwrap();
assert!(result.contains("status: proposed"));
assert!(repo.get(2).is_ok());
}
#[test]
fn test_update_madr_content_preserves_unmodified_sections() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let madr_content = r#"---
number: 2
title: Use Redis for caching
date: 2026-01-15
status: proposed
---
# Use Redis for caching
## Context and Problem Statement
Original context about caching needs.
## Considered Options
* Redis
* Memcached
## Decision Outcome
Chosen option: "Redis", because it supports data structures beyond simple key-value.
### Consequences
* Good, because it provides pub/sub
"#;
let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
fs::write(&adr_path, madr_content).unwrap();
let mut adr = repo.get(2).unwrap();
adr.context = "Updated context text.".into();
repo.update(
&adr,
BodySectionPatch {
context: Some("Updated context text.".into()),
..Default::default()
},
)
.unwrap();
let result = fs::read_to_string(&adr_path).unwrap();
assert!(result.contains("## Context and Problem Statement"));
assert!(result.contains("Updated context text."));
assert!(result.contains("## Considered Options"));
assert!(result.contains("* Memcached"));
assert!(result.contains("## Decision Outcome"));
assert!(result.contains("Chosen option: \"Redis\""));
assert!(result.contains("### Consequences"));
assert!(result.contains("* Good, because it provides pub/sub"));
assert!(!result.contains("What is the change that we're proposing"));
assert!(!result.contains("## Context\n"));
assert!(!result.contains("## Decision\n"));
}
#[test]
fn test_update_madr_context_preserves_decision_h3_subsections() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let madr_content = r#"---
number: 2
title: Use Redis
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
Original context.
## Decision Outcome
Chosen option: "Redis", because it is fast.
### Consequences
* Good, because it provides pub/sub
* Bad, because it needs memory
### Confirmation
We will confirm via load tests.
"#;
let adr_path = repo.adr_path().join("0002-use-redis.md");
fs::write(&adr_path, madr_content).unwrap();
let mut adr = repo.get(2).unwrap();
adr.context = "Updated context only.".into();
repo.update(
&adr,
BodySectionPatch {
context: Some("Updated context only.".into()),
..Default::default()
},
)
.unwrap();
let result = fs::read_to_string(&adr_path).unwrap();
assert!(result.contains("Updated context only."));
assert!(result.contains("Chosen option: \"Redis\", because it is fast."));
assert!(result.contains("### Consequences"));
assert!(result.contains("* Good, because it provides pub/sub"));
assert!(result.contains("* Bad, because it needs memory"));
assert!(result.contains("### Confirmation"));
assert!(result.contains("We will confirm via load tests."));
}
#[test]
fn test_update_madr_consequences_patches_h3_subsection() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let madr_content = r#"---
number: 2
title: Use Redis
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
Context.
## Decision Outcome
Chosen option: "Redis", because it is fast.
### Consequences
* Old consequence
"#;
let adr_path = repo.adr_path().join("0002-use-redis.md");
fs::write(&adr_path, madr_content).unwrap();
let mut adr = repo.get(2).unwrap();
adr.consequences = "* New consequence".into();
repo.update(
&adr,
BodySectionPatch {
consequences: Some("* New consequence".into()),
..Default::default()
},
)
.unwrap();
let result = fs::read_to_string(&adr_path).unwrap();
assert!(result.contains("Chosen option: \"Redis\", because it is fast."));
assert!(result.contains("### Consequences"));
assert!(result.contains("* New consequence"));
assert!(!result.contains("* Old consequence"));
}
#[test]
fn test_update_madr_content_round_trip_via_get() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let madr_content = r#"---
number: 2
title: Use PostgreSQL
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
We need a relational database.
## Decision Outcome
We will use PostgreSQL 16.
"#;
let adr_path = repo.adr_path().join("0002-use-postgresql.md");
fs::write(&adr_path, madr_content).unwrap();
let mut adr = repo.get(2).unwrap();
adr.context = "Updated context only.".into();
repo.update(
&adr,
BodySectionPatch {
context: Some("Updated context only.".into()),
..Default::default()
},
)
.unwrap();
let reloaded = repo.get(2).unwrap();
assert_eq!(reloaded.context, "Updated context only.");
assert_eq!(reloaded.decision, "We will use PostgreSQL 16.");
}
#[test]
fn test_update_metadata_adds_tags_to_frontmatter() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Tagged ADR
date: 2026-01-15
status: proposed
---
## Context
Context.
"#;
let adr_path = repo.adr_path().join("0002-tagged-adr.md");
fs::write(&adr_path, content).unwrap();
let mut adr = repo.get(2).unwrap();
adr.set_tags(vec!["security".into(), "api".into()]);
repo.update_metadata(&adr).unwrap();
let result = fs::read_to_string(&adr_path).unwrap();
assert!(result.contains("tags:"));
assert!(
result.contains("- security") && result.contains("- api"),
"tags missing from frontmatter\n{result}"
);
assert!(result.contains("## Context\n\nContext."));
}
#[test]
fn test_list_with_errors_all_valid() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
repo.new_adr("Valid ADR").unwrap();
let (adrs, errors) = repo.list_with_errors().unwrap();
assert_eq!(adrs.len(), 2); assert!(errors.is_empty());
}
#[test]
fn test_list_with_errors_captures_invalid_frontmatter() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let bad_content =
"---\nnumber: 2\nstatus: accepted\ndate: not-a-date\n---\n\n# 2. Bad ADR\n";
fs::write(repo.adr_path().join("0002-bad-adr.md"), bad_content).unwrap();
let (adrs, errors) = repo.list_with_errors().unwrap();
assert_eq!(adrs.len(), 1); assert_eq!(errors.len(), 1);
assert!(errors[0].0.to_string_lossy().contains("0002-bad-adr.md"));
}
#[test]
fn test_list_with_errors_mixed_valid_and_invalid() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
repo.new_adr("Good ADR").unwrap();
let bad_content = "---\n: :\n---\n\n# 3. Broken\n";
fs::write(repo.adr_path().join("0003-broken.md"), bad_content).unwrap();
let (adrs, errors) = repo.list_with_errors().unwrap();
assert_eq!(adrs.len(), 2); assert_eq!(errors.len(), 1); }
#[test]
fn test_list_with_errors_string_decision_makers_is_valid() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
status: accepted
date: 2026-03-18
decision-makers: mschoettle
---
# 2. Use Markdown Architectural Decision Records
"#;
fs::write(repo.adr_path().join("0002-use-markdown-adrs.md"), content).unwrap();
let (adrs, errors) = repo.list_with_errors().unwrap();
assert!(errors.is_empty(), "string decision-makers should parse");
assert_eq!(adrs.len(), 2);
let adr = adrs.iter().find(|a| a.number == 2).unwrap();
assert_eq!(adr.decision_makers, vec!["mschoettle"]);
}
#[test]
fn test_update_metadata_preserves_link_descriptions() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Linked ADR
date: 2026-01-15
status: proposed
links:
- target: 1
kind: relatesto
description: Explains the connection
---
## Context
Context.
"#;
let adr_path = repo.adr_path().join("0002-linked-adr.md");
fs::write(&adr_path, content).unwrap();
let adr = repo.get(2).unwrap();
assert_eq!(
adr.links[0].description.as_deref(),
Some("Explains the connection")
);
repo.update_metadata(&adr).unwrap();
let result = fs::read_to_string(&adr_path).unwrap();
assert!(result.contains("kind: relatesto"));
assert!(result.contains("description: Explains the connection"));
}
#[test]
fn test_update_metadata_kebab_case_kind_round_trips_verbatim() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Linked ADR
date: 2026-01-15
status: proposed
links:
- target: 1
kind: relates-to
---
## Context
Context.
"#;
let adr_path = repo.adr_path().join("0002-linked-adr.md");
fs::write(&adr_path, content).unwrap();
let adr = repo.get(2).unwrap();
assert_eq!(adr.links[0].kind, LinkKind::Custom("relates-to".into()));
repo.update_metadata(&adr).unwrap();
let result = fs::read_to_string(&adr_path).unwrap();
assert!(result.contains("kind: relates-to"));
}
#[test]
fn test_resolve_link_titles_uses_actual_filename_for_hand_named_target() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let target_content = "# 2. Use Rust for backend services\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nContext.\n";
fs::write(
repo.adr_path().join("0002-use-rust-for-backend.md"),
target_content,
)
.unwrap();
let mut source = repo.get(1).unwrap();
source.add_link(AdrLink::new(2, LinkKind::Amends));
let titles = repo.resolve_link_titles(&source);
let (title, filename) = titles.get(&2).unwrap();
assert_eq!(title, "Use Rust for backend services");
assert_eq!(filename, "0002-use-rust-for-backend.md");
}
#[test]
fn test_link_href_prefers_actual_path_over_slugified_title() {
let target = Adr {
path: Some(PathBuf::from("/repo/doc/adr/0003-use-rust-for-backend.md")),
..Adr::new(3, "Use Rust for backend services")
};
assert_eq!(
Repository::link_href(&target),
"0003-use-rust-for-backend.md"
);
}
#[test]
fn test_link_href_falls_back_to_slugified_title_when_path_missing() {
let target = Adr {
path: None,
..Adr::new(3, "Use Rust for backend services")
};
assert_eq!(
Repository::link_href(&target),
"0003-use-rust-for-backend-services.md"
);
}
fn extract_h2_block(content: &str, heading: &str) -> Option<String> {
let lines: Vec<&str> = content.lines().collect();
let marker = format!("## {heading}");
let start = lines.iter().position(|l| l.trim() == marker)?;
let end = lines[(start + 1)..]
.iter()
.position(|l| l.starts_with("## "))
.map(|p| start + 1 + p)
.unwrap_or(lines.len());
Some(lines[start..end].join("\n"))
}
fn assert_h2_block_unchanged(before: &str, after: &str, heading: &str) {
assert_eq!(
extract_h2_block(before, heading),
extract_h2_block(after, heading),
"section `{heading}` should be byte-identical"
);
}
#[test]
fn test_update_ng_nygard_rich_context_preserved_on_decision_patch() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Rich context ADR
date: 2026-01-15
status: proposed
---
## Context
We need caching. See the [Redis docs](https://redis.io).
* Requirement one
* Requirement two
Use `redis-cli` for debugging.
## Decision
Old decision.
## Consequences
Old consequences.
"#;
let adr_path = repo.adr_path().join("0002-rich-context-adr.md");
fs::write(&adr_path, content).unwrap();
let before = content.to_string();
let mut adr = repo.get(2).unwrap();
adr.decision = "New decision.".into();
repo.update(
&adr,
BodySectionPatch {
decision: Some("New decision.".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert_h2_block_unchanged(&before, &after, "Context");
assert_h2_block_unchanged(&before, &after, "Consequences");
assert!(after.contains("New decision."));
}
#[test]
fn test_update_madr_rich_context_preserved_on_consequences_patch() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Rich MADR context
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
We need caching. See the [Redis docs](https://redis.io).
* Requirement one
* Requirement two
Use `redis-cli` for debugging.
## Decision Outcome
Chosen option: "Redis", because it is fast.
### Consequences
* Old consequence
"#;
let adr_path = repo.adr_path().join("0002-rich-madr-context.md");
fs::write(&adr_path, content).unwrap();
let before = content.to_string();
let mut adr = repo.get(2).unwrap();
adr.consequences = "* New consequence".into();
repo.update(
&adr,
BodySectionPatch {
consequences: Some("* New consequence".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
assert!(after.contains("Chosen option: \"Redis\", because it is fast."));
assert!(after.contains("* New consequence"));
assert!(!after.contains("* Old consequence"));
}
#[test]
fn test_update_legacy_rich_context_preserved_on_decision_patch() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let content = r#"# 2. Legacy rich context
Date: 2026-01-15
## Status
Proposed
## Context
We need caching. See the [Redis docs](https://redis.io).
* Requirement one
* Requirement two
Use `redis-cli` for debugging.
## Decision
Old decision.
## Consequences
Old consequences.
"#;
let adr_path = repo.adr_path().join("0002-legacy-rich-context.md");
fs::write(&adr_path, content).unwrap();
let before = content.to_string();
let mut adr = repo.get(2).unwrap();
adr.decision = "New decision.".into();
repo.update(
&adr,
BodySectionPatch {
decision: Some("New decision.".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert_h2_block_unchanged(&before, &after, "Context");
assert_h2_block_unchanged(&before, &after, "Consequences");
assert!(after.contains("New decision."));
}
#[test]
fn test_update_madr_decision_only_preserves_h3_subsections() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Decision patch MADR
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
Context.
## Decision Outcome
Old intro text.
### Consequences
* Good, because it is fast
* Bad, because it uses memory
### Confirmation
Confirm via load tests.
"#;
let adr_path = repo.adr_path().join("0002-decision-patch-madr.md");
fs::write(&adr_path, content).unwrap();
let before = content.to_string();
let mut adr = repo.get(2).unwrap();
adr.decision = "New intro text.".into();
repo.update(
&adr,
BodySectionPatch {
decision: Some("New intro text.".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert!(after.contains("New intro text."));
assert!(!after.contains("Old intro text."));
assert!(after.contains("### Consequences"));
assert!(after.contains("* Good, because it is fast"));
assert!(after.contains("* Bad, because it uses memory"));
assert!(after.contains("### Confirmation"));
assert!(after.contains("Confirm via load tests."));
assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
}
#[test]
fn test_update_madr_decision_and_consequences_together_preserves_other_h3() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Combined patch MADR
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
Context.
## Decision Outcome
Old intro.
### Consequences
* Old consequence
### Confirmation
Confirm via load tests.
"#;
let adr_path = repo.adr_path().join("0002-combined-patch-madr.md");
fs::write(&adr_path, content).unwrap();
let before = content.to_string();
let mut adr = repo.get(2).unwrap();
adr.decision = "New intro.".into();
adr.consequences = "* New consequence".into();
repo.update(
&adr,
BodySectionPatch {
decision: Some("New intro.".into()),
consequences: Some("* New consequence".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert!(after.contains("New intro."));
assert!(after.contains("* New consequence"));
assert!(!after.contains("Old intro."));
assert!(!after.contains("* Old consequence"));
assert!(after.contains("### Confirmation"));
assert!(after.contains("Confirm via load tests."));
assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
}
#[test]
fn test_update_ng_nygard_consequences_only_patch() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Nygard consequences patch
date: 2026-01-15
status: proposed
---
## Context
Original context.
## Decision
Original decision.
## Consequences
* Old item
"#;
let adr_path = repo.adr_path().join("0002-nygard-consequences-patch.md");
fs::write(&adr_path, content).unwrap();
let before = content.to_string();
let mut adr = repo.get(2).unwrap();
adr.consequences = "* New item".into();
repo.update(
&adr,
BodySectionPatch {
consequences: Some("* New item".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert_h2_block_unchanged(&before, &after, "Context");
assert_h2_block_unchanged(&before, &after, "Decision");
assert!(after.contains("* New item"));
assert!(!after.contains("* Old item"));
}
#[test]
fn test_update_ng_nygard_context_only_preserves_consequences_section() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Nygard context patch
date: 2026-01-15
status: proposed
---
## Context
Old context.
## Decision
Original decision.
## Consequences
* Good, because it is fast
* Bad, because it uses memory
"#;
let adr_path = repo.adr_path().join("0002-nygard-context-patch.md");
fs::write(&adr_path, content).unwrap();
let before = content.to_string();
let mut adr = repo.get(2).unwrap();
adr.context = "New context.".into();
repo.update(
&adr,
BodySectionPatch {
context: Some("New context.".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert_h2_block_unchanged(&before, &after, "Decision");
assert_h2_block_unchanged(&before, &after, "Consequences");
assert!(after.contains("New context."));
}
#[test]
fn test_update_metadata_only_preserves_madr_body_byte_identical() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Metadata only MADR
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
Context.
## Decision Outcome
Intro.
### Consequences
* Good item
### Confirmation
Confirm via tests.
"#;
let adr_path = repo.adr_path().join("0002-metadata-only-madr.md");
fs::write(&adr_path, content).unwrap();
let before = fs::read_to_string(&adr_path).unwrap();
let body_start = before.find("\n\n## Context").unwrap();
let mut adr = repo.get(2).unwrap();
adr.status = AdrStatus::Accepted;
repo.update(&adr, BodySectionPatch::default()).unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert_eq!(
&before[body_start..],
&after[after.find("\n\n## Context").unwrap()..]
);
assert!(after.contains("status: accepted"));
}
#[test]
fn test_update_madr_consequences_appends_h3_when_missing() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Append consequences MADR
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
Context.
## Decision Outcome
Intro only, no consequences subsection yet.
"#;
let adr_path = repo.adr_path().join("0002-append-consequences-madr.md");
fs::write(&adr_path, content).unwrap();
let mut adr = repo.get(2).unwrap();
adr.consequences = "* Appended consequence".into();
repo.update(
&adr,
BodySectionPatch {
consequences: Some("* Appended consequence".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert!(after.contains("Intro only, no consequences subsection yet."));
assert!(after.contains("### Consequences"));
assert!(after.contains("* Appended consequence"));
}
#[test]
fn test_update_madr_in_compatible_mode_repo() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let content = r#"---
number: 2
title: Compatible repo MADR file
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
Original context.
## Decision Outcome
Chosen option: "Redis", because it is fast.
### Consequences
* Good item
### Confirmation
Confirm via tests.
"#;
let adr_path = repo.adr_path().join("0002-compatible-repo-madr-file.md");
fs::write(&adr_path, content).unwrap();
let before = content.to_string();
let mut adr = repo.get(2).unwrap();
adr.context = "Updated context.".into();
repo.update(
&adr,
BodySectionPatch {
context: Some("Updated context.".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert!(after.contains("Updated context."));
assert_h2_block_unchanged(&before, &after, "Decision Outcome");
}
#[test]
fn test_update_madr_decision_only_without_h3_subsections() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Simple decision MADR
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
Context.
## Decision Outcome
Old single-paragraph decision.
"#;
let adr_path = repo.adr_path().join("0002-simple-decision-madr.md");
fs::write(&adr_path, content).unwrap();
let mut adr = repo.get(2).unwrap();
adr.decision = "New single-paragraph decision.".into();
repo.update(
&adr,
BodySectionPatch {
decision: Some("New single-paragraph decision.".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert!(after.contains("New single-paragraph decision."));
assert!(!after.contains("Old single-paragraph decision."));
}
#[test]
fn test_update_madr_optional_sections_preserved_byte_identical() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: MADR optional sections
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
Original context.
## Considered Options
* Redis
* Memcached
## Decision Outcome
Chosen option: "Redis", because it is fast.
### Consequences
* Good item
## Pros and Cons of the Options
### Redis
* Good, because fast
* Bad, because memory
### Memcached
* Good, because simple
* Bad, because strings only
## More Information
See [MADR](https://adr.github.io/madr/) for details.
"#;
let adr_path = repo.adr_path().join("0002-madr-optional-sections.md");
fs::write(&adr_path, content).unwrap();
let before = content.to_string();
let mut adr = repo.get(2).unwrap();
adr.context = "Updated context.".into();
repo.update(
&adr,
BodySectionPatch {
context: Some("Updated context.".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert!(after.contains("Updated context."));
assert_h2_block_unchanged(&before, &after, "Considered Options");
assert_h2_block_unchanged(&before, &after, "Decision Outcome");
assert_h2_block_unchanged(&before, &after, "Pros and Cons of the Options");
assert_h2_block_unchanged(&before, &after, "More Information");
}
#[test]
fn test_update_unchanged_sections_byte_identical_after_context_patch() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Byte identity check
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
Original context.
## Decision Outcome
Intro.
### Consequences
* Good item
### Confirmation
Confirm via tests.
"#;
let adr_path = repo.adr_path().join("0002-byte-identity-check.md");
fs::write(&adr_path, content).unwrap();
let before = content.to_string();
let mut adr = repo.get(2).unwrap();
adr.context = "Updated context.".into();
repo.update(
&adr,
BodySectionPatch {
context: Some("Updated context.".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert_h2_block_unchanged(&before, &after, "Decision Outcome");
}
#[test]
fn test_update_madr_context_patch_does_not_round_trip_lossy_fields() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Lossy parse guard
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
Original context.
## Decision Outcome
See [Redis docs](https://redis.io) and use `redis-cli`.
* Chosen option: "Redis"
* Because it is **fast**
### Consequences
* Good item
"#;
let adr_path = repo.adr_path().join("0002-lossy-parse-guard.md");
fs::write(&adr_path, content).unwrap();
let before = content.to_string();
let mut adr = repo.get(2).unwrap();
adr.context = "Updated context.".into();
repo.update(
&adr,
BodySectionPatch {
context: Some("Updated context.".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert_h2_block_unchanged(&before, &after, "Decision Outcome");
assert!(after.contains("[Redis docs](https://redis.io)"));
assert!(after.contains("`redis-cli`"));
assert!(after.contains("**fast**"));
}
#[test]
fn test_fence_in_decision_outcome_preserved_on_consequences_patch() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Fenced example in decision
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
Context.
## Decision Outcome
Chosen option: "Redis", because it is fast.
```markdown
## Consequences
Example consequences inside a fence.
```
Trailing text after the fence.
### Consequences
* Good, because it provides pub/sub
### Confirmation
We will confirm via load tests.
"#;
let adr_path = repo.adr_path().join("0002-fenced-decision-outcome.md");
fs::write(&adr_path, content).unwrap();
let before = content.to_string();
let mut adr = repo.get(2).unwrap();
adr.consequences = "* Updated consequence".into();
repo.update(
&adr,
BodySectionPatch {
consequences: Some("* Updated consequence".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert!(after.contains("```markdown"));
assert!(after.contains("Example consequences inside a fence."));
assert!(after.contains("Trailing text after the fence."));
assert!(after.contains("### Confirmation"));
assert!(after.contains("We will confirm via load tests."));
assert!(after.contains("* Updated consequence"));
assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
}
#[test]
fn test_body_patch_preserves_sections_without_trailing_newline() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let content = "# 2. Compact file\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nOld context.\n\n## Decision\n\nOld decision.\n\n## Consequences\n\nOld consequences.";
let adr_path = repo.adr_path().join("0002-no-trailing-newline.md");
fs::write(&adr_path, content).unwrap();
assert_ne!(fs::read(&adr_path).unwrap().last(), Some(&b'\n'));
let mut adr = repo.get(2).unwrap();
adr.decision = "New decision.".into();
repo.update(
&adr,
BodySectionPatch {
decision: Some("New decision.".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert!(!after.contains("Old context.## Decision"));
let reparsed = repo.get(2).unwrap();
assert!(reparsed.decision.contains("New decision."));
}
#[test]
fn test_people_field_yaml_unchanged_skip_rewrite() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = r#"---
number: 2
title: Zero indent consulted
date: 2026-01-15
status: proposed
consulted:
- alice
- bob
---
## Context
Context.
"#;
let adr_path = repo.adr_path().join("0002-zero-indent-consulted.md");
fs::write(&adr_path, content).unwrap();
let before = fs::read_to_string(&adr_path).unwrap();
let adr = repo.get(2).unwrap();
repo.update_metadata(&adr).unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert_eq!(before, after);
}
#[test]
fn test_body_only_update_preserves_non_canonical_status() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let content = r#"# 2. Non-canonical status
Date: 2026-01-15
## Status
Approved by the architecture board on 2026-01-15.
## Context
Original context.
## Decision
Decision.
## Consequences
Consequences.
"#;
let adr_path = repo.adr_path().join("0002-non-canonical-status.md");
fs::write(&adr_path, content).unwrap();
let mut adr = repo.get(2).unwrap();
adr.context = "Updated context.".into();
repo.update(
&adr,
BodySectionPatch {
context: Some("Updated context.".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert!(after.contains("Approved by the architecture board"));
assert!(after.contains("Updated context."));
}
#[test]
fn test_missing_section_patch_returns_error() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let content = r#"# 2. No decision or consequences sections
Date: 2026-01-15
## Status
Accepted
## Context
Context only.
"#;
let adr_path = repo.adr_path().join("0002-no-consequences.md");
fs::write(&adr_path, content).unwrap();
let mut adr = repo.get(2).unwrap();
adr.consequences = "New consequences.".into();
let err = repo
.update(
&adr,
BodySectionPatch {
consequences: Some("New consequences.".into()),
..Default::default()
},
)
.unwrap_err();
assert!(err.to_string().contains("consequences patch requested"));
}
#[test]
fn test_nygard_consequences_patch_errors_without_consequences_section() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let content = r#"# 3. Nygard decision without consequences section
Date: 2026-01-15
## Status
Accepted
## Context
Context.
## Decision
We decided X.
"#;
let adr_path = repo.adr_path().join("0003-nygard-no-consequences.md");
fs::write(&adr_path, content).unwrap();
let mut adr = repo.get(3).unwrap();
adr.consequences = "New consequences.".into();
let err = repo
.update(
&adr,
BodySectionPatch {
consequences: Some("New consequences.".into()),
..Default::default()
},
)
.unwrap_err();
assert!(err.to_string().contains("consequences patch requested"));
let after = fs::read_to_string(&adr_path).unwrap();
assert!(!after.contains("### Consequences"));
assert!(!after.contains("New consequences."));
}
#[test]
fn test_decision_patch_preserves_fence_in_context() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let content = r#"# 4. Fence in context
Date: 2026-01-15
## Status
Accepted
## Context
Example:
```
## Decision
not a real heading
```
## Decision
We decided.
"#;
let adr_path = repo.adr_path().join("0004-fence-in-context.md");
fs::write(&adr_path, content).unwrap();
repo.update(
&repo.get(4).unwrap(),
BodySectionPatch {
decision: Some("Updated decision.".into()),
..Default::default()
},
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
assert!(after.contains("## Decision\nnot a real heading"));
assert!(after.contains("Updated decision."));
assert!(!after.contains("We decided."));
}
}