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 regex::Regex;
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, Default, Clone)]
#[non_exhaustive]
pub struct RenumberResult {
pub from: u32,
pub to: u32,
pub no_op: bool,
pub renamed_file: Option<(PathBuf, PathBuf)>,
pub frontmatter_updated: bool,
pub h1_updated: bool,
pub updated_references: Vec<PathBuf>,
pub prose_warnings: Vec<PathBuf>,
pub ambiguous_references: Vec<PathBuf>,
}
#[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 legacy_path = root.join(crate::config::LEGACY_CONFIG_FILE);
let toml_path = root.join(crate::config::CONFIG_FILE);
let existing_config = if legacy_path.exists() || toml_path.exists() {
Config::load(&root).ok().filter(|c| c.adr_dir == adr_dir)
} else {
None
};
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 = match existing_config {
Some(existing) => existing,
None => {
let config = Config {
adr_dir,
mode: if ng {
ConfigMode::NextGen
} else {
ConfigMode::Compatible
},
..Default::default()
};
let stale = if config.is_next_gen() {
&legacy_path
} else {
&toml_path
};
if stale.exists() {
fs::remove_file(stale)?;
}
config.save(&root)?;
config
}
};
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 renumber(
&self,
from: u32,
to: u32,
file: Option<&Path>,
dry_run: bool,
) -> Result<RenumberResult> {
let all = self.list()?;
let candidates: Vec<&Adr> = all.iter().filter(|a| a.number == from).collect();
if candidates.is_empty() {
return Err(Error::AdrNotFound(from.to_string()));
}
let source_was_ambiguous = candidates.len() > 1;
let source_adr: Adr = if let Some(file) = file {
let file_canon = fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
let found = candidates.iter().find(|a| {
a.path
.as_ref()
.map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
== Some(file_canon.clone())
});
match found {
Some(adr) => (*adr).clone(),
None => {
return Err(Error::RenumberFileMismatch {
number: from,
file: file.to_path_buf(),
candidates: Self::renumber_candidate_paths(&candidates),
});
}
}
} else if candidates.len() > 1 {
return Err(Error::AmbiguousRenumberSource {
number: from,
candidates: Self::renumber_candidate_paths(&candidates),
});
} else {
candidates[0].clone()
};
if from == to {
return Ok(RenumberResult {
from,
to,
no_op: true,
..Default::default()
});
}
if let Some(occupant) = all.iter().find(|a| a.number == to) {
return Err(Error::RenumberTargetOccupied {
to,
occupant_title: occupant.title.clone(),
occupant_path: occupant.path.clone().unwrap_or_default(),
suggestion: Self::smallest_free_number(&all),
});
}
let old_path = source_adr
.path
.clone()
.ok_or_else(|| Error::InvalidFormat {
path: PathBuf::new(),
reason: format!("ADR {from} has no on-disk path"),
})?;
let old_filename = old_path
.file_name()
.and_then(|f| f.to_str())
.ok_or_else(|| Error::InvalidFormat {
path: old_path.clone(),
reason: "filename is not valid UTF-8".into(),
})?
.to_string();
let prefix = format!("{from:04}-");
let slug_part =
old_filename
.strip_prefix(prefix.as_str())
.ok_or_else(|| Error::InvalidFormat {
path: old_path.clone(),
reason: format!("filename does not start with '{prefix}'"),
})?;
let new_filename = format!("{to:04}-{slug_part}");
let new_path = self.adr_path().join(&new_filename);
let original_own_content = fs::read_to_string(&old_path)?;
let mut target_adr = source_adr.clone();
target_adr.number = to;
let after_frontmatter = if Self::has_frontmatter(&original_own_content) {
self.update_frontmatter_metadata(&target_adr, &original_own_content)?
} else {
original_own_content.clone()
};
let frontmatter_updated = after_frontmatter != original_own_content;
let after_h1 = Self::rewrite_h1_number(&after_frontmatter, from, to);
let h1_updated = after_h1.is_some();
let final_own_content = after_h1.unwrap_or(after_frontmatter);
let mut updated_references = Vec::new();
let mut ambiguous_references = Vec::new();
let mut pending_writes: Vec<(PathBuf, String)> = Vec::new();
for adr in all
.iter()
.filter(|a| a.path.as_deref() != Some(old_path.as_path()))
{
let Some(path) = adr.path.clone() else {
continue;
};
let original = fs::read_to_string(&path)?;
let mut working = original.clone();
let mut this_changed = false;
if Self::has_frontmatter(&working) && adr.links.iter().any(|l| l.target == from) {
if source_was_ambiguous {
ambiguous_references.push(path.clone());
} else {
let mut mutated_adr = adr.clone();
for link in mutated_adr.links.iter_mut() {
if link.target == from {
link.target = to;
}
}
let rewritten = self.update_frontmatter_metadata(&mutated_adr, &working)?;
if rewritten != working {
working = rewritten;
this_changed = true;
}
}
}
if let Some(rewritten) =
Self::rewrite_body_link_references(&working, to, &old_filename, &new_filename)
{
working = rewritten;
this_changed = true;
}
if this_changed {
updated_references.push(path.clone());
pending_writes.push((path, working));
}
}
if !dry_run {
fs::rename(&old_path, &new_path)?;
fs::write(&new_path, &final_own_content)?;
for (path, content) in &pending_writes {
fs::write(path, content)?;
}
}
let prose_warnings = self.scan_prose_references(&old_filename);
Ok(RenumberResult {
from,
to,
no_op: false,
renamed_file: Some((old_path, new_path)),
frontmatter_updated,
h1_updated,
updated_references,
prose_warnings,
ambiguous_references,
})
}
fn renumber_candidate_paths(candidates: &[&Adr]) -> Vec<String> {
candidates
.iter()
.map(|a| {
a.path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| format!("<no path for ADR {}>", a.number))
})
.collect()
}
fn smallest_free_number(adrs: &[Adr]) -> u32 {
let existing: std::collections::HashSet<u32> = adrs.iter().map(|a| a.number).collect();
let mut n = 1;
while existing.contains(&n) {
n += 1;
}
n
}
fn rewrite_h1_number(content: &str, from: u32, to: u32) -> Option<String> {
let crlf = content.contains("\r\n");
let normalized = if crlf {
std::borrow::Cow::Owned(content.replace("\r\n", "\n"))
} else {
std::borrow::Cow::Borrowed(content)
};
let old_prefix = format!("# {from}. ");
let new_prefix = format!("# {to}. ");
let mut found_h1 = false;
let mut changed = false;
let mut result = String::with_capacity(normalized.len() + 4);
for (i, line) in normalized.split('\n').enumerate() {
if i > 0 {
result.push('\n');
}
if !found_h1 && line.starts_with("# ") {
found_h1 = true;
if let Some(rest) = line.strip_prefix(old_prefix.as_str()) {
result.push_str(&new_prefix);
result.push_str(rest);
changed = true;
continue;
}
}
result.push_str(line);
}
if !changed {
return None;
}
Some(if crlf {
result.replace('\n', "\r\n")
} else {
result
})
}
fn rewrite_body_link_references(
content: &str,
to: u32,
old_filename: &str,
new_filename: &str,
) -> Option<String> {
if !content.contains(old_filename) {
return None;
}
let crlf = content.contains("\r\n");
let normalized = if crlf {
std::borrow::Cow::Owned(content.replace("\r\n", "\n"))
} else {
std::borrow::Cow::Borrowed(content)
};
let link_pattern = format!(r"\[([^\]]*)\]\({}\)", regex::escape(old_filename));
let link_re = Regex::new(&link_pattern).expect("valid regex");
let number_prefix_re = Regex::new(r"^(\d+)(\..*)?$").expect("valid regex");
let mut changed = false;
let rewritten = link_re
.replace_all(&normalized, |caps: ®ex::Captures| {
changed = true;
let text = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let new_text = match number_prefix_re.captures(text) {
Some(tc) => format!("{to}{}", tc.get(2).map(|m| m.as_str()).unwrap_or("")),
None => text.to_string(),
};
format!("[{new_text}]({new_filename})")
})
.into_owned();
if !changed {
return None;
}
Some(if crlf {
rewritten.replace('\n', "\r\n")
} else {
rewritten
})
}
fn scan_prose_references(&self, old_filename: &str) -> Vec<PathBuf> {
let adr_dir = self.adr_path();
let mut matches: Vec<PathBuf> = WalkDir::new(&self.root)
.into_iter()
.filter_entry(|e| {
if !e.file_type().is_dir() {
return true;
}
!matches!(
e.file_name().to_str(),
Some(".git") | Some("target") | Some("node_modules")
)
})
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.map(|e| e.path().to_path_buf())
.filter(|p| !p.starts_with(&adr_dir))
.filter(|p| {
fs::read_to_string(p)
.map(|content| content.contains(old_filename))
.unwrap_or(false)
})
.collect();
matches.sort();
matches
}
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 Self::has_frontmatter(&content) {
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 Self::has_frontmatter(&content) {
self.update_frontmatter_metadata(adr, &content)?
} else {
self.update_legacy_metadata(adr, &content)?
};
fs::write(&path, updated)?;
Ok(path)
}
fn has_frontmatter(content: &str) -> bool {
content.starts_with("---\n") || content.starts_with("---\r\n")
}
fn update_frontmatter_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
let crlf = content.contains("\r\n");
let normalized = if crlf {
std::borrow::Cow::Owned(content.replace("\r\n", "\n"))
} else {
std::borrow::Cow::Borrowed(content)
};
let normalized: &str = &normalized;
let Some(rest) = normalized.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 number_val = serde_yaml_neo::to_value(adr.number)?;
if map.get(Self::yaml_str_key("number")) != Some(&number_val) {
map.insert(Self::yaml_str_key("number"), number_val);
dirty = true;
}
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');
let result = format!("---\n{new_yaml}{after_yaml}");
if crlf {
Ok(result.replace('\n', "\r\n"))
} else {
Ok(result)
}
}
fn update_legacy_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
let ending = if content.contains("\r\n") {
"\r\n"
} else {
"\n"
};
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_str(ending);
}
result.push_str(ending);
result.push_str(&adr.status.to_string());
result.push_str(ending);
let link_titles = self.resolve_link_titles(adr);
for link in &adr.links {
result.push_str(ending);
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_str(ending);
}
if let Some(next_idx) = next_heading_idx {
result.push_str(ending);
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_str(ending);
}
}
} else if content.ends_with('\n') {
}
Ok(result)
}
fn update_body_sections(&self, content: &str, patch: &BodySectionPatch) -> Result<String> {
let ending = if content.contains("\r\n") {
"\r\n"
} else {
"\n"
};
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_str(ending);
i += 1;
let body_end = Self::next_h2_index(&lines, i);
let next_is_heading = body_end < lines.len();
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, ending, next_is_heading);
} else {
Self::append_lines(&mut result, &lines, i, body_end, content, ending);
}
}
"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,
ending,
);
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, ending, next_is_heading);
} else {
Self::append_lines(&mut result, &lines, i, body_end, content, ending);
}
}
_ => {
Self::append_lines(&mut result, &lines, i, body_end, content, ending);
}
}
i = body_end;
continue;
}
result.push_str(line);
if i < lines.len() - 1 || content.ends_with('\n') {
result.push_str(ending);
}
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_str(ending);
}
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,
ending: &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_str(ending);
}
}
}
fn write_section_body(result: &mut String, text: &str, ending: &str, blank_line_after: bool) {
result.push_str(ending);
if ending == "\n" {
result.push_str(text);
} else {
result.push_str(&text.replace("\r\n", "\n").replace('\n', ending));
}
if !text.ends_with('\n') {
result.push_str(ending);
}
if blank_line_after {
result.push_str(ending);
}
}
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"))
}
#[allow(clippy::too_many_arguments)]
fn patch_decision_section(
result: &mut String,
lines: &[&str],
content: &str,
body_start: usize,
body_end: usize,
patch: &BodySectionPatch,
madr_decision_outcome: bool,
ending: &str,
) -> (bool, bool) {
if patch.decision.is_none() && patch.consequences.is_none() {
Self::append_lines(result, lines, body_start, body_end, content, ending);
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, ending);
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);
let will_append_madr_consequences = intro_end >= body_end
&& patch.consequences.is_some()
&& !Self::has_consequences_h2(lines)
&& madr_decision_outcome;
if let Some(ref text) = patch.decision {
let blank_line_after = if intro_end < body_end {
true
} else {
will_append_madr_consequences || body_end < lines.len()
};
Self::write_section_body(result, text, ending, blank_line_after);
} else {
Self::append_lines(result, lines, body_start, intro_end, content, ending);
}
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,
ending,
body_end < lines.len(),
);
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_str(ending);
let blank_line_after = sub_end < body_end || body_end < lines.len();
Self::write_section_body(result, text, ending, blank_line_after);
j = sub_end;
continue;
}
Self::append_lines(result, lines, sub_start, sub_end, content, ending);
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, ending, body_end < lines.len());
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,
ending: &str,
blank_line_after: bool,
) {
if !result.is_empty() && !result.ends_with('\n') {
result.push_str(ending);
}
result.push_str("### Consequences");
result.push_str(ending);
Self::write_section_body(result, text, ending, blank_line_after);
}
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_madr_consequences_only_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 Redis
date: 2026-01-15
status: proposed
---
## Context and Problem Statement
Context.
## Decision Outcome
Chosen option: "Redis", because it is fast.
### Consequences
Old consequence text.
"#;
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 text.".into();
repo.update(
&adr,
BodySectionPatch::new().with_consequences("New consequence text."),
)
.unwrap();
let reloaded = repo.get(2).unwrap();
assert_eq!(reloaded.consequences, "New consequence text.");
assert_eq!(
reloaded.decision,
"Chosen option: \"Redis\", because it is fast."
);
assert!(
!reloaded.decision.contains("New consequence text."),
"consequences text leaked into decision on read:\n{}",
reloaded.decision
);
}
#[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."));
}
fn is_uniformly_crlf(content: &str) -> bool {
content.contains("\r\n") && !content.replace("\r\n", "").contains('\n')
}
#[test]
fn test_crlf_madr_body_patch_preserves_line_endings() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let lf_content = "---\nnumber: 2\ntitle: CRLF MADR\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context and Problem Statement\n\nOld context.\n\n## Decision Outcome\n\nChosen option: \"Redis\", because it is fast.\n\n### Consequences\n\n* Old consequence\n";
let crlf_content = lf_content.replace('\n', "\r\n");
let adr_path = repo.adr_path().join("0002-crlf-madr.md");
fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
let mut adr = repo.get(2).unwrap();
adr.context = "New context.".into();
adr.consequences = "* New good\n* New bad".into();
repo.update(
&adr,
BodySectionPatch::new()
.with_context("New context.")
.with_consequences("* New good\n* New bad"),
)
.unwrap();
let after = String::from_utf8(fs::read(&adr_path).unwrap()).unwrap();
assert!(
is_uniformly_crlf(&after),
"expected uniform CRLF, found a bare \\n\n{after:?}"
);
assert!(after.contains("New context.\r\n"));
assert!(after.contains("* New good\r\n* New bad\r\n"));
assert!(after.contains("Chosen option: \"Redis\", because it is fast."));
let reparsed = repo.get(2).unwrap();
assert_eq!(reparsed.context, "New context.");
assert!(reparsed.consequences.contains("New good"));
assert!(!reparsed.decision.contains("New good"));
assert!(!reparsed.context.contains('\r'), "stray \\r in context");
assert!(!reparsed.decision.contains('\r'), "stray \\r in decision");
assert!(
!reparsed.consequences.contains('\r'),
"stray \\r in consequences"
);
}
#[test]
fn test_crlf_nygard_body_patch_preserves_line_endings() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let lf_content = "# 2. CRLF Nygard\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.\n";
let crlf_content = lf_content.replace('\n', "\r\n");
let adr_path = repo.adr_path().join("0002-crlf-nygard.md");
fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
let mut adr = repo.get(2).unwrap();
adr.decision = "New decision.".into();
repo.update(&adr, BodySectionPatch::new().with_decision("New decision."))
.unwrap();
let after = String::from_utf8(fs::read(&adr_path).unwrap()).unwrap();
assert!(
is_uniformly_crlf(&after),
"expected uniform CRLF, found a bare \\n\n{after:?}"
);
assert!(after.contains("New decision.\r\n"));
assert!(after.contains("Old context."));
assert!(after.contains("Old consequences."));
let reparsed = repo.get(2).unwrap();
assert_eq!(reparsed.decision, "New decision.");
assert!(!reparsed.context.contains('\r'), "stray \\r in context");
assert!(!reparsed.decision.contains('\r'), "stray \\r in decision");
assert!(
!reparsed.consequences.contains('\r'),
"stray \\r in consequences"
);
}
#[test]
fn test_update_metadata_on_crlf_frontmatter_file_updates_and_preserves_crlf() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let lf_content = "---\nnumber: 2\ntitle: CRLF metadata\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
let crlf_content = lf_content.replace('\n', "\r\n");
let adr_path = repo.adr_path().join("0002-crlf-metadata.md");
fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
let mut adr = repo.get(2).unwrap();
adr.status = AdrStatus::Accepted;
repo.update_metadata(&adr).unwrap();
let after = String::from_utf8(fs::read(&adr_path).unwrap()).unwrap();
assert!(
after.contains("status: accepted"),
"status must actually update on a CRLF file\n{after}"
);
assert!(
is_uniformly_crlf(&after),
"expected uniform CRLF, found a bare \\n\n{after:?}"
);
let listed = repo.get(2).unwrap();
assert_eq!(listed.status, AdrStatus::Accepted);
}
#[test]
fn test_noop_metadata_update_on_crlf_file_is_byte_identical() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let lf_content = "---\nnumber: 2\ntitle: CRLF noop\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
let crlf_content = lf_content.replace('\n', "\r\n");
let adr_path = repo.adr_path().join("0002-crlf-noop.md");
fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
let before = fs::read(&adr_path).unwrap();
let adr = repo.get(2).unwrap();
repo.update_metadata(&adr).unwrap();
let after = fs::read(&adr_path).unwrap();
assert_eq!(
before, after,
"no-op metadata update on a CRLF file must be byte-identical"
);
}
#[test]
fn test_update_metadata_number_field_is_noop_when_unchanged() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let adr = repo.get(1).unwrap();
let path = adr.path.clone().unwrap();
let before = fs::read(&path).unwrap();
repo.update_metadata(&adr).unwrap();
let after = fs::read(&path).unwrap();
assert_eq!(
before, after,
"update_metadata with an unchanged number must be byte-identical"
);
}
#[test]
fn test_update_legacy_metadata_on_crlf_file_preserves_crlf() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let lf_content = "# 2. CRLF legacy\n\nDate: 2026-01-15\n\n## Status\n\nProposed\n\n## Context\n\nContext.\n\n## Decision\n\nDecision.\n\n## Consequences\n\nConsequences.\n";
let crlf_content = lf_content.replace('\n', "\r\n");
let adr_path = repo.adr_path().join("0002-crlf-legacy.md");
fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
let mut adr = repo.get(2).unwrap();
adr.status = AdrStatus::Accepted;
repo.update_metadata(&adr).unwrap();
let after = String::from_utf8(fs::read(&adr_path).unwrap()).unwrap();
assert!(
after.contains("## Status\r\n\r\nAccepted\r\n"),
"status must update on a CRLF legacy file\n{after:?}"
);
assert!(
is_uniformly_crlf(&after),
"expected uniform CRLF, found a bare \\n\n{after:?}"
);
let listed = repo.get(2).unwrap();
assert_eq!(listed.status, AdrStatus::Accepted);
}
#[test]
fn test_noop_legacy_metadata_update_on_crlf_file_is_byte_identical() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let lf_content = "# 2. CRLF legacy noop\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nContext.\n\n## Decision\n\nDecision.\n\n## Consequences\n\nConsequences.\n";
let crlf_content = lf_content.replace('\n', "\r\n");
let adr_path = repo.adr_path().join("0002-crlf-legacy-noop.md");
fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
let before = fs::read(&adr_path).unwrap();
let adr = repo.get(2).unwrap();
repo.update_metadata(&adr).unwrap();
let after = fs::read(&adr_path).unwrap();
assert_eq!(
before, after,
"no-op legacy metadata update on a CRLF file must be byte-identical"
);
}
#[test]
fn test_decision_patch_middle_section_exact_blank_line_boundary() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let content = "# 2. Blank line boundary\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.\n";
let adr_path = repo.adr_path().join("0002-blank-line-boundary.md");
fs::write(&adr_path, content).unwrap();
let mut adr = repo.get(2).unwrap();
adr.decision = "New text.".into();
repo.update(&adr, BodySectionPatch::new().with_decision("New text."))
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
let expected = "# 2. Blank line boundary\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nOld context.\n\n## Decision\n\nNew text.\n\n## Consequences\n\nOld consequences.\n";
assert_eq!(after, expected);
}
#[test]
fn test_decision_patch_middle_section_exact_blank_line_boundary_crlf() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let lf_content = "# 2. Blank line boundary\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.\n";
let content = lf_content.replace('\n', "\r\n");
let adr_path = repo.adr_path().join("0002-blank-line-boundary-crlf.md");
fs::write(&adr_path, content.as_bytes()).unwrap();
let mut adr = repo.get(2).unwrap();
adr.decision = "New text.".into();
repo.update(&adr, BodySectionPatch::new().with_decision("New text."))
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
let expected_lf = "# 2. Blank line boundary\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nOld context.\n\n## Decision\n\nNew text.\n\n## Consequences\n\nOld consequences.\n";
let expected = expected_lf.replace('\n', "\r\n");
assert_eq!(after, expected);
}
#[test]
fn test_consequences_patch_last_section_no_trailing_blank_accumulation() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let content = "# 2. Last section\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.\n";
let adr_path = repo.adr_path().join("0002-last-section.md");
fs::write(&adr_path, content).unwrap();
let mut adr = repo.get(2).unwrap();
adr.consequences = "New consequences.".into();
repo.update(
&adr,
BodySectionPatch::new().with_consequences("New consequences."),
)
.unwrap();
let after = fs::read_to_string(&adr_path).unwrap();
let expected = "# 2. Last section\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\nNew consequences.\n";
assert_eq!(after, expected);
assert!(
!after.ends_with("\n\n"),
"trailing blank line accumulated at EOF"
);
}
#[test]
fn test_repeated_identical_context_patch_is_idempotent() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let content = "# 2. Idempotent context\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.\n";
let adr_path = repo.adr_path().join("0002-idempotent-context.md");
fs::write(&adr_path, content).unwrap();
let adr = repo.get(2).unwrap();
let patch = BodySectionPatch::new().with_context("New context.");
repo.update(&adr, patch.clone()).unwrap();
let after_first = fs::read(&adr_path).unwrap();
repo.update(&adr, patch).unwrap();
let after_second = fs::read(&adr_path).unwrap();
assert_eq!(
after_first, after_second,
"repeating an identical patch must produce identical bytes"
);
}
#[test]
fn test_repeated_identical_madr_consequences_append_is_idempotent() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = "---\nnumber: 2\ntitle: Idempotence\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context and Problem Statement\n\nContext.\n\n## Decision Outcome\n\nOld intro.\n\n## Links\n\nSee also.\n";
let adr_path = repo.adr_path().join("0002-idempotence.md");
fs::write(&adr_path, content).unwrap();
let adr = repo.get(2).unwrap();
let patch = BodySectionPatch::new()
.with_decision("New intro.")
.with_consequences("* New consequence");
repo.update(&adr, patch.clone()).unwrap();
let after_first = fs::read(&adr_path).unwrap();
repo.update(&adr, patch).unwrap();
let after_second = fs::read(&adr_path).unwrap();
assert_eq!(
after_first, after_second,
"repeating an identical patch must produce identical bytes"
);
}
fn snapshot_dir(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
let mut files: Vec<(PathBuf, Vec<u8>)> = WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.map(|e| {
let path = e.path().to_path_buf();
let bytes = fs::read(&path).unwrap();
(path, bytes)
})
.collect();
files.sort_by(|a, b| a.0.cmp(&b.0));
files
}
#[test]
fn test_renumber_nextgen_rewrites_filename_frontmatter_h1_and_inbound_link() {
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 old_path = repo.adr_path().join("0002-use-mysql.md");
let new_path = repo.adr_path().join("0005-use-mysql.md");
let result = repo.renumber(2, 5, None, false).unwrap();
assert!(!result.no_op);
assert_eq!(
result.renamed_file,
Some((old_path.clone(), new_path.clone()))
);
assert!(result.frontmatter_updated);
assert!(result.h1_updated);
assert!(!old_path.exists());
assert!(new_path.exists());
let content = fs::read_to_string(&new_path).unwrap();
assert!(
content.contains("number: 5"),
"frontmatter number must be rewritten\n{content}"
);
assert!(
content.contains("# 5. Use MySQL"),
"H1 must be rewritten\n{content}"
);
let adr3 = repo.get(3).unwrap();
assert!(adr3.links.iter().any(|l| l.target == 5));
assert!(!adr3.links.iter().any(|l| l.target == 2));
let adr3_path = repo.adr_path().join("0003-use-postgresql.md");
let adr3_content = fs::read_to_string(&adr3_path).unwrap();
assert!(
adr3_content.contains("Supersedes [5. Use MySQL](0005-use-mysql.md)"),
"inbound body link must be rewritten\n{adr3_content}"
);
assert!(!adr3_content.contains("0002-use-mysql.md"));
assert_eq!(result.updated_references, vec![adr3_path]);
assert!(result.prose_warnings.is_empty());
}
#[test]
fn test_renumber_compatible_rewrites_filename_h1_and_inbound_body_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.link(3, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
.unwrap();
let old_path = repo.adr_path().join("0002-first-decision.md");
let new_path = repo.adr_path().join("0005-first-decision.md");
let result = repo.renumber(2, 5, None, false).unwrap();
assert!(!result.no_op);
assert!(!result.frontmatter_updated);
assert!(result.h1_updated);
assert!(!old_path.exists());
assert!(new_path.exists());
let content = fs::read_to_string(&new_path).unwrap();
assert!(
content.starts_with("# 5. First decision"),
"H1 must be rewritten\n{content}"
);
let adr3_path = repo.adr_path().join("0003-second-decision.md");
let adr3_content = fs::read_to_string(&adr3_path).unwrap();
assert!(
adr3_content.contains("[5. First decision](0005-first-decision.md)"),
"inbound body link, including its text, must be rewritten\n{adr3_content}"
);
assert!(!adr3_content.contains("0002-first-decision.md"));
assert_eq!(result.updated_references, vec![adr3_path]);
}
#[test]
fn test_renumber_resolves_duplicate_via_file_and_leaves_other_untouched() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content_a = "---\nnumber: 3\ntitle: Branch A\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 3. Branch A\n\n## Context\n\nA.\n";
let path_a = repo.adr_path().join("0003-branch-a.md");
fs::write(&path_a, content_a).unwrap();
let content_b = "---\nnumber: 3\ntitle: Branch B\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 3. Branch B\n\n## Context\n\nB.\n";
let path_b = repo.adr_path().join("0003-branch-b.md");
fs::write(&path_b, content_b).unwrap();
let before = crate::lint::check_all(&repo).unwrap();
assert!(
before.issues.iter().any(|i| i.rule_id == "ADR012"),
"expected ADR012 (duplicate number) before the fix: {:?}",
before.issues
);
let new_path = repo.adr_path().join("0004-branch-b.md");
let result = repo.renumber(3, 4, Some(&path_b), false).unwrap();
assert_eq!(
result.renamed_file,
Some((path_b.clone(), new_path.clone()))
);
assert!(!path_b.exists());
assert!(new_path.exists());
let content_a_after = fs::read(&path_a).unwrap();
assert_eq!(content_a_after, content_a.as_bytes());
let after = crate::lint::check_all(&repo).unwrap();
assert!(
!after.issues.iter().any(|i| i.rule_id == "ADR012"),
"doctor should be clean after the fix: {:?}",
after.issues
);
}
#[test]
fn test_renumber_leaves_ambiguous_number_references_alone_and_reports_them() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let adr1 = "---\nnumber: 1\ntitle: Record architecture decisions\ndate: 2026-01-15\nstatus: superseded\nlinks:\n- target: 2\n kind: supersededby\n---\n\n# 1. Record architecture decisions\n\n## Context\n\nOne.\n";
let path1 = repo
.adr_path()
.join("0001-record-architecture-decisions.md");
fs::write(&path1, adr1).unwrap();
let pg = "---\nnumber: 2\ntitle: Use PostgreSQL\ndate: 2026-01-15\nstatus: accepted\nlinks:\n- target: 1\n kind: supersedes\n---\n\n# 2. Use PostgreSQL\n\n## Context\n\nPg.\n";
fs::write(repo.adr_path().join("0002-use-postgresql.md"), pg).unwrap();
let redis = "---\nnumber: 2\ntitle: Use Redis\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 2. Use Redis\n\n## Context\n\nFrom another branch.\n";
let redis_path = repo.adr_path().join("0002-use-redis.md");
fs::write(&redis_path, redis).unwrap();
let result = repo.renumber(2, 3, Some(&redis_path), false).unwrap();
assert_eq!(fs::read(&path1).unwrap(), adr1.as_bytes());
assert!(
!result.updated_references.contains(&path1),
"ADR 1 must not be counted as rewritten"
);
assert!(
result.ambiguous_references.contains(&path1),
"ADR 1's ambiguous reference should be reported, got: {:?}",
result.ambiguous_references
);
}
#[test]
fn test_renumber_rewrites_reference_held_by_the_other_duplicate() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content_a = "---\nnumber: 3\ntitle: Branch A\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 3. Branch A\n\n## Context\n\nSee [3. Branch B](0003-branch-b.md).\n";
let path_a = repo.adr_path().join("0003-branch-a.md");
fs::write(&path_a, content_a).unwrap();
let content_b = "---\nnumber: 3\ntitle: Branch B\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 3. Branch B\n\n## Context\n\nB.\n";
let path_b = repo.adr_path().join("0003-branch-b.md");
fs::write(&path_b, content_b).unwrap();
repo.renumber(3, 4, Some(&path_b), false).unwrap();
let after_a = fs::read_to_string(&path_a).unwrap();
assert!(
after_a.contains("[4. Branch B](0004-branch-b.md)"),
"the remaining duplicate's link to the renumbered file should be rewritten, got: {after_a}"
);
assert!(
!after_a.contains("0003-branch-b.md"),
"no reference to the old filename should survive, got: {after_a}"
);
}
#[test]
fn test_renumber_ambiguous_source_without_file_errors() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content_a = "---\nnumber: 3\ntitle: Branch A\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 3. Branch A\n\n## Context\n\nA.\n";
let path_a = repo.adr_path().join("0003-branch-a.md");
fs::write(&path_a, content_a).unwrap();
let content_b = "---\nnumber: 3\ntitle: Branch B\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 3. Branch B\n\n## Context\n\nB.\n";
let path_b = repo.adr_path().join("0003-branch-b.md");
fs::write(&path_b, content_b).unwrap();
let err = repo.renumber(3, 4, None, false).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("0003-branch-a.md") && msg.contains("0003-branch-b.md"),
"error should name both candidates: {msg}"
);
assert!(
msg.contains("--file"),
"error should direct the user to --file: {msg}"
);
assert_eq!(fs::read(&path_a).unwrap(), content_a.as_bytes());
assert_eq!(fs::read(&path_b).unwrap(), content_b.as_bytes());
}
#[test]
fn test_renumber_occupied_target_errors_and_writes_nothing() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
repo.new_adr("Second decision").unwrap();
let before = snapshot_dir(&repo.adr_path());
let err = repo.renumber(2, 1, None, false).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("already used") && msg.contains(crate::init_adr::TITLE),
"error should name the occupying record: {msg}"
);
assert!(
msg.contains("try 3"),
"error should suggest the smallest free number: {msg}"
);
let after = snapshot_dir(&repo.adr_path());
assert_eq!(before, after, "nothing should be written on refusal");
}
#[test]
fn test_renumber_from_equals_to_is_a_reported_noop() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let before = snapshot_dir(&repo.adr_path());
let result = repo.renumber(1, 1, None, false).unwrap();
let after = snapshot_dir(&repo.adr_path());
assert!(result.no_op);
assert_eq!(before, after);
}
#[test]
fn test_renumber_dry_run_writes_nothing() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
repo.new_adr("First decision").unwrap(); repo.new_adr("Second decision").unwrap(); repo.link(3, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
.unwrap();
let before = snapshot_dir(&repo.adr_path());
let result = repo.renumber(2, 5, None, true).unwrap();
assert!(!result.no_op);
assert!(result.renamed_file.is_some());
assert!(result.frontmatter_updated);
assert!(result.h1_updated);
assert_eq!(result.updated_references.len(), 1);
let after = snapshot_dir(&repo.adr_path());
assert_eq!(before, after, "dry run must not write anything");
}
#[test]
fn test_renumber_preserves_crlf_and_leaves_unrelated_file_untouched() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let lf_content = "---\nnumber: 2\ntitle: CRLF renumber\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 2. CRLF renumber\n\n## Context\n\nContext.\n";
let crlf_content = lf_content.replace('\n', "\r\n");
let path = repo.adr_path().join("0002-crlf-renumber.md");
fs::write(&path, crlf_content.as_bytes()).unwrap();
repo.new_adr("Unrelated").unwrap(); let unrelated_path = repo.adr_path().join("0003-unrelated.md");
let unrelated_before = fs::read(&unrelated_path).unwrap();
let result = repo.renumber(2, 5, None, false).unwrap();
let new_path = repo.adr_path().join("0005-crlf-renumber.md");
let after = fs::read_to_string(&new_path).unwrap();
assert!(
is_uniformly_crlf(&after),
"expected uniform CRLF, found a bare \\n\n{after:?}"
);
assert!(after.contains("number: 5"));
assert!(after.contains("# 5. CRLF renumber"));
let unrelated_after = fs::read(&unrelated_path).unwrap();
assert_eq!(
unrelated_before, unrelated_after,
"a record with no reference to `from` must not be rewritten at all"
);
assert!(result.updated_references.is_empty());
}
#[test]
fn test_renumber_preserves_hand_written_body_content() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let content = "---\nnumber: 2\ntitle: Hand Written\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 2. Hand Written\n\n## Context\n\nSome context with <!-- a comment --> and a\ncustom code block:\n\n```rust\nfn custom() {}\n```\n\n## Decision\n\nHand-authored, not template-derived: * bullet * bullet\n\n## Consequences\n\nConsequences.\n\n## My Custom Section\n\nThis section is not part of any template and must survive verbatim.\n";
let path = repo.adr_path().join("0002-hand-written.md");
fs::write(&path, content).unwrap();
repo.renumber(2, 5, None, false).unwrap();
let new_path = repo.adr_path().join("0005-hand-written.md");
let after = fs::read_to_string(&new_path).unwrap();
let expected = content
.replace("number: 2", "number: 5")
.replace("# 2. Hand Written", "# 5. Hand Written");
assert_eq!(
after, expected,
"only number/H1 may change; everything else must survive verbatim"
);
}
#[test]
fn test_renumber_reports_prose_references_outside_adr_dir_without_rewriting() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
repo.new_adr("First decision").unwrap();
let readme_path = temp.path().join("README.md");
let readme_before =
"See [2. First decision](doc/adr/0002-first-decision.md) for details.\n";
fs::write(&readme_path, readme_before).unwrap();
let result = repo.renumber(2, 5, None, false).unwrap();
assert_eq!(result.prose_warnings, vec![readme_path.clone()]);
let readme_after = fs::read_to_string(&readme_path).unwrap();
assert_eq!(readme_after, readme_before);
}
}