1use crate::{
4 Adr, AdrLink, AdrStatus, Config, ConfigMode, Error, LinkKind, Parser, Result, Template,
5 TemplateEngine, TemplateFormat, TemplateVariant,
6};
7use fuzzy_matcher::FuzzyMatcher;
8use fuzzy_matcher::skim::SkimMatcherV2;
9use regex::Regex;
10use serde_yaml_neo::{Mapping, Value};
11use std::collections::HashMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14use walkdir::WalkDir;
15
16#[derive(Debug, Default, Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub struct BodySectionPatch {
32 pub context: Option<String>,
34 pub decision: Option<String>,
36 pub consequences: Option<String>,
38}
39
40impl BodySectionPatch {
41 pub fn new() -> Self {
43 Self {
44 context: None,
45 decision: None,
46 consequences: None,
47 }
48 }
49
50 pub fn is_empty(&self) -> bool {
52 self.context.is_none() && self.decision.is_none() && self.consequences.is_none()
53 }
54
55 pub fn with_context(mut self, text: impl Into<String>) -> Self {
57 self.context = Some(text.into());
58 self
59 }
60
61 pub fn with_decision(mut self, text: impl Into<String>) -> Self {
63 self.decision = Some(text.into());
64 self
65 }
66
67 pub fn with_consequences(mut self, text: impl Into<String>) -> Self {
69 self.consequences = Some(text.into());
70 self
71 }
72}
73
74#[derive(Debug, Default, Clone)]
78#[non_exhaustive]
79pub struct RenumberResult {
80 pub from: u32,
82 pub to: u32,
84 pub no_op: bool,
86 pub renamed_file: Option<(PathBuf, PathBuf)>,
88 pub frontmatter_updated: bool,
91 pub h1_updated: bool,
95 pub updated_references: Vec<PathBuf>,
99 pub prose_warnings: Vec<PathBuf>,
102 pub ambiguous_references: Vec<PathBuf>,
106}
107
108#[derive(Debug)]
110pub struct Repository {
111 root: PathBuf,
113
114 config: Config,
116
117 parser: Parser,
119
120 template_engine: TemplateEngine,
122}
123
124impl Repository {
125 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
127 let root = root.into();
128 let config = Config::load(&root)?;
129 let template_engine = Self::engine_from_config(&config);
130
131 Ok(Self {
132 root,
133 config,
134 parser: Parser::new(),
135 template_engine,
136 })
137 }
138
139 pub fn open_or_default(root: impl Into<PathBuf>) -> Self {
141 let root = root.into();
142 let config = Config::load_or_default(&root);
143 let template_engine = Self::engine_from_config(&config);
144
145 Self {
146 root,
147 config,
148 parser: Parser::new(),
149 template_engine,
150 }
151 }
152
153 pub fn init(root: impl Into<PathBuf>, adr_dir: Option<PathBuf>, ng: bool) -> Result<Self> {
162 let root = root.into();
163 let adr_dir = adr_dir.unwrap_or_else(|| PathBuf::from(crate::config::DEFAULT_ADR_DIR));
164
165 let legacy_path = root.join(crate::config::LEGACY_CONFIG_FILE);
166 let toml_path = root.join(crate::config::CONFIG_FILE);
167
168 let existing_config = if legacy_path.exists() || toml_path.exists() {
171 Config::load(&root).ok().filter(|c| c.adr_dir == adr_dir)
172 } else {
173 None
174 };
175
176 let adr_path = root.join(&adr_dir);
177
178 let existing_adrs = if adr_path.exists() {
180 count_existing_adrs(&adr_path)
181 } else {
182 fs::create_dir_all(&adr_path)?;
184 0
185 };
186
187 let config = match existing_config {
188 Some(existing) => existing,
189 None => {
190 let config = Config {
192 adr_dir,
193 mode: if ng {
194 ConfigMode::NextGen
195 } else {
196 ConfigMode::Compatible
197 },
198 ..Default::default()
199 };
200 let stale = if config.is_next_gen() {
205 &legacy_path
206 } else {
207 &toml_path
208 };
209 if stale.exists() {
210 fs::remove_file(stale)?;
211 }
212 config.save(&root)?;
213 config
214 }
215 };
216
217 let template_engine = Self::engine_from_config(&config);
218
219 let repo = Self {
220 root,
221 config,
222 parser: Parser::new(),
223 template_engine,
224 };
225
226 if existing_adrs == 0 {
228 let mut adr = Adr::new(1, crate::init_adr::TITLE);
229 adr.status = AdrStatus::Accepted;
230 adr.context = crate::init_adr::CONTEXT.into();
231 adr.decision = crate::init_adr::DECISION.into();
232 adr.consequences = crate::init_adr::CONSEQUENCES.into();
233 repo.create(&adr)?;
234 }
235
236 Ok(repo)
237 }
238
239 pub fn root(&self) -> &Path {
241 &self.root
242 }
243
244 pub fn config(&self) -> &Config {
246 &self.config
247 }
248
249 pub fn adr_path(&self) -> PathBuf {
251 self.config.adr_path(&self.root)
252 }
253
254 fn engine_from_config(config: &Config) -> TemplateEngine {
256 let mut engine = TemplateEngine::new();
257 if let Some(ref fmt) = config.templates.format
258 && let Ok(format) = fmt.parse::<TemplateFormat>()
259 {
260 engine = engine.with_format(format);
261 }
262 engine
263 }
264
265 pub fn with_template_format(mut self, format: TemplateFormat) -> Self {
267 self.template_engine = self.template_engine.with_format(format);
268 self
269 }
270
271 pub fn with_template_variant(mut self, variant: TemplateVariant) -> Self {
273 self.template_engine = self.template_engine.with_variant(variant);
274 self
275 }
276
277 pub fn with_mode(mut self, mode: ConfigMode) -> Self {
279 self.config.mode = mode;
280 self
281 }
282
283 pub fn with_custom_template(mut self, template: Template) -> Self {
285 self.template_engine = self.template_engine.with_custom_template(template);
286 self
287 }
288
289 pub fn list(&self) -> Result<Vec<Adr>> {
291 let adr_path = self.adr_path();
292 if !adr_path.exists() {
293 return Err(Error::AdrDirNotFound);
294 }
295
296 let mut adrs: Vec<Adr> = WalkDir::new(&adr_path)
297 .max_depth(1)
298 .into_iter()
299 .filter_map(|e| e.ok())
300 .filter(|e| {
301 e.path().extension().is_some_and(|ext| ext == "md")
302 && e.path()
303 .file_name()
304 .and_then(|n| n.to_str())
305 .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
306 })
307 .filter_map(|e| self.parser.parse_file(e.path()).ok())
308 .collect();
309
310 adrs.sort_by_key(|a| a.number);
311 Ok(adrs)
312 }
313
314 #[allow(clippy::type_complexity)]
320 pub fn list_with_errors(&self) -> Result<(Vec<Adr>, Vec<(PathBuf, crate::Error)>)> {
321 let adr_path = self.adr_path();
322 if !adr_path.exists() {
323 return Err(Error::AdrDirNotFound);
324 }
325
326 let mut adrs = Vec::new();
327 let mut errors = Vec::new();
328
329 let candidates: Vec<_> = WalkDir::new(&adr_path)
330 .max_depth(1)
331 .into_iter()
332 .filter_map(|e| e.ok())
333 .filter(|e| {
334 e.path().extension().is_some_and(|ext| ext == "md")
335 && e.path()
336 .file_name()
337 .and_then(|n| n.to_str())
338 .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
339 })
340 .collect();
341
342 for entry in candidates {
343 match self.parser.parse_file(entry.path()) {
344 Ok(adr) => adrs.push(adr),
345 Err(e) => errors.push((entry.path().to_path_buf(), e)),
346 }
347 }
348
349 adrs.sort_by_key(|a| a.number);
350 Ok((adrs, errors))
351 }
352
353 pub fn next_number(&self) -> Result<u32> {
355 let adrs = self.list()?;
356 Ok(adrs.last().map(|a| a.number + 1).unwrap_or(1))
357 }
358
359 pub fn get(&self, number: u32) -> Result<Adr> {
361 let adrs = self.list()?;
362 adrs.into_iter()
363 .find(|a| a.number == number)
364 .ok_or_else(|| Error::AdrNotFound(number.to_string()))
365 }
366
367 pub fn find(&self, query: &str) -> Result<Adr> {
369 if let Ok(number) = query.parse::<u32>() {
371 return self.get(number);
372 }
373
374 let adrs = self.list()?;
376 let matcher = SkimMatcherV2::default();
377
378 let mut matches: Vec<_> = adrs
379 .into_iter()
380 .filter_map(|adr| {
381 let score = matcher.fuzzy_match(&adr.title, query)?;
382 Some((adr, score))
383 })
384 .collect();
385
386 matches.sort_by_key(|m| std::cmp::Reverse(m.1));
387
388 match matches.len() {
389 0 => Err(Error::AdrNotFound(query.to_string())),
390 1 => Ok(matches.remove(0).0),
391 _ => {
392 if matches[0].1 > matches[1].1 * 2 {
394 Ok(matches.remove(0).0)
395 } else {
396 Err(Error::AmbiguousAdr {
397 query: query.to_string(),
398 matches: matches
399 .iter()
400 .take(5)
401 .map(|(a, _)| a.title.clone())
402 .collect(),
403 })
404 }
405 }
406 }
407 }
408
409 fn resolve_link_titles(&self, adr: &Adr) -> HashMap<u32, (String, String)> {
411 let mut map = HashMap::new();
412 for link in &adr.links {
413 if map.contains_key(&link.target) {
414 continue;
415 }
416 if let Ok(target_adr) = self.get(link.target) {
417 map.insert(
418 link.target,
419 (target_adr.title.clone(), Self::link_href(&target_adr)),
420 );
421 }
422 }
423 map
424 }
425
426 fn link_href(target_adr: &Adr) -> String {
434 target_adr
435 .path
436 .as_ref()
437 .and_then(|p| p.file_name())
438 .and_then(|f| f.to_str())
439 .map(str::to_string)
440 .unwrap_or_else(|| target_adr.filename())
441 }
442
443 pub fn create(&self, adr: &Adr) -> Result<PathBuf> {
445 let path = self.adr_path().join(adr.filename());
446
447 let link_titles = self.resolve_link_titles(adr);
448 let content = self
449 .template_engine
450 .render(adr, &self.config, &link_titles)?;
451 fs::write(&path, content)?;
452
453 Ok(path)
454 }
455
456 pub fn new_adr(&self, title: impl Into<String>) -> Result<(Adr, PathBuf)> {
458 let number = self.next_number()?;
459 let mut adr = Adr::new(number, title);
460 if let Some(default_status) = self.config.default_status.as_deref() {
461 adr.status = default_status.parse::<AdrStatus>().unwrap();
462 }
463 let path = self.create(&adr)?;
464 Ok((adr, path))
465 }
466
467 pub fn supersede(&self, title: impl Into<String>, superseded: u32) -> Result<(Adr, PathBuf)> {
469 let number = self.next_number()?;
470 let mut adr = Adr::new(number, title);
471 adr.add_link(AdrLink::new(superseded, LinkKind::Supersedes));
472
473 let path = self.create(&adr)?;
476
477 let mut old_adr = self.get(superseded)?;
480 old_adr.status = AdrStatus::Superseded;
481 old_adr.add_link(AdrLink::new(number, LinkKind::SupersededBy));
482 self.update_metadata(&old_adr)?;
483
484 Ok((adr, path))
485 }
486
487 pub fn set_status(
492 &self,
493 number: u32,
494 status: AdrStatus,
495 superseded_by: Option<u32>,
496 ) -> Result<PathBuf> {
497 if let AdrStatus::Custom(s) = &status
501 && s.trim().is_empty()
502 {
503 return Err(Error::InvalidStatus(
504 "status cannot be empty or whitespace-only".to_string(),
505 ));
506 }
507
508 let mut adr = self.get(number)?;
509 adr.status = status.clone();
510
511 if let (AdrStatus::Superseded, Some(by)) = (&status, superseded_by) {
513 let _ = self.get(by)?;
515
516 if !adr
518 .links
519 .iter()
520 .any(|l| matches!(l.kind, LinkKind::SupersededBy) && l.target == by)
521 {
522 adr.add_link(AdrLink::new(by, LinkKind::SupersededBy));
523 }
524 }
525
526 self.update_metadata(&adr)
527 }
528
529 pub fn link(
531 &self,
532 source: u32,
533 target: u32,
534 source_kind: LinkKind,
535 target_kind: LinkKind,
536 ) -> Result<()> {
537 let mut source_adr = self.get(source)?;
538 let mut target_adr = self.get(target)?;
539
540 source_adr.add_link(AdrLink::new(target, source_kind));
541 target_adr.add_link(AdrLink::new(source, target_kind));
542
543 self.update_metadata(&source_adr)?;
544 self.update_metadata(&target_adr)?;
545
546 Ok(())
547 }
548
549 pub fn renumber(
589 &self,
590 from: u32,
591 to: u32,
592 file: Option<&Path>,
593 dry_run: bool,
594 ) -> Result<RenumberResult> {
595 let all = self.list()?;
596
597 let candidates: Vec<&Adr> = all.iter().filter(|a| a.number == from).collect();
598 if candidates.is_empty() {
599 return Err(Error::AdrNotFound(from.to_string()));
600 }
601 let source_was_ambiguous = candidates.len() > 1;
605
606 let source_adr: Adr = if let Some(file) = file {
607 let file_canon = fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
608 let found = candidates.iter().find(|a| {
609 a.path
610 .as_ref()
611 .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
612 == Some(file_canon.clone())
613 });
614 match found {
615 Some(adr) => (*adr).clone(),
616 None => {
617 return Err(Error::RenumberFileMismatch {
618 number: from,
619 file: file.to_path_buf(),
620 candidates: Self::renumber_candidate_paths(&candidates),
621 });
622 }
623 }
624 } else if candidates.len() > 1 {
625 return Err(Error::AmbiguousRenumberSource {
626 number: from,
627 candidates: Self::renumber_candidate_paths(&candidates),
628 });
629 } else {
630 candidates[0].clone()
631 };
632
633 if from == to {
634 return Ok(RenumberResult {
635 from,
636 to,
637 no_op: true,
638 ..Default::default()
639 });
640 }
641
642 if let Some(occupant) = all.iter().find(|a| a.number == to) {
643 return Err(Error::RenumberTargetOccupied {
644 to,
645 occupant_title: occupant.title.clone(),
646 occupant_path: occupant.path.clone().unwrap_or_default(),
647 suggestion: Self::smallest_free_number(&all),
648 });
649 }
650
651 let old_path = source_adr
657 .path
658 .clone()
659 .ok_or_else(|| Error::InvalidFormat {
660 path: PathBuf::new(),
661 reason: format!("ADR {from} has no on-disk path"),
662 })?;
663 let old_filename = old_path
664 .file_name()
665 .and_then(|f| f.to_str())
666 .ok_or_else(|| Error::InvalidFormat {
667 path: old_path.clone(),
668 reason: "filename is not valid UTF-8".into(),
669 })?
670 .to_string();
671 let prefix = format!("{from:04}-");
672 let slug_part =
673 old_filename
674 .strip_prefix(prefix.as_str())
675 .ok_or_else(|| Error::InvalidFormat {
676 path: old_path.clone(),
677 reason: format!("filename does not start with '{prefix}'"),
678 })?;
679 let new_filename = format!("{to:04}-{slug_part}");
680 let new_path = self.adr_path().join(&new_filename);
681
682 let original_own_content = fs::read_to_string(&old_path)?;
685 let mut target_adr = source_adr.clone();
686 target_adr.number = to;
687
688 let after_frontmatter = if Self::has_frontmatter(&original_own_content) {
689 self.update_frontmatter_metadata(&target_adr, &original_own_content)?
690 } else {
691 original_own_content.clone()
692 };
693 let frontmatter_updated = after_frontmatter != original_own_content;
694
695 let after_h1 = Self::rewrite_h1_number(&after_frontmatter, from, to);
696 let h1_updated = after_h1.is_some();
697 let final_own_content = after_h1.unwrap_or(after_frontmatter);
698
699 let mut updated_references = Vec::new();
708 let mut ambiguous_references = Vec::new();
709 let mut pending_writes: Vec<(PathBuf, String)> = Vec::new();
710
711 for adr in all
712 .iter()
713 .filter(|a| a.path.as_deref() != Some(old_path.as_path()))
714 {
715 let Some(path) = adr.path.clone() else {
716 continue;
717 };
718 let original = fs::read_to_string(&path)?;
719 let mut working = original.clone();
720 let mut this_changed = false;
721
722 if Self::has_frontmatter(&working) && adr.links.iter().any(|l| l.target == from) {
723 if source_was_ambiguous {
724 ambiguous_references.push(path.clone());
731 } else {
732 let mut mutated_adr = adr.clone();
733 for link in mutated_adr.links.iter_mut() {
734 if link.target == from {
735 link.target = to;
736 }
737 }
738 let rewritten = self.update_frontmatter_metadata(&mutated_adr, &working)?;
739 if rewritten != working {
740 working = rewritten;
741 this_changed = true;
742 }
743 }
744 }
745
746 if let Some(rewritten) =
747 Self::rewrite_body_link_references(&working, to, &old_filename, &new_filename)
748 {
749 working = rewritten;
750 this_changed = true;
751 }
752
753 if this_changed {
754 updated_references.push(path.clone());
755 pending_writes.push((path, working));
756 }
757 }
758
759 if !dry_run {
760 fs::rename(&old_path, &new_path)?;
761 fs::write(&new_path, &final_own_content)?;
762 for (path, content) in &pending_writes {
763 fs::write(path, content)?;
764 }
765 }
766
767 let prose_warnings = self.scan_prose_references(&old_filename);
768
769 Ok(RenumberResult {
770 from,
771 to,
772 no_op: false,
773 renamed_file: Some((old_path, new_path)),
774 frontmatter_updated,
775 h1_updated,
776 updated_references,
777 prose_warnings,
778 ambiguous_references,
779 })
780 }
781
782 fn renumber_candidate_paths(candidates: &[&Adr]) -> Vec<String> {
784 candidates
785 .iter()
786 .map(|a| {
787 a.path
788 .as_ref()
789 .map(|p| p.display().to_string())
790 .unwrap_or_else(|| format!("<no path for ADR {}>", a.number))
791 })
792 .collect()
793 }
794
795 fn smallest_free_number(adrs: &[Adr]) -> u32 {
798 let existing: std::collections::HashSet<u32> = adrs.iter().map(|a| a.number).collect();
799 let mut n = 1;
800 while existing.contains(&n) {
801 n += 1;
802 }
803 n
804 }
805
806 fn rewrite_h1_number(content: &str, from: u32, to: u32) -> Option<String> {
814 let crlf = content.contains("\r\n");
815 let normalized = if crlf {
816 std::borrow::Cow::Owned(content.replace("\r\n", "\n"))
817 } else {
818 std::borrow::Cow::Borrowed(content)
819 };
820
821 let old_prefix = format!("# {from}. ");
822 let new_prefix = format!("# {to}. ");
823
824 let mut found_h1 = false;
825 let mut changed = false;
826 let mut result = String::with_capacity(normalized.len() + 4);
827
828 for (i, line) in normalized.split('\n').enumerate() {
829 if i > 0 {
830 result.push('\n');
831 }
832 if !found_h1 && line.starts_with("# ") {
833 found_h1 = true;
834 if let Some(rest) = line.strip_prefix(old_prefix.as_str()) {
835 result.push_str(&new_prefix);
836 result.push_str(rest);
837 changed = true;
838 continue;
839 }
840 }
841 result.push_str(line);
842 }
843
844 if !changed {
845 return None;
846 }
847
848 Some(if crlf {
849 result.replace('\n', "\r\n")
850 } else {
851 result
852 })
853 }
854
855 fn rewrite_body_link_references(
865 content: &str,
866 to: u32,
867 old_filename: &str,
868 new_filename: &str,
869 ) -> Option<String> {
870 if !content.contains(old_filename) {
871 return None;
872 }
873
874 let crlf = content.contains("\r\n");
875 let normalized = if crlf {
876 std::borrow::Cow::Owned(content.replace("\r\n", "\n"))
877 } else {
878 std::borrow::Cow::Borrowed(content)
879 };
880
881 let link_pattern = format!(r"\[([^\]]*)\]\({}\)", regex::escape(old_filename));
882 let link_re = Regex::new(&link_pattern).expect("valid regex");
883 let number_prefix_re = Regex::new(r"^(\d+)(\..*)?$").expect("valid regex");
884
885 let mut changed = false;
886 let rewritten = link_re
887 .replace_all(&normalized, |caps: ®ex::Captures| {
888 changed = true;
889 let text = caps.get(1).map(|m| m.as_str()).unwrap_or("");
890 let new_text = match number_prefix_re.captures(text) {
891 Some(tc) => format!("{to}{}", tc.get(2).map(|m| m.as_str()).unwrap_or("")),
892 None => text.to_string(),
893 };
894 format!("[{new_text}]({new_filename})")
895 })
896 .into_owned();
897
898 if !changed {
899 return None;
900 }
901
902 Some(if crlf {
903 rewritten.replace('\n', "\r\n")
904 } else {
905 rewritten
906 })
907 }
908
909 fn scan_prose_references(&self, old_filename: &str) -> Vec<PathBuf> {
914 let adr_dir = self.adr_path();
915 let mut matches: Vec<PathBuf> = WalkDir::new(&self.root)
916 .into_iter()
917 .filter_entry(|e| {
918 if !e.file_type().is_dir() {
919 return true;
920 }
921 !matches!(
922 e.file_name().to_str(),
923 Some(".git") | Some("target") | Some("node_modules")
924 )
925 })
926 .filter_map(|e| e.ok())
927 .filter(|e| e.file_type().is_file())
928 .map(|e| e.path().to_path_buf())
929 .filter(|p| !p.starts_with(&adr_dir))
930 .filter(|p| {
931 fs::read_to_string(p)
932 .map(|content| content.contains(old_filename))
933 .unwrap_or(false)
934 })
935 .collect();
936
937 matches.sort();
938 matches
939 }
940
941 pub fn update(&self, adr: &Adr, body: BodySectionPatch) -> Result<PathBuf> {
958 let path = adr
959 .path
960 .clone()
961 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
962
963 let content = fs::read_to_string(&path)?;
964
965 let content = if body.is_empty() {
966 if Self::has_frontmatter(&content) {
967 self.update_frontmatter_metadata(adr, &content)?
968 } else {
969 self.update_legacy_metadata(adr, &content)?
970 }
971 } else {
972 content
973 };
974
975 let updated = if body.is_empty() {
976 content
977 } else {
978 self.update_body_sections(&content, &body)?
979 };
980 fs::write(&path, updated)?;
981
982 Ok(path)
983 }
984
985 pub fn read_content(&self, adr: &Adr) -> Result<String> {
987 let path = adr
988 .path
989 .as_ref()
990 .cloned()
991 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
992
993 Ok(fs::read_to_string(path)?)
994 }
995
996 pub fn write_content(&self, adr: &Adr, content: &str) -> Result<PathBuf> {
998 let path = adr
999 .path
1000 .as_ref()
1001 .cloned()
1002 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
1003
1004 fs::write(&path, content)?;
1005 Ok(path)
1006 }
1007
1008 pub fn update_metadata(&self, adr: &Adr) -> Result<PathBuf> {
1011 let path = adr
1012 .path
1013 .clone()
1014 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
1015
1016 let content = fs::read_to_string(&path)?;
1017
1018 let updated = if Self::has_frontmatter(&content) {
1019 self.update_frontmatter_metadata(adr, &content)?
1020 } else {
1021 self.update_legacy_metadata(adr, &content)?
1022 };
1023
1024 fs::write(&path, updated)?;
1025 Ok(path)
1026 }
1027
1028 fn has_frontmatter(content: &str) -> bool {
1033 content.starts_with("---\n") || content.starts_with("---\r\n")
1034 }
1035
1036 fn update_frontmatter_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
1062 let crlf = content.contains("\r\n");
1063 let normalized = if crlf {
1064 std::borrow::Cow::Owned(content.replace("\r\n", "\n"))
1065 } else {
1066 std::borrow::Cow::Borrowed(content)
1067 };
1068 let normalized: &str = &normalized;
1069
1070 let Some(rest) = normalized.strip_prefix("---\n") else {
1072 return Err(Error::InvalidFormat {
1073 path: Default::default(),
1074 reason: "Missing opening frontmatter delimiter".into(),
1075 });
1076 };
1077
1078 let Some(end_idx) = rest.find("\n---\n").or_else(|| {
1079 if rest.ends_with("\n---") {
1081 Some(rest.len() - 3)
1082 } else {
1083 None
1084 }
1085 }) else {
1086 return Err(Error::InvalidFormat {
1087 path: Default::default(),
1088 reason: "Missing closing frontmatter delimiter".into(),
1089 });
1090 };
1091
1092 let yaml_block = &rest[..end_idx + 1]; let after_yaml = &rest[end_idx..]; let parsed: Value = serde_yaml_neo::from_str(yaml_block)?;
1096 let Value::Mapping(mut map) = parsed else {
1097 return Err(Error::InvalidFormat {
1098 path: Default::default(),
1099 reason: "Frontmatter YAML must be a mapping".into(),
1100 });
1101 };
1102
1103 let mut dirty = false;
1104
1105 let number_val = serde_yaml_neo::to_value(adr.number)?;
1107 if map.get(Self::yaml_str_key("number")) != Some(&number_val) {
1108 map.insert(Self::yaml_str_key("number"), number_val);
1109 dirty = true;
1110 }
1111
1112 let status_val = Value::String(adr.status.to_string().to_lowercase());
1114 if map.get(Self::yaml_str_key("status")) != Some(&status_val) {
1115 map.insert(Self::yaml_str_key("status"), status_val);
1116 dirty = true;
1117 }
1118
1119 if Self::set_yaml_sequence_field(&mut map, "links", &adr.links)? {
1121 dirty = true;
1122 }
1123
1124 if !Self::yaml_string_list_matches(&map, "tags", &adr.tags)
1126 && Self::set_yaml_string_list_field(&mut map, "tags", &adr.tags)?
1127 {
1128 dirty = true;
1129 }
1130
1131 if !Self::yaml_string_list_matches(&map, "decision-makers", &adr.decision_makers)
1134 && Self::set_yaml_string_list_field(&mut map, "decision-makers", &adr.decision_makers)?
1135 {
1136 dirty = true;
1137 }
1138 if !Self::yaml_string_list_matches(&map, "consulted", &adr.consulted)
1139 && Self::set_yaml_string_list_field(&mut map, "consulted", &adr.consulted)?
1140 {
1141 dirty = true;
1142 }
1143 if !Self::yaml_string_list_matches(&map, "informed", &adr.informed)
1144 && Self::set_yaml_string_list_field(&mut map, "informed", &adr.informed)?
1145 {
1146 dirty = true;
1147 }
1148
1149 if !dirty {
1150 return Ok(content.to_string());
1151 }
1152
1153 let new_yaml = serde_yaml_neo::to_string(&Value::Mapping(map))?;
1154 let new_yaml = new_yaml.trim_end_matches('\n');
1155 let result = format!("---\n{new_yaml}{after_yaml}");
1156 if crlf {
1157 Ok(result.replace('\n', "\r\n"))
1158 } else {
1159 Ok(result)
1160 }
1161 }
1162
1163 fn update_legacy_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
1168 let ending = if content.contains("\r\n") {
1175 "\r\n"
1176 } else {
1177 "\n"
1178 };
1179 let lines: Vec<&str> = content.lines().collect();
1180 let mut result = String::with_capacity(content.len());
1181
1182 let status_idx = lines.iter().position(|l| {
1184 l.trim().eq_ignore_ascii_case("## Status") || l.trim().eq_ignore_ascii_case("## STATUS")
1185 });
1186
1187 let Some(status_idx) = status_idx else {
1188 return Ok(content.to_string());
1190 };
1191
1192 let next_heading_idx = lines[status_idx + 1..]
1194 .iter()
1195 .position(|l| l.starts_with("## "))
1196 .map(|i| i + status_idx + 1);
1197
1198 for line in &lines[..=status_idx] {
1200 result.push_str(line);
1201 result.push_str(ending);
1202 }
1203
1204 result.push_str(ending);
1206 result.push_str(&adr.status.to_string());
1207 result.push_str(ending);
1208
1209 let link_titles = self.resolve_link_titles(adr);
1211 for link in &adr.links {
1212 result.push_str(ending);
1213 if let Some((title, filename)) = link_titles.get(&link.target) {
1214 result.push_str(&format!(
1215 "{} [{}. {}]({})",
1216 link.kind, link.target, title, filename
1217 ));
1218 } else {
1219 result.push_str(&format!(
1220 "{} [{}. ...]({:04}-....md)",
1221 link.kind, link.target, link.target
1222 ));
1223 }
1224 result.push_str(ending);
1225 }
1226
1227 if let Some(next_idx) = next_heading_idx {
1229 result.push_str(ending);
1230 for (i, line) in lines[next_idx..].iter().enumerate() {
1231 result.push_str(line);
1232 if next_idx + i < lines.len() - 1 || content.ends_with('\n') {
1234 result.push_str(ending);
1235 }
1236 }
1237 } else if content.ends_with('\n') {
1238 }
1240
1241 Ok(result)
1242 }
1243
1244 fn update_body_sections(&self, content: &str, patch: &BodySectionPatch) -> Result<String> {
1254 let ending = if content.contains("\r\n") {
1255 "\r\n"
1256 } else {
1257 "\n"
1258 };
1259 let lines: Vec<&str> = content.lines().collect();
1260 let mut result = String::with_capacity(content.len());
1261 let mut i = 0;
1262 let mut found_context = patch.context.is_none();
1263 let mut found_decision = patch.decision.is_none();
1264 let mut found_consequences = patch.consequences.is_none();
1265
1266 while i < lines.len() {
1267 let line = lines[i];
1268 if Self::is_h2_outside_fence(&lines, i)
1269 && let Some(heading_text) = line.strip_prefix("## ")
1270 && let Some(field) = crate::parse::canonical_section_field(heading_text.trim())
1271 {
1272 result.push_str(line);
1273 result.push_str(ending);
1274 i += 1;
1275 let body_end = Self::next_h2_index(&lines, i);
1276 let next_is_heading = body_end < lines.len();
1280
1281 match field {
1282 "context" => {
1283 if patch.context.is_some() {
1284 found_context = true;
1285 }
1286 if let Some(ref text) = patch.context {
1287 Self::write_section_body(&mut result, text, ending, next_is_heading);
1288 } else {
1289 Self::append_lines(&mut result, &lines, i, body_end, content, ending);
1290 }
1291 }
1292 "decision" => {
1293 let madr_decision_outcome =
1294 heading_text.trim().eq_ignore_ascii_case("Decision Outcome");
1295 let (decision_found, consequences_applied) = Self::patch_decision_section(
1296 &mut result,
1297 &lines,
1298 content,
1299 i,
1300 body_end,
1301 patch,
1302 madr_decision_outcome,
1303 ending,
1304 );
1305 if decision_found {
1306 found_decision = true;
1307 }
1308 if consequences_applied {
1309 found_consequences = true;
1310 }
1311 }
1312 "consequences" => {
1313 if patch.consequences.is_some() {
1314 found_consequences = true;
1315 }
1316 if let Some(ref text) = patch.consequences {
1317 Self::write_section_body(&mut result, text, ending, next_is_heading);
1318 } else {
1319 Self::append_lines(&mut result, &lines, i, body_end, content, ending);
1320 }
1321 }
1322 _ => {
1323 Self::append_lines(&mut result, &lines, i, body_end, content, ending);
1324 }
1325 }
1326
1327 i = body_end;
1328 continue;
1329 }
1330
1331 result.push_str(line);
1332 if i < lines.len() - 1 || content.ends_with('\n') {
1333 result.push_str(ending);
1334 }
1335 i += 1;
1336 }
1337
1338 if !found_context {
1339 return Err(Error::InvalidFormat {
1340 path: PathBuf::new(),
1341 reason: "context patch requested but no matching section heading found".into(),
1342 });
1343 }
1344 if !found_decision {
1345 return Err(Error::InvalidFormat {
1346 path: PathBuf::new(),
1347 reason: "decision patch requested but no matching section heading found".into(),
1348 });
1349 }
1350 if !found_consequences {
1351 return Err(Error::InvalidFormat {
1352 path: PathBuf::new(),
1353 reason: "consequences patch requested but no matching section heading found".into(),
1354 });
1355 }
1356
1357 if content.ends_with('\n') && !result.ends_with('\n') {
1358 result.push_str(ending);
1359 }
1360
1361 Ok(result)
1362 }
1363
1364 fn fence_run(line: &str) -> Option<(char, usize)> {
1367 let indent = line.chars().take_while(|c| *c == ' ').count();
1368 if indent >= 4 {
1369 return None;
1370 }
1371 let rest = &line[indent..];
1372 let ch = rest.chars().next()?;
1373 if ch != '`' && ch != '~' {
1374 return None;
1375 }
1376 let run = rest.chars().take_while(|c| *c == ch).count();
1377 if run < 3 {
1378 return None;
1379 }
1380 Some((ch, run))
1381 }
1382
1383 fn is_fence_close(line: &str, ch: char, open_len: usize) -> bool {
1386 let Some((close_ch, run)) = Self::fence_run(line) else {
1387 return false;
1388 };
1389 if close_ch != ch || run < open_len {
1390 return false;
1391 }
1392 let indent = line.chars().take_while(|c| *c == ' ').count();
1393 let after_run = &line[indent + run..];
1394 after_run.chars().all(|c| c == ' ' || c == '\t')
1395 }
1396
1397 fn in_fence_at_line(lines: &[&str], index: usize) -> bool {
1398 let mut open: Option<(char, usize)> = None;
1399 for line in &lines[..index] {
1400 match open {
1401 None => {
1402 if let Some((ch, run)) = Self::fence_run(line) {
1403 open = Some((ch, run));
1404 }
1405 }
1406 Some((ch, open_len)) => {
1407 if Self::is_fence_close(line, ch, open_len) {
1408 open = None;
1409 }
1410 }
1411 }
1412 }
1413 open.is_some()
1414 }
1415
1416 fn is_h2_outside_fence(lines: &[&str], index: usize) -> bool {
1417 lines[index].starts_with("## ") && !Self::in_fence_at_line(lines, index)
1418 }
1419
1420 fn is_h3_outside_fence(lines: &[&str], index: usize) -> bool {
1421 lines[index].starts_with("### ") && !Self::in_fence_at_line(lines, index)
1422 }
1423
1424 fn next_h2_index(lines: &[&str], start: usize) -> usize {
1425 lines[start..]
1426 .iter()
1427 .enumerate()
1428 .find(|(offset, _)| Self::is_h2_outside_fence(lines, start + offset))
1429 .map(|(offset, _)| start + offset)
1430 .unwrap_or(lines.len())
1431 }
1432
1433 fn append_lines(
1434 result: &mut String,
1435 lines: &[&str],
1436 start: usize,
1437 end: usize,
1438 content: &str,
1439 ending: &str,
1440 ) {
1441 for (offset, line) in lines[start..end].iter().enumerate() {
1442 result.push_str(line);
1443 if start + offset < end - 1 || end < lines.len() || content.ends_with('\n') {
1444 result.push_str(ending);
1445 }
1446 }
1447 }
1448
1449 fn write_section_body(result: &mut String, text: &str, ending: &str, blank_line_after: bool) {
1463 result.push_str(ending);
1464 if ending == "\n" {
1465 result.push_str(text);
1466 } else {
1467 result.push_str(&text.replace("\r\n", "\n").replace('\n', ending));
1468 }
1469 if !text.ends_with('\n') {
1470 result.push_str(ending);
1471 }
1472 if blank_line_after {
1473 result.push_str(ending);
1474 }
1475 }
1476
1477 fn is_consequences_h3(line: &str) -> bool {
1478 line.strip_prefix("### ")
1479 .is_some_and(|title| title.trim().eq_ignore_ascii_case("consequences"))
1480 }
1481
1482 fn is_consequences_h2(line: &str) -> bool {
1483 line.strip_prefix("## ")
1484 .is_some_and(|title| title.trim().eq_ignore_ascii_case("consequences"))
1485 }
1486
1487 #[allow(clippy::too_many_arguments)]
1492 fn patch_decision_section(
1493 result: &mut String,
1494 lines: &[&str],
1495 content: &str,
1496 body_start: usize,
1497 body_end: usize,
1498 patch: &BodySectionPatch,
1499 madr_decision_outcome: bool,
1500 ending: &str,
1501 ) -> (bool, bool) {
1502 if patch.decision.is_none() && patch.consequences.is_none() {
1503 Self::append_lines(result, lines, body_start, body_end, content, ending);
1504 return (false, false);
1505 }
1506
1507 let mut consequences_applied = false;
1508
1509 if patch.decision.is_none()
1513 && patch.consequences.is_some()
1514 && Self::has_consequences_h2(lines)
1515 {
1516 Self::append_lines(result, lines, body_start, body_end, content, ending);
1517 return (true, false);
1518 }
1519
1520 let body = &lines[body_start..body_end];
1521 let first_h3 = body
1522 .iter()
1523 .enumerate()
1524 .find(|(offset, _)| Self::is_h3_outside_fence(lines, body_start + offset))
1525 .map(|(offset, _)| body_start + offset);
1526 let intro_end = first_h3.unwrap_or(body_end);
1527
1528 let will_append_madr_consequences = intro_end >= body_end
1532 && patch.consequences.is_some()
1533 && !Self::has_consequences_h2(lines)
1534 && madr_decision_outcome;
1535
1536 if let Some(ref text) = patch.decision {
1537 let blank_line_after = if intro_end < body_end {
1538 true
1540 } else {
1541 will_append_madr_consequences || body_end < lines.len()
1542 };
1543 Self::write_section_body(result, text, ending, blank_line_after);
1544 } else {
1545 Self::append_lines(result, lines, body_start, intro_end, content, ending);
1546 }
1547
1548 if intro_end >= body_end {
1549 if let Some(ref text) = patch.consequences
1550 && !Self::has_consequences_h2(lines)
1551 && madr_decision_outcome
1552 {
1553 Self::write_madr_consequences_subsection(
1554 result,
1555 text,
1556 ending,
1557 body_end < lines.len(),
1558 );
1559 consequences_applied = true;
1560 }
1561 return (true, consequences_applied);
1562 }
1563
1564 let mut j = intro_end;
1565
1566 while j < body_end {
1567 if !Self::is_h3_outside_fence(lines, j) {
1568 j += 1;
1569 continue;
1570 }
1571
1572 let sub_start = j;
1573 let sub_end = lines[sub_start + 1..body_end]
1574 .iter()
1575 .enumerate()
1576 .find(|(offset, _)| {
1577 let idx = sub_start + 1 + offset;
1578 Self::is_h2_outside_fence(lines, idx) || Self::is_h3_outside_fence(lines, idx)
1579 })
1580 .map(|(offset, _)| sub_start + 1 + offset)
1581 .unwrap_or(body_end);
1582
1583 if Self::is_consequences_h3(lines[sub_start])
1584 && let Some(ref text) = patch.consequences
1585 {
1586 consequences_applied = true;
1587 result.push_str(lines[sub_start]);
1588 result.push_str(ending);
1589 let blank_line_after = sub_end < body_end || body_end < lines.len();
1592 Self::write_section_body(result, text, ending, blank_line_after);
1593 j = sub_end;
1594 continue;
1595 }
1596
1597 Self::append_lines(result, lines, sub_start, sub_end, content, ending);
1598 j = sub_end;
1599 }
1600
1601 if let Some(ref text) = patch.consequences
1602 && !consequences_applied
1603 && !Self::has_consequences_h2(lines)
1604 && madr_decision_outcome
1605 {
1606 Self::write_madr_consequences_subsection(result, text, ending, body_end < lines.len());
1607 consequences_applied = true;
1608 }
1609
1610 (true, consequences_applied)
1611 }
1612
1613 fn has_consequences_h2(lines: &[&str]) -> bool {
1615 lines.iter().enumerate().any(|(idx, line)| {
1616 Self::is_h2_outside_fence(lines, idx) && Self::is_consequences_h2(line)
1617 })
1618 }
1619
1620 fn write_madr_consequences_subsection(
1621 result: &mut String,
1622 text: &str,
1623 ending: &str,
1624 blank_line_after: bool,
1625 ) {
1626 if !result.is_empty() && !result.ends_with('\n') {
1629 result.push_str(ending);
1630 }
1631 result.push_str("### Consequences");
1632 result.push_str(ending);
1633 Self::write_section_body(result, text, ending, blank_line_after);
1634 }
1635
1636 fn yaml_str_key(key: &str) -> Value {
1637 Value::String(key.to_string())
1638 }
1639
1640 fn set_yaml_sequence_field<T: serde::Serialize>(
1642 map: &mut Mapping,
1643 key: &str,
1644 values: &[T],
1645 ) -> Result<bool> {
1646 let key = Self::yaml_str_key(key);
1647 if values.is_empty() {
1648 return Ok(map.remove(&key).is_some());
1649 }
1650 let desired = serde_yaml_neo::to_value(values)?;
1651 if map.get(&key) == Some(&desired) {
1652 return Ok(false);
1653 }
1654 map.insert(key, desired);
1655 Ok(true)
1656 }
1657
1658 fn set_yaml_string_list_field(map: &mut Mapping, key: &str, values: &[String]) -> Result<bool> {
1660 Self::set_yaml_sequence_field(map, key, values)
1661 }
1662
1663 fn yaml_string_list_matches(map: &Mapping, key: &str, desired: &[String]) -> bool {
1666 match map.get(Self::yaml_str_key(key)) {
1667 None | Some(Value::Null) => desired.is_empty(),
1668 Some(Value::String(s)) => desired.len() == 1 && desired[0] == *s,
1669 Some(Value::Sequence(seq)) => {
1670 let got: Option<Vec<&str>> = seq.iter().map(|v| v.as_str()).collect();
1671 match got {
1672 Some(got) => got == desired.iter().map(String::as_str).collect::<Vec<_>>(),
1673 None => false,
1674 }
1675 }
1676 Some(_) => false,
1677 }
1678 }
1679}
1680
1681fn count_existing_adrs(path: &Path) -> usize {
1683 if !path.is_dir() {
1684 return 0;
1685 }
1686
1687 fs::read_dir(path)
1688 .map(|entries| {
1689 entries
1690 .filter_map(|e| e.ok())
1691 .filter(|e| {
1692 let path = e.path();
1693 path.is_file()
1694 && path.extension().is_some_and(|ext| ext == "md")
1695 && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
1696 n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
1698 })
1699 })
1700 .count()
1701 })
1702 .unwrap_or(0)
1703}
1704
1705#[cfg(test)]
1706mod tests {
1707 use super::*;
1708 use tempfile::TempDir;
1709
1710 #[test]
1713 fn test_init_repository() {
1714 let temp = TempDir::new().unwrap();
1715 let repo = Repository::init(temp.path(), None, false).unwrap();
1716
1717 assert!(repo.adr_path().exists());
1718 assert!(temp.path().join(".adr-dir").exists());
1719
1720 let adrs = repo.list().unwrap();
1721 assert_eq!(adrs.len(), 1);
1722 assert_eq!(adrs[0].number, 1);
1723 assert_eq!(adrs[0].title, "Record architecture decisions");
1724 }
1725
1726 #[test]
1727 fn test_init_repository_ng() {
1728 let temp = TempDir::new().unwrap();
1729 let repo = Repository::init(temp.path(), None, true).unwrap();
1730
1731 assert!(temp.path().join("adrs.toml").exists());
1732 assert!(repo.config().is_next_gen());
1733 }
1734
1735 #[test]
1736 fn test_init_repository_custom_dir() {
1737 let temp = TempDir::new().unwrap();
1738 let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
1739
1740 assert!(temp.path().join("decisions").exists());
1741 assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
1742 }
1743
1744 #[test]
1745 fn test_init_repository_nested_dir() {
1746 let temp = TempDir::new().unwrap();
1747 let _repo =
1748 Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
1749
1750 assert!(temp.path().join("docs/architecture/adr").exists());
1751 }
1752
1753 #[test]
1754 fn test_init_repository_already_exists_skips_initial_adr() {
1755 let temp = TempDir::new().unwrap();
1756 Repository::init(temp.path(), None, false).unwrap();
1757
1758 let repo = Repository::init(temp.path(), None, false).unwrap();
1760 let adrs = repo.list().unwrap();
1761 assert_eq!(adrs.len(), 1); }
1763
1764 #[test]
1765 fn test_init_with_existing_adrs_skips_initial() {
1766 let temp = TempDir::new().unwrap();
1767 let adr_dir = temp.path().join("doc/adr");
1768 fs::create_dir_all(&adr_dir).unwrap();
1769
1770 fs::write(
1772 adr_dir.join("0001-existing-decision.md"),
1773 "# 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",
1774 )
1775 .unwrap();
1776 fs::write(
1777 adr_dir.join("0002-another-decision.md"),
1778 "# 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",
1779 )
1780 .unwrap();
1781
1782 let repo = Repository::init(temp.path(), None, false).unwrap();
1784 let adrs = repo.list().unwrap();
1785 assert_eq!(adrs.len(), 2); assert_eq!(adrs[0].title, "Existing Decision");
1787 assert_eq!(adrs[1].title, "Another Decision");
1788 }
1789
1790 #[test]
1791 fn test_init_creates_first_adr() {
1792 let temp = TempDir::new().unwrap();
1793 let repo = Repository::init(temp.path(), None, false).unwrap();
1794
1795 let adr = repo.get(1).unwrap();
1796 assert_eq!(adr.title, crate::init_adr::TITLE);
1797 assert_eq!(adr.status, AdrStatus::Accepted);
1798 assert_eq!(adr.context, crate::init_adr::CONTEXT);
1799 assert_eq!(adr.decision, crate::init_adr::DECISION);
1800 assert_eq!(adr.consequences, crate::init_adr::CONSEQUENCES);
1801 }
1802
1803 #[test]
1804 fn test_init_first_adr_has_markdown_links_in_both_modes() {
1805 for ng in [false, true] {
1806 let temp = TempDir::new().unwrap();
1807 let repo = Repository::init(temp.path(), None, ng).unwrap();
1808 let path = repo
1809 .adr_path()
1810 .join("0001-record-architecture-decisions.md");
1811 let content = fs::read_to_string(path).unwrap();
1812
1813 assert!(
1814 content.contains(
1815 "[Documenting Architecture Decisions](https://www.cognitect.com/blog/2011/11/15/documenting-architecture-decisions)"
1816 ),
1817 "expected Nygard article link (ng={ng})"
1818 );
1819 assert!(
1820 content.contains("[adrs](https://github.com/joshrotenberg/adrs)"),
1821 "expected adrs link (ng={ng})"
1822 );
1823 assert!(
1824 content.contains("[adr-tools](https://github.com/npryce/adr-tools)"),
1825 "expected adr-tools link (ng={ng})"
1826 );
1827 assert!(
1828 content.ends_with('\n'),
1829 "init ADR should end with a newline (ng={ng})"
1830 );
1831 }
1832 }
1833
1834 #[test]
1837 fn test_open_repository() {
1838 let temp = TempDir::new().unwrap();
1839 Repository::init(temp.path(), None, false).unwrap();
1840
1841 let repo = Repository::open(temp.path()).unwrap();
1842 assert_eq!(repo.list().unwrap().len(), 1);
1843 }
1844
1845 #[test]
1846 fn test_open_repository_not_found() {
1847 let temp = TempDir::new().unwrap();
1848 let result = Repository::open(temp.path());
1849 assert!(result.is_err());
1850 }
1851
1852 #[test]
1853 fn test_open_or_default() {
1854 let temp = TempDir::new().unwrap();
1855 let repo = Repository::open_or_default(temp.path());
1856 assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
1857 }
1858
1859 #[test]
1860 fn test_open_or_default_existing() {
1861 let temp = TempDir::new().unwrap();
1862 Repository::init(temp.path(), Some("custom".into()), false).unwrap();
1863
1864 let repo = Repository::open_or_default(temp.path());
1865 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
1866 }
1867
1868 #[test]
1871 fn test_create_and_list() {
1872 let temp = TempDir::new().unwrap();
1873 let repo = Repository::init(temp.path(), None, false).unwrap();
1874
1875 let (adr, _) = repo.new_adr("Use Rust").unwrap();
1876 assert_eq!(adr.number, 2);
1877
1878 let adrs = repo.list().unwrap();
1879 assert_eq!(adrs.len(), 2);
1880 }
1881
1882 #[test]
1883 fn test_create_multiple() {
1884 let temp = TempDir::new().unwrap();
1885 let repo = Repository::init(temp.path(), None, false).unwrap();
1886
1887 repo.new_adr("Second").unwrap();
1888 repo.new_adr("Third").unwrap();
1889 repo.new_adr("Fourth").unwrap();
1890
1891 let adrs = repo.list().unwrap();
1892 assert_eq!(adrs.len(), 4);
1893 assert_eq!(adrs[0].number, 1);
1894 assert_eq!(adrs[1].number, 2);
1895 assert_eq!(adrs[2].number, 3);
1896 assert_eq!(adrs[3].number, 4);
1897 }
1898
1899 #[test]
1900 fn test_list_sorted_by_number() {
1901 let temp = TempDir::new().unwrap();
1902 let repo = Repository::init(temp.path(), None, false).unwrap();
1903
1904 repo.new_adr("B").unwrap();
1905 repo.new_adr("A").unwrap();
1906 repo.new_adr("C").unwrap();
1907
1908 let adrs = repo.list().unwrap();
1909 assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
1910 }
1911
1912 #[test]
1913 fn test_next_number() {
1914 let temp = TempDir::new().unwrap();
1915 let repo = Repository::init(temp.path(), None, false).unwrap();
1916
1917 assert_eq!(repo.next_number().unwrap(), 2);
1918
1919 repo.new_adr("Second").unwrap();
1920 assert_eq!(repo.next_number().unwrap(), 3);
1921 }
1922
1923 #[test]
1924 fn test_create_file_exists() {
1925 let temp = TempDir::new().unwrap();
1926 let repo = Repository::init(temp.path(), None, false).unwrap();
1927
1928 let (_, path) = repo.new_adr("Test ADR").unwrap();
1929 assert!(path.exists());
1930 assert!(path.to_string_lossy().contains("0002-test-adr.md"));
1931 }
1932
1933 #[test]
1934 fn test_new_adr_uses_custom_default_status_from_config() {
1935 let temp = TempDir::new().unwrap();
1936 Repository::init(temp.path(), None, false).unwrap();
1937
1938 std::fs::write(
1939 temp.path().join("adrs.toml"),
1940 r#"
1941adr_dir = "doc/adr"
1942mode = "compatible"
1943default_status = "draft"
1944"#,
1945 )
1946 .unwrap();
1947
1948 let repo = Repository::open(temp.path()).unwrap();
1949 let (adr, _) = repo.new_adr("Custom status ADR").unwrap();
1950
1951 assert_eq!(adr.status, AdrStatus::Custom("draft".into()));
1952 }
1953
1954 #[test]
1957 fn test_get_by_number() {
1958 let temp = TempDir::new().unwrap();
1959 let repo = Repository::init(temp.path(), None, false).unwrap();
1960 repo.new_adr("Second").unwrap();
1961
1962 let adr = repo.get(2).unwrap();
1963 assert_eq!(adr.title, "Second");
1964 }
1965
1966 #[test]
1967 fn test_get_not_found() {
1968 let temp = TempDir::new().unwrap();
1969 let repo = Repository::init(temp.path(), None, false).unwrap();
1970
1971 let result = repo.get(99);
1972 assert!(result.is_err());
1973 }
1974
1975 #[test]
1976 fn test_find_by_number() {
1977 let temp = TempDir::new().unwrap();
1978 let repo = Repository::init(temp.path(), None, false).unwrap();
1979
1980 let adr = repo.find("1").unwrap();
1981 assert_eq!(adr.number, 1);
1982 }
1983
1984 #[test]
1985 fn test_find_by_title() {
1986 let temp = TempDir::new().unwrap();
1987 let repo = Repository::init(temp.path(), None, false).unwrap();
1988
1989 let adr = repo.find("architecture").unwrap();
1990 assert_eq!(adr.number, 1);
1991 }
1992
1993 #[test]
1994 fn test_find_fuzzy_match() {
1995 let temp = TempDir::new().unwrap();
1996 let repo = Repository::init(temp.path(), None, false).unwrap();
1997 repo.new_adr("Use PostgreSQL for database").unwrap();
1998 repo.new_adr("Use Redis for caching").unwrap();
1999
2000 let adr = repo.find("postgres").unwrap();
2001 assert!(adr.title.contains("PostgreSQL"));
2002 }
2003
2004 #[test]
2005 fn test_find_not_found() {
2006 let temp = TempDir::new().unwrap();
2007 let repo = Repository::init(temp.path(), None, false).unwrap();
2008
2009 let result = repo.find("nonexistent");
2010 assert!(result.is_err());
2011 }
2012
2013 #[test]
2016 fn test_supersede() {
2017 let temp = TempDir::new().unwrap();
2018 let repo = Repository::init(temp.path(), None, false).unwrap();
2019
2020 let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
2021 assert_eq!(new_adr.number, 2);
2022 assert_eq!(new_adr.links.len(), 1);
2023 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
2024
2025 let old_adr = repo.get(1).unwrap();
2026 assert_eq!(old_adr.status, AdrStatus::Superseded);
2027 }
2028
2029 #[test]
2030 fn test_supersede_creates_bidirectional_links() {
2031 let temp = TempDir::new().unwrap();
2032 let repo = Repository::init(temp.path(), None, false).unwrap();
2033
2034 repo.supersede("New approach", 1).unwrap();
2035
2036 let old_adr = repo.get(1).unwrap();
2037 assert_eq!(old_adr.links.len(), 1);
2038 assert_eq!(old_adr.links[0].target, 2);
2039 assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
2040
2041 let new_adr = repo.get(2).unwrap();
2042 assert_eq!(new_adr.links.len(), 1);
2043 assert_eq!(new_adr.links[0].target, 1);
2044 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
2045 }
2046
2047 #[test]
2048 fn test_supersede_not_found() {
2049 let temp = TempDir::new().unwrap();
2050 let repo = Repository::init(temp.path(), None, false).unwrap();
2051
2052 let result = repo.supersede("New", 99);
2053 assert!(result.is_err());
2054 }
2055
2056 #[test]
2059 fn test_supersede_generates_functional_links() {
2060 let temp = TempDir::new().unwrap();
2061 let repo = Repository::init(temp.path(), None, false).unwrap();
2062
2063 repo.new_adr("Use MySQL for persistence").unwrap();
2065 repo.supersede("Use PostgreSQL instead", 2).unwrap();
2066
2067 let new_content =
2069 fs::read_to_string(repo.adr_path().join("0003-use-postgresql-instead.md")).unwrap();
2070 assert!(
2071 new_content.contains(
2072 "Supersedes [2. Use MySQL for persistence](0002-use-mysql-for-persistence.md)"
2073 ),
2074 "New ADR should have functional Supersedes link. Got:\n{new_content}"
2075 );
2076
2077 let old_content =
2079 fs::read_to_string(repo.adr_path().join("0002-use-mysql-for-persistence.md")).unwrap();
2080 assert!(
2081 old_content.contains(
2082 "Superseded by [3. Use PostgreSQL instead](0003-use-postgresql-instead.md)"
2083 ),
2084 "Old ADR should have functional Superseded by link. Got:\n{old_content}"
2085 );
2086 }
2087
2088 #[test]
2089 fn test_link_generates_functional_links() {
2090 let temp = TempDir::new().unwrap();
2091 let repo = Repository::init(temp.path(), None, false).unwrap();
2092
2093 repo.new_adr("Use REST API").unwrap();
2094 repo.new_adr("Use JSON for API responses").unwrap();
2095
2096 repo.link(3, 2, LinkKind::Amends, LinkKind::AmendedBy)
2097 .unwrap();
2098
2099 let source_content =
2101 fs::read_to_string(repo.adr_path().join("0003-use-json-for-api-responses.md")).unwrap();
2102 assert!(
2103 source_content.contains("Amends [2. Use REST API](0002-use-rest-api.md)"),
2104 "Source ADR should have functional Amends link. Got:\n{source_content}"
2105 );
2106
2107 let target_content =
2109 fs::read_to_string(repo.adr_path().join("0002-use-rest-api.md")).unwrap();
2110 assert!(
2111 target_content.contains(
2112 "Amended by [3. Use JSON for API responses](0003-use-json-for-api-responses.md)"
2113 ),
2114 "Target ADR should have functional Amended by link. Got:\n{target_content}"
2115 );
2116 }
2117
2118 #[test]
2119 fn test_set_status_superseded_generates_functional_link() {
2120 let temp = TempDir::new().unwrap();
2121 let repo = Repository::init(temp.path(), None, false).unwrap();
2122
2123 repo.new_adr("First Decision").unwrap();
2124 repo.new_adr("Second Decision").unwrap();
2125
2126 repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
2127
2128 let content = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
2129 assert!(
2130 content.contains("Superseded by [3. Second Decision](0003-second-decision.md)"),
2131 "ADR should have functional Superseded by link. Got:\n{content}"
2132 );
2133 }
2134
2135 #[test]
2136 fn test_supersede_chain_generates_functional_links() {
2137 let temp = TempDir::new().unwrap();
2138 let repo = Repository::init(temp.path(), None, false).unwrap();
2139
2140 repo.new_adr("Use SQLite").unwrap();
2143 repo.supersede("Use PostgreSQL", 2).unwrap();
2145 repo.supersede("Use CockroachDB", 3).unwrap();
2147
2148 let adr3_content =
2150 fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
2151 assert!(
2152 adr3_content.contains("Supersedes [2. Use SQLite](0002-use-sqlite.md)"),
2153 "ADR 3 should supersede ADR 2. Got:\n{adr3_content}"
2154 );
2155 assert!(
2156 adr3_content.contains("Superseded by [4. Use CockroachDB](0004-use-cockroachdb.md)"),
2157 "ADR 3 should be superseded by ADR 4. Got:\n{adr3_content}"
2158 );
2159 }
2160
2161 #[test]
2162 fn test_ng_mode_supersede_generates_functional_links() {
2163 let temp = TempDir::new().unwrap();
2164 let repo = Repository::init(temp.path(), None, true).unwrap();
2165
2166 repo.new_adr("Use MySQL").unwrap();
2167 repo.supersede("Use PostgreSQL", 2).unwrap();
2168
2169 let new_content =
2171 fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
2172
2173 assert!(
2175 new_content.contains("Supersedes [2. Use MySQL](0002-use-mysql.md)"),
2176 "NG mode should have functional link in body. Got:\n{new_content}"
2177 );
2178 assert!(new_content.contains("links:"));
2180 assert!(new_content.contains("target: 2"));
2181 }
2182
2183 #[test]
2186 fn test_set_status_accepted() {
2187 let temp = TempDir::new().unwrap();
2188 let repo = Repository::init(temp.path(), None, false).unwrap();
2189 repo.new_adr("Test Decision").unwrap();
2190
2191 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2192
2193 let adr = repo.get(2).unwrap();
2194 assert_eq!(adr.status, AdrStatus::Accepted);
2195 }
2196
2197 #[test]
2198 fn test_set_status_deprecated() {
2199 let temp = TempDir::new().unwrap();
2200 let repo = Repository::init(temp.path(), None, false).unwrap();
2201 repo.new_adr("Old Decision").unwrap();
2202
2203 repo.set_status(2, AdrStatus::Deprecated, None).unwrap();
2204
2205 let adr = repo.get(2).unwrap();
2206 assert_eq!(adr.status, AdrStatus::Deprecated);
2207 }
2208
2209 #[test]
2210 fn test_set_status_superseded_with_link() {
2211 let temp = TempDir::new().unwrap();
2212 let repo = Repository::init(temp.path(), None, false).unwrap();
2213 repo.new_adr("First Decision").unwrap();
2214 repo.new_adr("Second Decision").unwrap();
2215
2216 repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
2217
2218 let adr = repo.get(2).unwrap();
2219 assert_eq!(adr.status, AdrStatus::Superseded);
2220 assert_eq!(adr.links.len(), 1);
2221 assert_eq!(adr.links[0].target, 3);
2222 assert_eq!(adr.links[0].kind, LinkKind::SupersededBy);
2223 }
2224
2225 #[test]
2226 fn test_set_status_superseded_without_link() {
2227 let temp = TempDir::new().unwrap();
2228 let repo = Repository::init(temp.path(), None, false).unwrap();
2229 repo.new_adr("Decision").unwrap();
2230
2231 repo.set_status(2, AdrStatus::Superseded, None).unwrap();
2232
2233 let adr = repo.get(2).unwrap();
2234 assert_eq!(adr.status, AdrStatus::Superseded);
2235 assert_eq!(adr.links.len(), 0);
2236 }
2237
2238 #[test]
2239 fn test_set_status_custom() {
2240 let temp = TempDir::new().unwrap();
2241 let repo = Repository::init(temp.path(), None, false).unwrap();
2242 repo.new_adr("Test Decision").unwrap();
2243
2244 repo.set_status(2, AdrStatus::Custom("Draft".into()), None)
2245 .unwrap();
2246
2247 let adr = repo.get(2).unwrap();
2248 assert_eq!(adr.status, AdrStatus::Custom("Draft".into()));
2249 }
2250
2251 #[test]
2252 fn test_set_status_adr_not_found() {
2253 let temp = TempDir::new().unwrap();
2254 let repo = Repository::init(temp.path(), None, false).unwrap();
2255
2256 let result = repo.set_status(99, AdrStatus::Accepted, None);
2257 assert!(result.is_err());
2258 }
2259
2260 #[test]
2261 fn test_set_status_superseded_by_not_found() {
2262 let temp = TempDir::new().unwrap();
2263 let repo = Repository::init(temp.path(), None, false).unwrap();
2264 repo.new_adr("Decision").unwrap();
2265
2266 let result = repo.set_status(2, AdrStatus::Superseded, Some(99));
2267 assert!(result.is_err());
2268 }
2269
2270 #[test]
2273 fn test_link_adrs() {
2274 let temp = TempDir::new().unwrap();
2275 let repo = Repository::init(temp.path(), None, false).unwrap();
2276 repo.new_adr("Second").unwrap();
2277
2278 repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
2279 .unwrap();
2280
2281 let adr1 = repo.get(1).unwrap();
2282 assert_eq!(adr1.links.len(), 1);
2283 assert_eq!(adr1.links[0].target, 2);
2284 assert_eq!(adr1.links[0].kind, LinkKind::Amends);
2285
2286 let adr2 = repo.get(2).unwrap();
2287 assert_eq!(adr2.links.len(), 1);
2288 assert_eq!(adr2.links[0].target, 1);
2289 assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
2290 }
2291
2292 #[test]
2293 fn test_link_relates_to() {
2294 let temp = TempDir::new().unwrap();
2295 let repo = Repository::init(temp.path(), None, false).unwrap();
2296 repo.new_adr("Second").unwrap();
2297
2298 repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
2299 .unwrap();
2300
2301 let adr1 = repo.get(1).unwrap();
2302 assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
2303
2304 let adr2 = repo.get(2).unwrap();
2305 assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
2306 }
2307
2308 #[test]
2311 fn test_update_adr() {
2312 let temp = TempDir::new().unwrap();
2313 let repo = Repository::init(temp.path(), None, false).unwrap();
2314
2315 let mut adr = repo.get(1).unwrap();
2316 adr.status = AdrStatus::Deprecated;
2317
2318 repo.update(&adr, BodySectionPatch::default()).unwrap();
2319
2320 let updated = repo.get(1).unwrap();
2321 assert_eq!(updated.status, AdrStatus::Deprecated);
2322 }
2323
2324 #[test]
2325 fn test_update_preserves_content() {
2326 let temp = TempDir::new().unwrap();
2327 let repo = Repository::init(temp.path(), None, false).unwrap();
2328
2329 let mut adr = repo.get(1).unwrap();
2330 let original_title = adr.title.clone();
2331 adr.status = AdrStatus::Deprecated;
2332
2333 repo.update(&adr, BodySectionPatch::default()).unwrap();
2334
2335 let updated = repo.get(1).unwrap();
2336 assert_eq!(updated.title, original_title);
2337 }
2338
2339 #[test]
2342 fn test_read_content() {
2343 let temp = TempDir::new().unwrap();
2344 let repo = Repository::init(temp.path(), None, false).unwrap();
2345
2346 let adr = repo.get(1).unwrap();
2347 let content = repo.read_content(&adr).unwrap();
2348
2349 assert!(content.contains("Record architecture decisions"));
2350 assert!(content.contains("## Status"));
2351 }
2352
2353 #[test]
2354 fn test_write_content() {
2355 let temp = TempDir::new().unwrap();
2356 let repo = Repository::init(temp.path(), None, false).unwrap();
2357
2358 let adr = repo.get(1).unwrap();
2359 let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
2360
2361 repo.write_content(&adr, new_content).unwrap();
2362
2363 let content = repo.read_content(&adr).unwrap();
2364 assert!(content.contains("Modified"));
2365 }
2366
2367 #[test]
2370 fn test_with_mode_overrides_compatible_to_ng() {
2371 let temp = TempDir::new().unwrap();
2372 let repo = Repository::init(temp.path(), None, false)
2374 .unwrap()
2375 .with_mode(ConfigMode::NextGen);
2376
2377 let (_, path) = repo.new_adr("Mode Override Test").unwrap();
2378 let content = fs::read_to_string(path).unwrap();
2379
2380 assert!(
2381 content.starts_with("---\n"),
2382 "with_mode(NextGen) on compatible repo should produce YAML frontmatter. Got:\n{content}"
2383 );
2384 assert!(content.contains("status: proposed"));
2385 }
2386
2387 #[test]
2388 fn test_with_mode_ng_to_compatible() {
2389 let temp = TempDir::new().unwrap();
2390 let repo = Repository::init(temp.path(), None, true)
2392 .unwrap()
2393 .with_mode(ConfigMode::Compatible);
2394
2395 let (_, path) = repo.new_adr("Downgrade Mode Test").unwrap();
2396 let content = fs::read_to_string(path).unwrap();
2397
2398 assert!(
2399 !content.starts_with("---\n"),
2400 "with_mode(Compatible) on ng repo should NOT produce YAML frontmatter. Got:\n{content}"
2401 );
2402 }
2403
2404 #[test]
2407 fn test_with_template_format() {
2408 let temp = TempDir::new().unwrap();
2409 let repo = Repository::init(temp.path(), None, false)
2410 .unwrap()
2411 .with_template_format(TemplateFormat::Madr);
2412
2413 let (_, path) = repo.new_adr("MADR Test").unwrap();
2414 let content = fs::read_to_string(path).unwrap();
2415
2416 assert!(content.contains("Context and Problem Statement"));
2417 }
2418
2419 #[test]
2420 fn test_with_custom_template() {
2421 let temp = TempDir::new().unwrap();
2422 let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
2423 let repo = Repository::init(temp.path(), None, false)
2424 .unwrap()
2425 .with_custom_template(custom);
2426
2427 let (_, path) = repo.new_adr("Custom Test").unwrap();
2428 let content = fs::read_to_string(path).unwrap();
2429
2430 assert_eq!(content, "# ADR 2: Custom Test\n");
2431 }
2432
2433 #[test]
2436 fn test_root() {
2437 let temp = TempDir::new().unwrap();
2438 let repo = Repository::init(temp.path(), None, false).unwrap();
2439
2440 assert_eq!(repo.root(), temp.path());
2441 }
2442
2443 #[test]
2444 fn test_config() {
2445 let temp = TempDir::new().unwrap();
2446 let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
2447
2448 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
2449 assert!(repo.config().is_next_gen());
2450 }
2451
2452 #[test]
2453 fn test_adr_path() {
2454 let temp = TempDir::new().unwrap();
2455 let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
2456
2457 assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
2458 }
2459
2460 #[test]
2463 fn test_ng_mode_creates_frontmatter() {
2464 let temp = TempDir::new().unwrap();
2465 let repo = Repository::init(temp.path(), None, true).unwrap();
2466
2467 let (_, path) = repo.new_adr("NG Test").unwrap();
2468 let content = fs::read_to_string(path).unwrap();
2469
2470 assert!(content.starts_with("---"));
2471 assert!(content.contains("number: 2"));
2472 assert!(content.contains("title: NG Test"));
2473 }
2474
2475 #[test]
2476 fn test_ng_mode_parses_frontmatter() {
2477 let temp = TempDir::new().unwrap();
2478 let repo = Repository::init(temp.path(), None, true).unwrap();
2479
2480 repo.new_adr("NG ADR").unwrap();
2481
2482 let adr = repo.get(2).unwrap();
2483 assert_eq!(adr.title, "NG ADR");
2484 assert_eq!(adr.number, 2);
2485 }
2486
2487 #[test]
2490 fn test_list_empty_after_init_removal() {
2491 let temp = TempDir::new().unwrap();
2492 let repo = Repository::init(temp.path(), None, false).unwrap();
2493
2494 fs::remove_file(
2496 repo.adr_path()
2497 .join("0001-record-architecture-decisions.md"),
2498 )
2499 .unwrap();
2500
2501 let adrs = repo.list().unwrap();
2502 assert!(adrs.is_empty());
2503 }
2504
2505 #[test]
2506 fn test_list_ignores_non_adr_files() {
2507 let temp = TempDir::new().unwrap();
2508 let repo = Repository::init(temp.path(), None, false).unwrap();
2509
2510 fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
2512 fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
2513
2514 let adrs = repo.list().unwrap();
2515 assert_eq!(adrs.len(), 1); }
2517
2518 #[test]
2519 fn test_special_characters_in_title() {
2520 let temp = TempDir::new().unwrap();
2521 let repo = Repository::init(temp.path(), None, false).unwrap();
2522
2523 let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
2524 assert!(path.exists());
2525 assert_eq!(adr.title, "Use C++ & Rust!");
2526 }
2527
2528 #[test]
2531 fn test_set_status_preserves_madr_body() {
2532 let temp = TempDir::new().unwrap();
2533 let repo = Repository::init(temp.path(), None, true).unwrap();
2534
2535 let madr_content = r#"---
2536number: 2
2537title: Use Redis for caching
2538date: 2026-01-15
2539status: proposed
2540---
2541
2542# Use Redis for caching
2543
2544## Context and Problem Statement
2545
2546We need a **fast** caching layer for our [API](https://api.example.com).
2547
2548## Considered Options
2549
2550* Redis
2551* Memcached
2552* In-memory cache
2553
2554## Decision Outcome
2555
2556Chosen option: "Redis", because it supports data structures beyond simple key-value.
2557
2558### Consequences
2559
2560* Good, because it provides pub/sub
2561* Bad, because it adds operational complexity
2562
2563## Pros and Cons of the Options
2564
2565### Redis
2566
2567* Good, because it supports complex data types
2568* Bad, because it requires a separate server
2569
2570### Memcached
2571
2572* Good, because it's simpler
2573* Bad, because it only supports strings
2574"#;
2575 let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
2576 fs::write(&adr_path, madr_content).unwrap();
2577
2578 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2580
2581 let result = fs::read_to_string(&adr_path).unwrap();
2582
2583 assert!(result.contains("status: accepted"));
2585 assert!(!result.contains("status: proposed"));
2586
2587 let body_start = result.find("\n# Use Redis").unwrap();
2589 let original_body_start = madr_content.find("\n# Use Redis").unwrap();
2590 assert_eq!(
2591 &result[body_start..],
2592 &madr_content[original_body_start..],
2593 "Body content was modified"
2594 );
2595 }
2596
2597 #[test]
2598 fn test_set_status_via_mapping_preserves_unknown_keys_and_body() {
2599 let temp = TempDir::new().unwrap();
2602 let repo = Repository::init(temp.path(), None, true).unwrap();
2603
2604 let content_with_comments = r#"---
2605# SPDX-License-Identifier: MIT
2606number: 2
2607title: Use MADR format
2608date: 2026-01-15
2609status: proposed
2610custom-meta: keep-me
2611---
2612
2613## Context and Problem Statement
2614
2615We need a standard ADR format.
2616
2617## Decision Outcome
2618
2619Use MADR 4.0.0.
2620"#;
2621 let adr_path = repo.adr_path().join("0002-use-madr-format.md");
2622 fs::write(&adr_path, content_with_comments).unwrap();
2623
2624 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2625
2626 let result = fs::read_to_string(&adr_path).unwrap();
2627
2628 assert!(result.contains("status: accepted"));
2629 assert!(
2630 result.contains("custom-meta: keep-me"),
2631 "unknown frontmatter key was dropped\n{result}"
2632 );
2633 assert!(
2634 result.contains("## Decision Outcome") && result.contains("Use MADR 4.0.0."),
2635 "markdown body must survive\n{result}"
2636 );
2637 }
2638
2639 #[test]
2640 fn test_set_status_preserves_markdown_links() {
2641 let temp = TempDir::new().unwrap();
2642 let repo = Repository::init(temp.path(), None, true).unwrap();
2643
2644 let content = r#"---
2645number: 2
2646title: Use PostgreSQL
2647date: 2026-01-15
2648status: proposed
2649---
2650
2651## Context
2652
2653See the [PostgreSQL docs](https://www.postgresql.org/docs/) for details.
2654
2655Also see [RFC 7159](https://tools.ietf.org/html/rfc7159) and `inline code`.
2656
2657## Decision
2658
2659We will use **PostgreSQL** version `16.x`.
2660
2661## Consequences
2662
2663- [Monitoring guide](https://example.com/monitoring)
2664- Performance benchmarks in [this report](./benchmarks.md)
2665"#;
2666 let adr_path = repo.adr_path().join("0002-use-postgresql.md");
2667 fs::write(&adr_path, content).unwrap();
2668
2669 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2670
2671 let result = fs::read_to_string(&adr_path).unwrap();
2672
2673 assert!(result.contains("[PostgreSQL docs](https://www.postgresql.org/docs/)"));
2674 assert!(result.contains("[RFC 7159](https://tools.ietf.org/html/rfc7159)"));
2675 assert!(result.contains("`inline code`"));
2676 assert!(result.contains("**PostgreSQL**"));
2677 assert!(result.contains("[Monitoring guide](https://example.com/monitoring)"));
2678 assert!(result.contains("[this report](./benchmarks.md)"));
2679 }
2680
2681 #[test]
2682 fn test_link_preserves_body_content() {
2683 let temp = TempDir::new().unwrap();
2684 let repo = Repository::init(temp.path(), None, true).unwrap();
2685
2686 let content_1 = r#"---
2687number: 2
2688title: First decision
2689date: 2026-01-15
2690status: accepted
2691---
2692
2693## Context
2694
2695Custom context with **bold** and [links](https://example.com).
2696
2697## Decision
2698
2699A detailed decision paragraph.
2700
2701## Consequences
2702
2703- Important consequence 1
2704- Important consequence 2
2705"#;
2706 let content_2 = r#"---
2707number: 3
2708title: Second decision
2709date: 2026-01-16
2710status: accepted
2711---
2712
2713## Context
2714
2715Different context entirely.
2716
2717## Decision
2718
2719Another decision.
2720
2721## Consequences
2722
2723None significant.
2724"#;
2725 fs::write(repo.adr_path().join("0002-first-decision.md"), content_1).unwrap();
2726 fs::write(repo.adr_path().join("0003-second-decision.md"), content_2).unwrap();
2727
2728 repo.link(2, 3, LinkKind::Amends, LinkKind::AmendedBy)
2729 .unwrap();
2730
2731 let result_1 = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
2732 let result_2 = fs::read_to_string(repo.adr_path().join("0003-second-decision.md")).unwrap();
2733
2734 assert!(result_1.contains("Custom context with **bold** and [links](https://example.com)"));
2736 assert!(result_1.contains("A detailed decision paragraph."));
2737 assert!(result_2.contains("Different context entirely."));
2738 assert!(result_2.contains("None significant."));
2739
2740 assert!(result_1.contains("links:"));
2742 assert!(result_1.contains("target: 3"));
2743 assert!(result_2.contains("links:"));
2744 assert!(result_2.contains("target: 2"));
2745 }
2746
2747 #[test]
2748 fn test_supersede_preserves_old_adr_body() {
2749 let temp = TempDir::new().unwrap();
2750 let repo = Repository::init(temp.path(), None, true).unwrap();
2751
2752 let rich_content = r#"---
2753number: 2
2754title: Original approach
2755date: 2026-01-15
2756status: accepted
2757---
2758
2759## Context and Problem Statement
2760
2761This has **rich** markdown with [links](https://example.com).
2762
2763```rust
2764fn important_code() -> bool {
2765 true
2766}
2767```
2768
2769## Decision Outcome
2770
2771We chose the original approach.
2772
2773| Criteria | Score |
2774|----------|-------|
2775| Speed | 9/10 |
2776| Safety | 8/10 |
2777"#;
2778 fs::write(
2779 repo.adr_path().join("0002-original-approach.md"),
2780 rich_content,
2781 )
2782 .unwrap();
2783
2784 repo.supersede("Better approach", 2).unwrap();
2785
2786 let old_content =
2787 fs::read_to_string(repo.adr_path().join("0002-original-approach.md")).unwrap();
2788
2789 assert!(old_content.contains("```rust"));
2791 assert!(old_content.contains("fn important_code()"));
2792 assert!(old_content.contains("| Criteria | Score |"));
2793 assert!(old_content.contains("[links](https://example.com)"));
2794
2795 assert!(old_content.contains("status: superseded"));
2797 assert!(old_content.contains("target: 3"));
2798 }
2799
2800 #[test]
2801 fn test_set_status_legacy_preserves_sections() {
2802 let temp = TempDir::new().unwrap();
2803 let repo = Repository::init(temp.path(), None, false).unwrap();
2804
2805 let legacy_content = r#"# 2. Use Rust for backend
2806
2807Date: 2026-01-15
2808
2809## Status
2810
2811Proposed
2812
2813## Context
2814
2815We need a fast, safe language for our backend services.
2816
2817See the [Rust book](https://doc.rust-lang.org/book/) for details.
2818
2819## Decision
2820
2821We will use **Rust** with the `tokio` runtime.
2822
2823```toml
2824[dependencies]
2825tokio = { version = "1", features = ["full"] }
2826```
2827
2828## Consequences
2829
2830- Type safety prevents many bugs at compile time
2831- Learning curve for team members
2832"#;
2833 let adr_path = repo.adr_path().join("0002-use-rust-for-backend.md");
2834 fs::write(&adr_path, legacy_content).unwrap();
2835
2836 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2837
2838 let result = fs::read_to_string(&adr_path).unwrap();
2839
2840 assert!(result.contains("Accepted"));
2842
2843 assert!(result.contains("[Rust book](https://doc.rust-lang.org/book/)"));
2845 assert!(result.contains("**Rust**"));
2846 assert!(result.contains("`tokio`"));
2847 assert!(result.contains("```toml"));
2848 assert!(result.contains("tokio = { version = \"1\", features = [\"full\"] }"));
2849 assert!(result.contains("Type safety prevents many bugs"));
2850 }
2851
2852 #[test]
2853 fn test_set_status_frontmatter_with_existing_links() {
2854 let temp = TempDir::new().unwrap();
2855 let repo = Repository::init(temp.path(), None, true).unwrap();
2856
2857 let content = r#"---
2858number: 2
2859title: Updated approach
2860date: 2026-01-15
2861status: proposed
2862links:
2863 - target: 1
2864 kind: amends
2865---
2866
2867## Context
2868
2869Context.
2870
2871## Decision
2872
2873Decision.
2874"#;
2875 let adr_path = repo.adr_path().join("0002-updated-approach.md");
2876 fs::write(&adr_path, content).unwrap();
2877
2878 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2880
2881 let result = fs::read_to_string(&adr_path).unwrap();
2882 assert!(result.contains("status: accepted"));
2883 assert!(result.contains("links:"));
2884 assert!(result.contains("target: 1"));
2885 assert!(result.contains("kind: amends"));
2886 assert!(
2888 !result.contains("\n\n---"),
2889 "Should not have extra blank line before closing ---: {:?}",
2890 result
2891 );
2892 }
2893
2894 #[test]
2895 fn test_set_status_no_extra_newline_before_separator() {
2896 let temp = TempDir::new().unwrap();
2897 let repo = Repository::init(temp.path(), None, true).unwrap();
2898
2899 let content = "---\nnumber: 2\ntitle: Test\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
2900 let adr_path = repo.adr_path().join("0002-test.md");
2901 fs::write(&adr_path, content).unwrap();
2902
2903 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2904
2905 let result = fs::read_to_string(&adr_path).unwrap();
2906 assert!(result.contains("status: accepted"));
2907 assert!(
2909 result.contains("\n---\n"),
2910 "Should have clean closing separator: {:?}",
2911 result
2912 );
2913 assert!(
2914 !result.contains("\n\n---"),
2915 "Should not have extra blank line before closing ---: {:?}",
2916 result
2917 );
2918 }
2919
2920 #[test]
2921 fn test_set_status_rejects_empty_custom() {
2922 let temp = TempDir::new().unwrap();
2923 let repo = Repository::init(temp.path(), None, true).unwrap();
2924
2925 let content = "---\nnumber: 2\ntitle: Test\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
2926 let adr_path = repo.adr_path().join("0002-test.md");
2927 fs::write(&adr_path, content).unwrap();
2928
2929 for bad in ["", " ", " ", "\t"] {
2931 let err = repo
2932 .set_status(2, AdrStatus::Custom(bad.to_string()), None)
2933 .unwrap_err();
2934 assert!(
2935 matches!(err, Error::InvalidStatus(_)),
2936 "expected InvalidStatus for {:?}, got {:?}",
2937 bad,
2938 err
2939 );
2940 }
2941
2942 let result = fs::read_to_string(&adr_path).unwrap();
2944 assert!(result.contains("status: proposed"));
2945 assert!(repo.get(2).is_ok());
2946 }
2947
2948 #[test]
2951 fn test_update_madr_content_preserves_unmodified_sections() {
2952 let temp = TempDir::new().unwrap();
2953 let repo = Repository::init(temp.path(), None, true).unwrap();
2954
2955 let madr_content = r#"---
2956number: 2
2957title: Use Redis for caching
2958date: 2026-01-15
2959status: proposed
2960---
2961
2962# Use Redis for caching
2963
2964## Context and Problem Statement
2965
2966Original context about caching needs.
2967
2968## Considered Options
2969
2970* Redis
2971* Memcached
2972
2973## Decision Outcome
2974
2975Chosen option: "Redis", because it supports data structures beyond simple key-value.
2976
2977### Consequences
2978
2979* Good, because it provides pub/sub
2980"#;
2981 let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
2982 fs::write(&adr_path, madr_content).unwrap();
2983
2984 let mut adr = repo.get(2).unwrap();
2985 adr.context = "Updated context text.".into();
2986 repo.update(
2987 &adr,
2988 BodySectionPatch {
2989 context: Some("Updated context text.".into()),
2990 ..Default::default()
2991 },
2992 )
2993 .unwrap();
2994
2995 let result = fs::read_to_string(&adr_path).unwrap();
2996
2997 assert!(result.contains("## Context and Problem Statement"));
2998 assert!(result.contains("Updated context text."));
2999 assert!(result.contains("## Considered Options"));
3000 assert!(result.contains("* Memcached"));
3001 assert!(result.contains("## Decision Outcome"));
3002 assert!(result.contains("Chosen option: \"Redis\""));
3003 assert!(result.contains("### Consequences"));
3004 assert!(result.contains("* Good, because it provides pub/sub"));
3005 assert!(!result.contains("What is the change that we're proposing"));
3006 assert!(!result.contains("## Context\n"));
3007 assert!(!result.contains("## Decision\n"));
3008 }
3009
3010 #[test]
3011 fn test_update_madr_context_preserves_decision_h3_subsections() {
3012 let temp = TempDir::new().unwrap();
3013 let repo = Repository::init(temp.path(), None, true).unwrap();
3014
3015 let madr_content = r#"---
3016number: 2
3017title: Use Redis
3018date: 2026-01-15
3019status: proposed
3020---
3021
3022## Context and Problem Statement
3023
3024Original context.
3025
3026## Decision Outcome
3027
3028Chosen option: "Redis", because it is fast.
3029
3030### Consequences
3031
3032* Good, because it provides pub/sub
3033* Bad, because it needs memory
3034
3035### Confirmation
3036
3037We will confirm via load tests.
3038"#;
3039 let adr_path = repo.adr_path().join("0002-use-redis.md");
3040 fs::write(&adr_path, madr_content).unwrap();
3041
3042 let mut adr = repo.get(2).unwrap();
3043 adr.context = "Updated context only.".into();
3044 repo.update(
3045 &adr,
3046 BodySectionPatch {
3047 context: Some("Updated context only.".into()),
3048 ..Default::default()
3049 },
3050 )
3051 .unwrap();
3052
3053 let result = fs::read_to_string(&adr_path).unwrap();
3054 assert!(result.contains("Updated context only."));
3055 assert!(result.contains("Chosen option: \"Redis\", because it is fast."));
3056 assert!(result.contains("### Consequences"));
3057 assert!(result.contains("* Good, because it provides pub/sub"));
3058 assert!(result.contains("* Bad, because it needs memory"));
3059 assert!(result.contains("### Confirmation"));
3060 assert!(result.contains("We will confirm via load tests."));
3061 }
3062
3063 #[test]
3064 fn test_update_madr_consequences_patches_h3_subsection() {
3065 let temp = TempDir::new().unwrap();
3066 let repo = Repository::init(temp.path(), None, true).unwrap();
3067
3068 let madr_content = r#"---
3069number: 2
3070title: Use Redis
3071date: 2026-01-15
3072status: proposed
3073---
3074
3075## Context and Problem Statement
3076
3077Context.
3078
3079## Decision Outcome
3080
3081Chosen option: "Redis", because it is fast.
3082
3083### Consequences
3084
3085* Old consequence
3086"#;
3087 let adr_path = repo.adr_path().join("0002-use-redis.md");
3088 fs::write(&adr_path, madr_content).unwrap();
3089
3090 let mut adr = repo.get(2).unwrap();
3091 adr.consequences = "* New consequence".into();
3092 repo.update(
3093 &adr,
3094 BodySectionPatch {
3095 consequences: Some("* New consequence".into()),
3096 ..Default::default()
3097 },
3098 )
3099 .unwrap();
3100
3101 let result = fs::read_to_string(&adr_path).unwrap();
3102 assert!(result.contains("Chosen option: \"Redis\", because it is fast."));
3103 assert!(result.contains("### Consequences"));
3104 assert!(result.contains("* New consequence"));
3105 assert!(!result.contains("* Old consequence"));
3106 }
3107
3108 #[test]
3110 fn test_update_madr_content_round_trip_via_get() {
3111 let temp = TempDir::new().unwrap();
3112 let repo = Repository::init(temp.path(), None, true).unwrap();
3113
3114 let madr_content = r#"---
3115number: 2
3116title: Use PostgreSQL
3117date: 2026-01-15
3118status: proposed
3119---
3120
3121## Context and Problem Statement
3122
3123We need a relational database.
3124
3125## Decision Outcome
3126
3127We will use PostgreSQL 16.
3128"#;
3129 let adr_path = repo.adr_path().join("0002-use-postgresql.md");
3130 fs::write(&adr_path, madr_content).unwrap();
3131
3132 let mut adr = repo.get(2).unwrap();
3133 adr.context = "Updated context only.".into();
3134 repo.update(
3135 &adr,
3136 BodySectionPatch {
3137 context: Some("Updated context only.".into()),
3138 ..Default::default()
3139 },
3140 )
3141 .unwrap();
3142
3143 let reloaded = repo.get(2).unwrap();
3144 assert_eq!(reloaded.context, "Updated context only.");
3145 assert_eq!(reloaded.decision, "We will use PostgreSQL 16.");
3146 }
3147
3148 #[test]
3155 fn test_update_madr_consequences_only_round_trip_via_get() {
3156 let temp = TempDir::new().unwrap();
3157 let repo = Repository::init(temp.path(), None, true).unwrap();
3158
3159 let madr_content = r#"---
3160number: 2
3161title: Use Redis
3162date: 2026-01-15
3163status: proposed
3164---
3165
3166## Context and Problem Statement
3167
3168Context.
3169
3170## Decision Outcome
3171
3172Chosen option: "Redis", because it is fast.
3173
3174### Consequences
3175
3176Old consequence text.
3177"#;
3178 let adr_path = repo.adr_path().join("0002-use-redis.md");
3179 fs::write(&adr_path, madr_content).unwrap();
3180
3181 let mut adr = repo.get(2).unwrap();
3182 adr.consequences = "New consequence text.".into();
3183 repo.update(
3184 &adr,
3185 BodySectionPatch::new().with_consequences("New consequence text."),
3186 )
3187 .unwrap();
3188
3189 let reloaded = repo.get(2).unwrap();
3190 assert_eq!(reloaded.consequences, "New consequence text.");
3191 assert_eq!(
3192 reloaded.decision,
3193 "Chosen option: \"Redis\", because it is fast."
3194 );
3195 assert!(
3196 !reloaded.decision.contains("New consequence text."),
3197 "consequences text leaked into decision on read:\n{}",
3198 reloaded.decision
3199 );
3200 }
3201
3202 #[test]
3203 fn test_update_metadata_adds_tags_to_frontmatter() {
3204 let temp = TempDir::new().unwrap();
3205 let repo = Repository::init(temp.path(), None, true).unwrap();
3206
3207 let content = r#"---
3208number: 2
3209title: Tagged ADR
3210date: 2026-01-15
3211status: proposed
3212---
3213
3214## Context
3215
3216Context.
3217"#;
3218 let adr_path = repo.adr_path().join("0002-tagged-adr.md");
3219 fs::write(&adr_path, content).unwrap();
3220
3221 let mut adr = repo.get(2).unwrap();
3222 adr.set_tags(vec!["security".into(), "api".into()]);
3223 repo.update_metadata(&adr).unwrap();
3224
3225 let result = fs::read_to_string(&adr_path).unwrap();
3226 assert!(result.contains("tags:"));
3227 assert!(
3229 result.contains("- security") && result.contains("- api"),
3230 "tags missing from frontmatter\n{result}"
3231 );
3232 assert!(result.contains("## Context\n\nContext."));
3234 }
3235
3236 #[test]
3239 fn test_list_with_errors_all_valid() {
3240 let temp = TempDir::new().unwrap();
3241 let repo = Repository::init(temp.path(), None, true).unwrap();
3242 repo.new_adr("Valid ADR").unwrap();
3243
3244 let (adrs, errors) = repo.list_with_errors().unwrap();
3245 assert_eq!(adrs.len(), 2); assert!(errors.is_empty());
3247 }
3248
3249 #[test]
3250 fn test_list_with_errors_captures_invalid_frontmatter() {
3251 let temp = TempDir::new().unwrap();
3252 let repo = Repository::init(temp.path(), None, true).unwrap();
3253
3254 let bad_content =
3256 "---\nnumber: 2\nstatus: accepted\ndate: not-a-date\n---\n\n# 2. Bad ADR\n";
3257 fs::write(repo.adr_path().join("0002-bad-adr.md"), bad_content).unwrap();
3258
3259 let (adrs, errors) = repo.list_with_errors().unwrap();
3260 assert_eq!(adrs.len(), 1); assert_eq!(errors.len(), 1);
3262 assert!(errors[0].0.to_string_lossy().contains("0002-bad-adr.md"));
3263 }
3264
3265 #[test]
3266 fn test_list_with_errors_mixed_valid_and_invalid() {
3267 let temp = TempDir::new().unwrap();
3268 let repo = Repository::init(temp.path(), None, true).unwrap();
3269
3270 repo.new_adr("Good ADR").unwrap();
3272
3273 let bad_content = "---\n: :\n---\n\n# 3. Broken\n";
3275 fs::write(repo.adr_path().join("0003-broken.md"), bad_content).unwrap();
3276
3277 let (adrs, errors) = repo.list_with_errors().unwrap();
3278 assert_eq!(adrs.len(), 2); assert_eq!(errors.len(), 1); }
3281
3282 #[test]
3283 fn test_list_with_errors_string_decision_makers_is_valid() {
3284 let temp = TempDir::new().unwrap();
3285 let repo = Repository::init(temp.path(), None, true).unwrap();
3286
3287 let content = r#"---
3289number: 2
3290status: accepted
3291date: 2026-03-18
3292decision-makers: mschoettle
3293---
3294
3295# 2. Use Markdown Architectural Decision Records
3296"#;
3297 fs::write(repo.adr_path().join("0002-use-markdown-adrs.md"), content).unwrap();
3298
3299 let (adrs, errors) = repo.list_with_errors().unwrap();
3300 assert!(errors.is_empty(), "string decision-makers should parse");
3301 assert_eq!(adrs.len(), 2);
3302
3303 let adr = adrs.iter().find(|a| a.number == 2).unwrap();
3304 assert_eq!(adr.decision_makers, vec!["mschoettle"]);
3305 }
3306 #[test]
3309 fn test_update_metadata_preserves_link_descriptions() {
3310 let temp = TempDir::new().unwrap();
3311 let repo = Repository::init(temp.path(), None, true).unwrap();
3312
3313 let content = r#"---
3314number: 2
3315title: Linked ADR
3316date: 2026-01-15
3317status: proposed
3318links:
3319 - target: 1
3320 kind: relatesto
3321 description: Explains the connection
3322---
3323
3324## Context
3325
3326Context.
3327"#;
3328 let adr_path = repo.adr_path().join("0002-linked-adr.md");
3329 fs::write(&adr_path, content).unwrap();
3330
3331 let adr = repo.get(2).unwrap();
3332 assert_eq!(
3333 adr.links[0].description.as_deref(),
3334 Some("Explains the connection")
3335 );
3336
3337 repo.update_metadata(&adr).unwrap();
3339 let result = fs::read_to_string(&adr_path).unwrap();
3340 assert!(result.contains("kind: relatesto"));
3341 assert!(result.contains("description: Explains the connection"));
3342 }
3343
3344 #[test]
3345 fn test_update_metadata_kebab_case_kind_round_trips_verbatim() {
3346 let temp = TempDir::new().unwrap();
3351 let repo = Repository::init(temp.path(), None, true).unwrap();
3352
3353 let content = r#"---
3354number: 2
3355title: Linked ADR
3356date: 2026-01-15
3357status: proposed
3358links:
3359 - target: 1
3360 kind: relates-to
3361---
3362
3363## Context
3364
3365Context.
3366"#;
3367 let adr_path = repo.adr_path().join("0002-linked-adr.md");
3368 fs::write(&adr_path, content).unwrap();
3369
3370 let adr = repo.get(2).unwrap();
3371 assert_eq!(adr.links[0].kind, LinkKind::Custom("relates-to".into()));
3372
3373 repo.update_metadata(&adr).unwrap();
3374 let result = fs::read_to_string(&adr_path).unwrap();
3375 assert!(result.contains("kind: relates-to"));
3376 }
3377
3378 #[test]
3379 fn test_resolve_link_titles_uses_actual_filename_for_hand_named_target() {
3380 let temp = TempDir::new().unwrap();
3381 let repo = Repository::init(temp.path(), None, false).unwrap();
3382
3383 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";
3385 fs::write(
3386 repo.adr_path().join("0002-use-rust-for-backend.md"),
3387 target_content,
3388 )
3389 .unwrap();
3390
3391 let mut source = repo.get(1).unwrap();
3392 source.add_link(AdrLink::new(2, LinkKind::Amends));
3393
3394 let titles = repo.resolve_link_titles(&source);
3395 let (title, filename) = titles.get(&2).unwrap();
3396 assert_eq!(title, "Use Rust for backend services");
3397 assert_eq!(filename, "0002-use-rust-for-backend.md");
3398 }
3399
3400 #[test]
3401 fn test_link_href_prefers_actual_path_over_slugified_title() {
3402 let target = Adr {
3403 path: Some(PathBuf::from("/repo/doc/adr/0003-use-rust-for-backend.md")),
3404 ..Adr::new(3, "Use Rust for backend services")
3405 };
3406 assert_eq!(
3407 Repository::link_href(&target),
3408 "0003-use-rust-for-backend.md"
3409 );
3410 }
3411
3412 #[test]
3413 fn test_link_href_falls_back_to_slugified_title_when_path_missing() {
3414 let target = Adr {
3415 path: None,
3416 ..Adr::new(3, "Use Rust for backend services")
3417 };
3418 assert_eq!(
3419 Repository::link_href(&target),
3420 "0003-use-rust-for-backend-services.md"
3421 );
3422 }
3423
3424 fn extract_h2_block(content: &str, heading: &str) -> Option<String> {
3428 let lines: Vec<&str> = content.lines().collect();
3429 let marker = format!("## {heading}");
3430 let start = lines.iter().position(|l| l.trim() == marker)?;
3431 let end = lines[(start + 1)..]
3432 .iter()
3433 .position(|l| l.starts_with("## "))
3434 .map(|p| start + 1 + p)
3435 .unwrap_or(lines.len());
3436 Some(lines[start..end].join("\n"))
3437 }
3438
3439 fn assert_h2_block_unchanged(before: &str, after: &str, heading: &str) {
3440 assert_eq!(
3441 extract_h2_block(before, heading),
3442 extract_h2_block(after, heading),
3443 "section `{heading}` should be byte-identical"
3444 );
3445 }
3446
3447 #[test]
3448 fn test_update_ng_nygard_rich_context_preserved_on_decision_patch() {
3449 let temp = TempDir::new().unwrap();
3450 let repo = Repository::init(temp.path(), None, true).unwrap();
3451
3452 let content = r#"---
3453number: 2
3454title: Rich context ADR
3455date: 2026-01-15
3456status: proposed
3457---
3458
3459## Context
3460
3461We need caching. See the [Redis docs](https://redis.io).
3462
3463* Requirement one
3464* Requirement two
3465
3466Use `redis-cli` for debugging.
3467
3468## Decision
3469
3470Old decision.
3471
3472## Consequences
3473
3474Old consequences.
3475"#;
3476 let adr_path = repo.adr_path().join("0002-rich-context-adr.md");
3477 fs::write(&adr_path, content).unwrap();
3478 let before = content.to_string();
3479
3480 let mut adr = repo.get(2).unwrap();
3481 adr.decision = "New decision.".into();
3482 repo.update(
3483 &adr,
3484 BodySectionPatch {
3485 decision: Some("New decision.".into()),
3486 ..Default::default()
3487 },
3488 )
3489 .unwrap();
3490
3491 let after = fs::read_to_string(&adr_path).unwrap();
3492 assert_h2_block_unchanged(&before, &after, "Context");
3493 assert_h2_block_unchanged(&before, &after, "Consequences");
3494 assert!(after.contains("New decision."));
3495 }
3496
3497 #[test]
3498 fn test_update_madr_rich_context_preserved_on_consequences_patch() {
3499 let temp = TempDir::new().unwrap();
3500 let repo = Repository::init(temp.path(), None, true).unwrap();
3501
3502 let content = r#"---
3503number: 2
3504title: Rich MADR context
3505date: 2026-01-15
3506status: proposed
3507---
3508
3509## Context and Problem Statement
3510
3511We need caching. See the [Redis docs](https://redis.io).
3512
3513* Requirement one
3514* Requirement two
3515
3516Use `redis-cli` for debugging.
3517
3518## Decision Outcome
3519
3520Chosen option: "Redis", because it is fast.
3521
3522### Consequences
3523
3524* Old consequence
3525"#;
3526 let adr_path = repo.adr_path().join("0002-rich-madr-context.md");
3527 fs::write(&adr_path, content).unwrap();
3528 let before = content.to_string();
3529
3530 let mut adr = repo.get(2).unwrap();
3531 adr.consequences = "* New consequence".into();
3532 repo.update(
3533 &adr,
3534 BodySectionPatch {
3535 consequences: Some("* New consequence".into()),
3536 ..Default::default()
3537 },
3538 )
3539 .unwrap();
3540
3541 let after = fs::read_to_string(&adr_path).unwrap();
3542 assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
3543 assert!(after.contains("Chosen option: \"Redis\", because it is fast."));
3544 assert!(after.contains("* New consequence"));
3545 assert!(!after.contains("* Old consequence"));
3546 }
3547
3548 #[test]
3549 fn test_update_legacy_rich_context_preserved_on_decision_patch() {
3550 let temp = TempDir::new().unwrap();
3551 let repo = Repository::init(temp.path(), None, false).unwrap();
3552
3553 let content = r#"# 2. Legacy rich context
3554
3555Date: 2026-01-15
3556
3557## Status
3558
3559Proposed
3560
3561## Context
3562
3563We need caching. See the [Redis docs](https://redis.io).
3564
3565* Requirement one
3566* Requirement two
3567
3568Use `redis-cli` for debugging.
3569
3570## Decision
3571
3572Old decision.
3573
3574## Consequences
3575
3576Old consequences.
3577"#;
3578 let adr_path = repo.adr_path().join("0002-legacy-rich-context.md");
3579 fs::write(&adr_path, content).unwrap();
3580 let before = content.to_string();
3581
3582 let mut adr = repo.get(2).unwrap();
3583 adr.decision = "New decision.".into();
3584 repo.update(
3585 &adr,
3586 BodySectionPatch {
3587 decision: Some("New decision.".into()),
3588 ..Default::default()
3589 },
3590 )
3591 .unwrap();
3592
3593 let after = fs::read_to_string(&adr_path).unwrap();
3594 assert_h2_block_unchanged(&before, &after, "Context");
3595 assert_h2_block_unchanged(&before, &after, "Consequences");
3596 assert!(after.contains("New decision."));
3597 }
3598
3599 #[test]
3600 fn test_update_madr_decision_only_preserves_h3_subsections() {
3601 let temp = TempDir::new().unwrap();
3602 let repo = Repository::init(temp.path(), None, true).unwrap();
3603
3604 let content = r#"---
3605number: 2
3606title: Decision patch MADR
3607date: 2026-01-15
3608status: proposed
3609---
3610
3611## Context and Problem Statement
3612
3613Context.
3614
3615## Decision Outcome
3616
3617Old intro text.
3618
3619### Consequences
3620
3621* Good, because it is fast
3622* Bad, because it uses memory
3623
3624### Confirmation
3625
3626Confirm via load tests.
3627"#;
3628 let adr_path = repo.adr_path().join("0002-decision-patch-madr.md");
3629 fs::write(&adr_path, content).unwrap();
3630 let before = content.to_string();
3631
3632 let mut adr = repo.get(2).unwrap();
3633 adr.decision = "New intro text.".into();
3634 repo.update(
3635 &adr,
3636 BodySectionPatch {
3637 decision: Some("New intro text.".into()),
3638 ..Default::default()
3639 },
3640 )
3641 .unwrap();
3642
3643 let after = fs::read_to_string(&adr_path).unwrap();
3644 assert!(after.contains("New intro text."));
3645 assert!(!after.contains("Old intro text."));
3646 assert!(after.contains("### Consequences"));
3647 assert!(after.contains("* Good, because it is fast"));
3648 assert!(after.contains("* Bad, because it uses memory"));
3649 assert!(after.contains("### Confirmation"));
3650 assert!(after.contains("Confirm via load tests."));
3651 assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
3652 }
3653
3654 #[test]
3655 fn test_update_madr_decision_and_consequences_together_preserves_other_h3() {
3656 let temp = TempDir::new().unwrap();
3657 let repo = Repository::init(temp.path(), None, true).unwrap();
3658
3659 let content = r#"---
3660number: 2
3661title: Combined patch MADR
3662date: 2026-01-15
3663status: proposed
3664---
3665
3666## Context and Problem Statement
3667
3668Context.
3669
3670## Decision Outcome
3671
3672Old intro.
3673
3674### Consequences
3675
3676* Old consequence
3677
3678### Confirmation
3679
3680Confirm via load tests.
3681"#;
3682 let adr_path = repo.adr_path().join("0002-combined-patch-madr.md");
3683 fs::write(&adr_path, content).unwrap();
3684 let before = content.to_string();
3685
3686 let mut adr = repo.get(2).unwrap();
3687 adr.decision = "New intro.".into();
3688 adr.consequences = "* New consequence".into();
3689 repo.update(
3690 &adr,
3691 BodySectionPatch {
3692 decision: Some("New intro.".into()),
3693 consequences: Some("* New consequence".into()),
3694 ..Default::default()
3695 },
3696 )
3697 .unwrap();
3698
3699 let after = fs::read_to_string(&adr_path).unwrap();
3700 assert!(after.contains("New intro."));
3701 assert!(after.contains("* New consequence"));
3702 assert!(!after.contains("Old intro."));
3703 assert!(!after.contains("* Old consequence"));
3704 assert!(after.contains("### Confirmation"));
3705 assert!(after.contains("Confirm via load tests."));
3706 assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
3707 }
3708
3709 #[test]
3710 fn test_update_ng_nygard_consequences_only_patch() {
3711 let temp = TempDir::new().unwrap();
3712 let repo = Repository::init(temp.path(), None, true).unwrap();
3713
3714 let content = r#"---
3715number: 2
3716title: Nygard consequences patch
3717date: 2026-01-15
3718status: proposed
3719---
3720
3721## Context
3722
3723Original context.
3724
3725## Decision
3726
3727Original decision.
3728
3729## Consequences
3730
3731* Old item
3732"#;
3733 let adr_path = repo.adr_path().join("0002-nygard-consequences-patch.md");
3734 fs::write(&adr_path, content).unwrap();
3735 let before = content.to_string();
3736
3737 let mut adr = repo.get(2).unwrap();
3738 adr.consequences = "* New item".into();
3739 repo.update(
3740 &adr,
3741 BodySectionPatch {
3742 consequences: Some("* New item".into()),
3743 ..Default::default()
3744 },
3745 )
3746 .unwrap();
3747
3748 let after = fs::read_to_string(&adr_path).unwrap();
3749 assert_h2_block_unchanged(&before, &after, "Context");
3750 assert_h2_block_unchanged(&before, &after, "Decision");
3751 assert!(after.contains("* New item"));
3752 assert!(!after.contains("* Old item"));
3753 }
3754
3755 #[test]
3756 fn test_update_ng_nygard_context_only_preserves_consequences_section() {
3757 let temp = TempDir::new().unwrap();
3758 let repo = Repository::init(temp.path(), None, true).unwrap();
3759
3760 let content = r#"---
3761number: 2
3762title: Nygard context patch
3763date: 2026-01-15
3764status: proposed
3765---
3766
3767## Context
3768
3769Old context.
3770
3771## Decision
3772
3773Original decision.
3774
3775## Consequences
3776
3777* Good, because it is fast
3778* Bad, because it uses memory
3779"#;
3780 let adr_path = repo.adr_path().join("0002-nygard-context-patch.md");
3781 fs::write(&adr_path, content).unwrap();
3782 let before = content.to_string();
3783
3784 let mut adr = repo.get(2).unwrap();
3785 adr.context = "New context.".into();
3786 repo.update(
3787 &adr,
3788 BodySectionPatch {
3789 context: Some("New context.".into()),
3790 ..Default::default()
3791 },
3792 )
3793 .unwrap();
3794
3795 let after = fs::read_to_string(&adr_path).unwrap();
3796 assert_h2_block_unchanged(&before, &after, "Decision");
3797 assert_h2_block_unchanged(&before, &after, "Consequences");
3798 assert!(after.contains("New context."));
3799 }
3800
3801 #[test]
3802 fn test_update_metadata_only_preserves_madr_body_byte_identical() {
3803 let temp = TempDir::new().unwrap();
3804 let repo = Repository::init(temp.path(), None, true).unwrap();
3805
3806 let content = r#"---
3807number: 2
3808title: Metadata only MADR
3809date: 2026-01-15
3810status: proposed
3811---
3812
3813## Context and Problem Statement
3814
3815Context.
3816
3817## Decision Outcome
3818
3819Intro.
3820
3821### Consequences
3822
3823* Good item
3824
3825### Confirmation
3826
3827Confirm via tests.
3828"#;
3829 let adr_path = repo.adr_path().join("0002-metadata-only-madr.md");
3830 fs::write(&adr_path, content).unwrap();
3831 let before = fs::read_to_string(&adr_path).unwrap();
3832 let body_start = before.find("\n\n## Context").unwrap();
3833
3834 let mut adr = repo.get(2).unwrap();
3835 adr.status = AdrStatus::Accepted;
3836 repo.update(&adr, BodySectionPatch::default()).unwrap();
3837
3838 let after = fs::read_to_string(&adr_path).unwrap();
3839 assert_eq!(
3840 &before[body_start..],
3841 &after[after.find("\n\n## Context").unwrap()..]
3842 );
3843 assert!(after.contains("status: accepted"));
3844 }
3845
3846 #[test]
3847 fn test_update_madr_consequences_appends_h3_when_missing() {
3848 let temp = TempDir::new().unwrap();
3849 let repo = Repository::init(temp.path(), None, true).unwrap();
3850
3851 let content = r#"---
3852number: 2
3853title: Append consequences MADR
3854date: 2026-01-15
3855status: proposed
3856---
3857
3858## Context and Problem Statement
3859
3860Context.
3861
3862## Decision Outcome
3863
3864Intro only, no consequences subsection yet.
3865"#;
3866 let adr_path = repo.adr_path().join("0002-append-consequences-madr.md");
3867 fs::write(&adr_path, content).unwrap();
3868
3869 let mut adr = repo.get(2).unwrap();
3870 adr.consequences = "* Appended consequence".into();
3871 repo.update(
3872 &adr,
3873 BodySectionPatch {
3874 consequences: Some("* Appended consequence".into()),
3875 ..Default::default()
3876 },
3877 )
3878 .unwrap();
3879
3880 let after = fs::read_to_string(&adr_path).unwrap();
3881 assert!(after.contains("Intro only, no consequences subsection yet."));
3882 assert!(after.contains("### Consequences"));
3883 assert!(after.contains("* Appended consequence"));
3884 }
3885
3886 #[test]
3887 fn test_update_madr_in_compatible_mode_repo() {
3888 let temp = TempDir::new().unwrap();
3889 let repo = Repository::init(temp.path(), None, false).unwrap();
3890
3891 let content = r#"---
3892number: 2
3893title: Compatible repo MADR file
3894date: 2026-01-15
3895status: proposed
3896---
3897
3898## Context and Problem Statement
3899
3900Original context.
3901
3902## Decision Outcome
3903
3904Chosen option: "Redis", because it is fast.
3905
3906### Consequences
3907
3908* Good item
3909
3910### Confirmation
3911
3912Confirm via tests.
3913"#;
3914 let adr_path = repo.adr_path().join("0002-compatible-repo-madr-file.md");
3915 fs::write(&adr_path, content).unwrap();
3916 let before = content.to_string();
3917
3918 let mut adr = repo.get(2).unwrap();
3919 adr.context = "Updated context.".into();
3920 repo.update(
3921 &adr,
3922 BodySectionPatch {
3923 context: Some("Updated context.".into()),
3924 ..Default::default()
3925 },
3926 )
3927 .unwrap();
3928
3929 let after = fs::read_to_string(&adr_path).unwrap();
3930 assert!(after.contains("Updated context."));
3931 assert_h2_block_unchanged(&before, &after, "Decision Outcome");
3932 }
3933
3934 #[test]
3935 fn test_update_madr_decision_only_without_h3_subsections() {
3936 let temp = TempDir::new().unwrap();
3937 let repo = Repository::init(temp.path(), None, true).unwrap();
3938
3939 let content = r#"---
3940number: 2
3941title: Simple decision MADR
3942date: 2026-01-15
3943status: proposed
3944---
3945
3946## Context and Problem Statement
3947
3948Context.
3949
3950## Decision Outcome
3951
3952Old single-paragraph decision.
3953"#;
3954 let adr_path = repo.adr_path().join("0002-simple-decision-madr.md");
3955 fs::write(&adr_path, content).unwrap();
3956
3957 let mut adr = repo.get(2).unwrap();
3958 adr.decision = "New single-paragraph decision.".into();
3959 repo.update(
3960 &adr,
3961 BodySectionPatch {
3962 decision: Some("New single-paragraph decision.".into()),
3963 ..Default::default()
3964 },
3965 )
3966 .unwrap();
3967
3968 let after = fs::read_to_string(&adr_path).unwrap();
3969 assert!(after.contains("New single-paragraph decision."));
3970 assert!(!after.contains("Old single-paragraph decision."));
3971 }
3972
3973 #[test]
3974 fn test_update_madr_optional_sections_preserved_byte_identical() {
3975 let temp = TempDir::new().unwrap();
3976 let repo = Repository::init(temp.path(), None, true).unwrap();
3977
3978 let content = r#"---
3979number: 2
3980title: MADR optional sections
3981date: 2026-01-15
3982status: proposed
3983---
3984
3985## Context and Problem Statement
3986
3987Original context.
3988
3989## Considered Options
3990
3991* Redis
3992* Memcached
3993
3994## Decision Outcome
3995
3996Chosen option: "Redis", because it is fast.
3997
3998### Consequences
3999
4000* Good item
4001
4002## Pros and Cons of the Options
4003
4004### Redis
4005
4006* Good, because fast
4007* Bad, because memory
4008
4009### Memcached
4010
4011* Good, because simple
4012* Bad, because strings only
4013
4014## More Information
4015
4016See [MADR](https://adr.github.io/madr/) for details.
4017"#;
4018 let adr_path = repo.adr_path().join("0002-madr-optional-sections.md");
4019 fs::write(&adr_path, content).unwrap();
4020 let before = content.to_string();
4021
4022 let mut adr = repo.get(2).unwrap();
4023 adr.context = "Updated context.".into();
4024 repo.update(
4025 &adr,
4026 BodySectionPatch {
4027 context: Some("Updated context.".into()),
4028 ..Default::default()
4029 },
4030 )
4031 .unwrap();
4032
4033 let after = fs::read_to_string(&adr_path).unwrap();
4034 assert!(after.contains("Updated context."));
4035 assert_h2_block_unchanged(&before, &after, "Considered Options");
4036 assert_h2_block_unchanged(&before, &after, "Decision Outcome");
4037 assert_h2_block_unchanged(&before, &after, "Pros and Cons of the Options");
4038 assert_h2_block_unchanged(&before, &after, "More Information");
4039 }
4040
4041 #[test]
4042 fn test_update_unchanged_sections_byte_identical_after_context_patch() {
4043 let temp = TempDir::new().unwrap();
4044 let repo = Repository::init(temp.path(), None, true).unwrap();
4045
4046 let content = r#"---
4047number: 2
4048title: Byte identity check
4049date: 2026-01-15
4050status: proposed
4051---
4052
4053## Context and Problem Statement
4054
4055Original context.
4056
4057## Decision Outcome
4058
4059Intro.
4060
4061### Consequences
4062
4063* Good item
4064
4065### Confirmation
4066
4067Confirm via tests.
4068"#;
4069 let adr_path = repo.adr_path().join("0002-byte-identity-check.md");
4070 fs::write(&adr_path, content).unwrap();
4071 let before = content.to_string();
4072
4073 let mut adr = repo.get(2).unwrap();
4074 adr.context = "Updated context.".into();
4075 repo.update(
4076 &adr,
4077 BodySectionPatch {
4078 context: Some("Updated context.".into()),
4079 ..Default::default()
4080 },
4081 )
4082 .unwrap();
4083
4084 let after = fs::read_to_string(&adr_path).unwrap();
4085 assert_h2_block_unchanged(&before, &after, "Decision Outcome");
4086 }
4087
4088 #[test]
4089 fn test_update_madr_context_patch_does_not_round_trip_lossy_fields() {
4090 let temp = TempDir::new().unwrap();
4091 let repo = Repository::init(temp.path(), None, true).unwrap();
4092
4093 let content = r#"---
4094number: 2
4095title: Lossy parse guard
4096date: 2026-01-15
4097status: proposed
4098---
4099
4100## Context and Problem Statement
4101
4102Original context.
4103
4104## Decision Outcome
4105
4106See [Redis docs](https://redis.io) and use `redis-cli`.
4107
4108* Chosen option: "Redis"
4109* Because it is **fast**
4110
4111### Consequences
4112
4113* Good item
4114"#;
4115 let adr_path = repo.adr_path().join("0002-lossy-parse-guard.md");
4116 fs::write(&adr_path, content).unwrap();
4117 let before = content.to_string();
4118
4119 let mut adr = repo.get(2).unwrap();
4120 adr.context = "Updated context.".into();
4122 repo.update(
4123 &adr,
4124 BodySectionPatch {
4125 context: Some("Updated context.".into()),
4126 ..Default::default()
4127 },
4128 )
4129 .unwrap();
4130
4131 let after = fs::read_to_string(&adr_path).unwrap();
4132 assert_h2_block_unchanged(&before, &after, "Decision Outcome");
4133 assert!(after.contains("[Redis docs](https://redis.io)"));
4134 assert!(after.contains("`redis-cli`"));
4135 assert!(after.contains("**fast**"));
4136 }
4137
4138 #[test]
4141 fn test_fence_in_decision_outcome_preserved_on_consequences_patch() {
4142 let temp = TempDir::new().unwrap();
4143 let repo = Repository::init(temp.path(), None, true).unwrap();
4144
4145 let content = r#"---
4146number: 2
4147title: Fenced example in decision
4148date: 2026-01-15
4149status: proposed
4150---
4151
4152## Context and Problem Statement
4153
4154Context.
4155
4156## Decision Outcome
4157
4158Chosen option: "Redis", because it is fast.
4159
4160```markdown
4161## Consequences
4162
4163Example consequences inside a fence.
4164```
4165
4166Trailing text after the fence.
4167
4168### Consequences
4169
4170* Good, because it provides pub/sub
4171
4172### Confirmation
4173
4174We will confirm via load tests.
4175"#;
4176 let adr_path = repo.adr_path().join("0002-fenced-decision-outcome.md");
4177 fs::write(&adr_path, content).unwrap();
4178 let before = content.to_string();
4179
4180 let mut adr = repo.get(2).unwrap();
4181 adr.consequences = "* Updated consequence".into();
4182 repo.update(
4183 &adr,
4184 BodySectionPatch {
4185 consequences: Some("* Updated consequence".into()),
4186 ..Default::default()
4187 },
4188 )
4189 .unwrap();
4190
4191 let after = fs::read_to_string(&adr_path).unwrap();
4192 assert!(after.contains("```markdown"));
4193 assert!(after.contains("Example consequences inside a fence."));
4194 assert!(after.contains("Trailing text after the fence."));
4195 assert!(after.contains("### Confirmation"));
4196 assert!(after.contains("We will confirm via load tests."));
4197 assert!(after.contains("* Updated consequence"));
4198 assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
4199 }
4200
4201 #[test]
4202 fn test_body_patch_preserves_sections_without_trailing_newline() {
4203 let temp = TempDir::new().unwrap();
4204 let repo = Repository::init(temp.path(), None, false).unwrap();
4205
4206 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.";
4207 let adr_path = repo.adr_path().join("0002-no-trailing-newline.md");
4208 fs::write(&adr_path, content).unwrap();
4209 assert_ne!(fs::read(&adr_path).unwrap().last(), Some(&b'\n'));
4210
4211 let mut adr = repo.get(2).unwrap();
4212 adr.decision = "New decision.".into();
4213 repo.update(
4214 &adr,
4215 BodySectionPatch {
4216 decision: Some("New decision.".into()),
4217 ..Default::default()
4218 },
4219 )
4220 .unwrap();
4221
4222 let after = fs::read_to_string(&adr_path).unwrap();
4223 assert!(!after.contains("Old context.## Decision"));
4224 let reparsed = repo.get(2).unwrap();
4225 assert!(reparsed.decision.contains("New decision."));
4226 }
4227
4228 #[test]
4229 fn test_people_field_yaml_unchanged_skip_rewrite() {
4230 let temp = TempDir::new().unwrap();
4231 let repo = Repository::init(temp.path(), None, true).unwrap();
4232
4233 let content = r#"---
4234number: 2
4235title: Zero indent consulted
4236date: 2026-01-15
4237status: proposed
4238consulted:
4239- alice
4240- bob
4241---
4242
4243## Context
4244
4245Context.
4246"#;
4247 let adr_path = repo.adr_path().join("0002-zero-indent-consulted.md");
4248 fs::write(&adr_path, content).unwrap();
4249 let before = fs::read_to_string(&adr_path).unwrap();
4250
4251 let adr = repo.get(2).unwrap();
4252 repo.update_metadata(&adr).unwrap();
4253
4254 let after = fs::read_to_string(&adr_path).unwrap();
4255 assert_eq!(before, after);
4256 }
4257
4258 #[test]
4259 fn test_body_only_update_preserves_non_canonical_status() {
4260 let temp = TempDir::new().unwrap();
4261 let repo = Repository::init(temp.path(), None, false).unwrap();
4262
4263 let content = r#"# 2. Non-canonical status
4264
4265Date: 2026-01-15
4266
4267## Status
4268
4269Approved by the architecture board on 2026-01-15.
4270
4271## Context
4272
4273Original context.
4274
4275## Decision
4276
4277Decision.
4278
4279## Consequences
4280
4281Consequences.
4282"#;
4283 let adr_path = repo.adr_path().join("0002-non-canonical-status.md");
4284 fs::write(&adr_path, content).unwrap();
4285
4286 let mut adr = repo.get(2).unwrap();
4287 adr.context = "Updated context.".into();
4288 repo.update(
4289 &adr,
4290 BodySectionPatch {
4291 context: Some("Updated context.".into()),
4292 ..Default::default()
4293 },
4294 )
4295 .unwrap();
4296
4297 let after = fs::read_to_string(&adr_path).unwrap();
4298 assert!(after.contains("Approved by the architecture board"));
4299 assert!(after.contains("Updated context."));
4300 }
4301
4302 #[test]
4303 fn test_missing_section_patch_returns_error() {
4304 let temp = TempDir::new().unwrap();
4305 let repo = Repository::init(temp.path(), None, false).unwrap();
4306
4307 let content = r#"# 2. No decision or consequences sections
4308
4309Date: 2026-01-15
4310
4311## Status
4312
4313Accepted
4314
4315## Context
4316
4317Context only.
4318"#;
4319 let adr_path = repo.adr_path().join("0002-no-consequences.md");
4320 fs::write(&adr_path, content).unwrap();
4321
4322 let mut adr = repo.get(2).unwrap();
4323 adr.consequences = "New consequences.".into();
4324 let err = repo
4325 .update(
4326 &adr,
4327 BodySectionPatch {
4328 consequences: Some("New consequences.".into()),
4329 ..Default::default()
4330 },
4331 )
4332 .unwrap_err();
4333 assert!(err.to_string().contains("consequences patch requested"));
4334 }
4335
4336 #[test]
4337 fn test_nygard_consequences_patch_errors_without_consequences_section() {
4338 let temp = TempDir::new().unwrap();
4339 let repo = Repository::init(temp.path(), None, false).unwrap();
4340
4341 let content = r#"# 3. Nygard decision without consequences section
4342
4343Date: 2026-01-15
4344
4345## Status
4346
4347Accepted
4348
4349## Context
4350
4351Context.
4352
4353## Decision
4354
4355We decided X.
4356"#;
4357 let adr_path = repo.adr_path().join("0003-nygard-no-consequences.md");
4358 fs::write(&adr_path, content).unwrap();
4359
4360 let mut adr = repo.get(3).unwrap();
4361 adr.consequences = "New consequences.".into();
4362 let err = repo
4363 .update(
4364 &adr,
4365 BodySectionPatch {
4366 consequences: Some("New consequences.".into()),
4367 ..Default::default()
4368 },
4369 )
4370 .unwrap_err();
4371 assert!(err.to_string().contains("consequences patch requested"));
4372 let after = fs::read_to_string(&adr_path).unwrap();
4373 assert!(!after.contains("### Consequences"));
4374 assert!(!after.contains("New consequences."));
4375 }
4376
4377 #[test]
4378 fn test_decision_patch_preserves_fence_in_context() {
4379 let temp = TempDir::new().unwrap();
4380 let repo = Repository::init(temp.path(), None, false).unwrap();
4381
4382 let content = r#"# 4. Fence in context
4383
4384Date: 2026-01-15
4385
4386## Status
4387
4388Accepted
4389
4390## Context
4391
4392Example:
4393
4394```
4395## Decision
4396not a real heading
4397```
4398
4399## Decision
4400
4401We decided.
4402"#;
4403 let adr_path = repo.adr_path().join("0004-fence-in-context.md");
4404 fs::write(&adr_path, content).unwrap();
4405
4406 repo.update(
4407 &repo.get(4).unwrap(),
4408 BodySectionPatch {
4409 decision: Some("Updated decision.".into()),
4410 ..Default::default()
4411 },
4412 )
4413 .unwrap();
4414
4415 let after = fs::read_to_string(&adr_path).unwrap();
4416 assert!(after.contains("## Decision\nnot a real heading"));
4417 assert!(after.contains("Updated decision."));
4418 assert!(!after.contains("We decided."));
4419 }
4420
4421 fn is_uniformly_crlf(content: &str) -> bool {
4426 content.contains("\r\n") && !content.replace("\r\n", "").contains('\n')
4427 }
4428
4429 #[test]
4430 fn test_crlf_madr_body_patch_preserves_line_endings() {
4431 let temp = TempDir::new().unwrap();
4432 let repo = Repository::init(temp.path(), None, true).unwrap();
4433
4434 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";
4435 let crlf_content = lf_content.replace('\n', "\r\n");
4436 let adr_path = repo.adr_path().join("0002-crlf-madr.md");
4437 fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
4438
4439 let mut adr = repo.get(2).unwrap();
4440 adr.context = "New context.".into();
4441 adr.consequences = "* New good\n* New bad".into();
4442 repo.update(
4443 &adr,
4444 BodySectionPatch::new()
4445 .with_context("New context.")
4446 .with_consequences("* New good\n* New bad"),
4447 )
4448 .unwrap();
4449
4450 let after = String::from_utf8(fs::read(&adr_path).unwrap()).unwrap();
4451
4452 assert!(
4455 is_uniformly_crlf(&after),
4456 "expected uniform CRLF, found a bare \\n\n{after:?}"
4457 );
4458 assert!(after.contains("New context.\r\n"));
4459 assert!(after.contains("* New good\r\n* New bad\r\n"));
4461 assert!(after.contains("Chosen option: \"Redis\", because it is fast."));
4462
4463 let reparsed = repo.get(2).unwrap();
4464 assert_eq!(reparsed.context, "New context.");
4465 assert!(reparsed.consequences.contains("New good"));
4469 assert!(!reparsed.decision.contains("New good"));
4470 assert!(!reparsed.context.contains('\r'), "stray \\r in context");
4471 assert!(!reparsed.decision.contains('\r'), "stray \\r in decision");
4472 assert!(
4473 !reparsed.consequences.contains('\r'),
4474 "stray \\r in consequences"
4475 );
4476 }
4477
4478 #[test]
4479 fn test_crlf_nygard_body_patch_preserves_line_endings() {
4480 let temp = TempDir::new().unwrap();
4481 let repo = Repository::init(temp.path(), None, false).unwrap();
4482
4483 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";
4484 let crlf_content = lf_content.replace('\n', "\r\n");
4485 let adr_path = repo.adr_path().join("0002-crlf-nygard.md");
4486 fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
4487
4488 let mut adr = repo.get(2).unwrap();
4489 adr.decision = "New decision.".into();
4490 repo.update(&adr, BodySectionPatch::new().with_decision("New decision."))
4491 .unwrap();
4492
4493 let after = String::from_utf8(fs::read(&adr_path).unwrap()).unwrap();
4494
4495 assert!(
4496 is_uniformly_crlf(&after),
4497 "expected uniform CRLF, found a bare \\n\n{after:?}"
4498 );
4499 assert!(after.contains("New decision.\r\n"));
4500 assert!(after.contains("Old context."));
4501 assert!(after.contains("Old consequences."));
4502
4503 let reparsed = repo.get(2).unwrap();
4504 assert_eq!(reparsed.decision, "New decision.");
4505 assert!(!reparsed.context.contains('\r'), "stray \\r in context");
4506 assert!(!reparsed.decision.contains('\r'), "stray \\r in decision");
4507 assert!(
4508 !reparsed.consequences.contains('\r'),
4509 "stray \\r in consequences"
4510 );
4511 }
4512
4513 #[test]
4514 fn test_update_metadata_on_crlf_frontmatter_file_updates_and_preserves_crlf() {
4515 let temp = TempDir::new().unwrap();
4521 let repo = Repository::init(temp.path(), None, true).unwrap();
4522
4523 let lf_content = "---\nnumber: 2\ntitle: CRLF metadata\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
4524 let crlf_content = lf_content.replace('\n', "\r\n");
4525 let adr_path = repo.adr_path().join("0002-crlf-metadata.md");
4526 fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
4527
4528 let mut adr = repo.get(2).unwrap();
4529 adr.status = AdrStatus::Accepted;
4530 repo.update_metadata(&adr).unwrap();
4531
4532 let after = String::from_utf8(fs::read(&adr_path).unwrap()).unwrap();
4533 assert!(
4534 after.contains("status: accepted"),
4535 "status must actually update on a CRLF file\n{after}"
4536 );
4537 assert!(
4538 is_uniformly_crlf(&after),
4539 "expected uniform CRLF, found a bare \\n\n{after:?}"
4540 );
4541
4542 let listed = repo.get(2).unwrap();
4543 assert_eq!(listed.status, AdrStatus::Accepted);
4544 }
4545
4546 #[test]
4547 fn test_noop_metadata_update_on_crlf_file_is_byte_identical() {
4548 let temp = TempDir::new().unwrap();
4549 let repo = Repository::init(temp.path(), None, true).unwrap();
4550
4551 let lf_content = "---\nnumber: 2\ntitle: CRLF noop\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
4552 let crlf_content = lf_content.replace('\n', "\r\n");
4553 let adr_path = repo.adr_path().join("0002-crlf-noop.md");
4554 fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
4555
4556 let before = fs::read(&adr_path).unwrap();
4557 let adr = repo.get(2).unwrap();
4558 repo.update_metadata(&adr).unwrap();
4559 let after = fs::read(&adr_path).unwrap();
4560
4561 assert_eq!(
4562 before, after,
4563 "no-op metadata update on a CRLF file must be byte-identical"
4564 );
4565 }
4566
4567 #[test]
4568 fn test_update_metadata_number_field_is_noop_when_unchanged() {
4569 let temp = TempDir::new().unwrap();
4576 let repo = Repository::init(temp.path(), None, true).unwrap();
4577
4578 let adr = repo.get(1).unwrap();
4579 let path = adr.path.clone().unwrap();
4580 let before = fs::read(&path).unwrap();
4581
4582 repo.update_metadata(&adr).unwrap();
4583
4584 let after = fs::read(&path).unwrap();
4585 assert_eq!(
4586 before, after,
4587 "update_metadata with an unchanged number must be byte-identical"
4588 );
4589 }
4590
4591 #[test]
4592 fn test_update_legacy_metadata_on_crlf_file_preserves_crlf() {
4593 let temp = TempDir::new().unwrap();
4598 let repo = Repository::init(temp.path(), None, false).unwrap();
4599
4600 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";
4601 let crlf_content = lf_content.replace('\n', "\r\n");
4602 let adr_path = repo.adr_path().join("0002-crlf-legacy.md");
4603 fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
4604
4605 let mut adr = repo.get(2).unwrap();
4606 adr.status = AdrStatus::Accepted;
4607 repo.update_metadata(&adr).unwrap();
4608
4609 let after = String::from_utf8(fs::read(&adr_path).unwrap()).unwrap();
4610 assert!(
4611 after.contains("## Status\r\n\r\nAccepted\r\n"),
4612 "status must update on a CRLF legacy file\n{after:?}"
4613 );
4614 assert!(
4615 is_uniformly_crlf(&after),
4616 "expected uniform CRLF, found a bare \\n\n{after:?}"
4617 );
4618
4619 let listed = repo.get(2).unwrap();
4620 assert_eq!(listed.status, AdrStatus::Accepted);
4621 }
4622
4623 #[test]
4624 fn test_noop_legacy_metadata_update_on_crlf_file_is_byte_identical() {
4625 let temp = TempDir::new().unwrap();
4626 let repo = Repository::init(temp.path(), None, false).unwrap();
4627
4628 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";
4629 let crlf_content = lf_content.replace('\n', "\r\n");
4630 let adr_path = repo.adr_path().join("0002-crlf-legacy-noop.md");
4631 fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
4632
4633 let before = fs::read(&adr_path).unwrap();
4634 let adr = repo.get(2).unwrap();
4635 repo.update_metadata(&adr).unwrap();
4636 let after = fs::read(&adr_path).unwrap();
4637
4638 assert_eq!(
4639 before, after,
4640 "no-op legacy metadata update on a CRLF file must be byte-identical"
4641 );
4642 }
4643
4644 #[test]
4647 fn test_decision_patch_middle_section_exact_blank_line_boundary() {
4648 let temp = TempDir::new().unwrap();
4649 let repo = Repository::init(temp.path(), None, false).unwrap();
4650
4651 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";
4652 let adr_path = repo.adr_path().join("0002-blank-line-boundary.md");
4653 fs::write(&adr_path, content).unwrap();
4654
4655 let mut adr = repo.get(2).unwrap();
4656 adr.decision = "New text.".into();
4657 repo.update(&adr, BodySectionPatch::new().with_decision("New text."))
4658 .unwrap();
4659
4660 let after = fs::read_to_string(&adr_path).unwrap();
4661 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";
4662 assert_eq!(after, expected);
4663 }
4664
4665 #[test]
4666 fn test_decision_patch_middle_section_exact_blank_line_boundary_crlf() {
4667 let temp = TempDir::new().unwrap();
4668 let repo = Repository::init(temp.path(), None, false).unwrap();
4669
4670 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";
4671 let content = lf_content.replace('\n', "\r\n");
4672 let adr_path = repo.adr_path().join("0002-blank-line-boundary-crlf.md");
4673 fs::write(&adr_path, content.as_bytes()).unwrap();
4674
4675 let mut adr = repo.get(2).unwrap();
4676 adr.decision = "New text.".into();
4677 repo.update(&adr, BodySectionPatch::new().with_decision("New text."))
4678 .unwrap();
4679
4680 let after = fs::read_to_string(&adr_path).unwrap();
4681 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";
4682 let expected = expected_lf.replace('\n', "\r\n");
4683 assert_eq!(after, expected);
4684 }
4685
4686 #[test]
4687 fn test_consequences_patch_last_section_no_trailing_blank_accumulation() {
4688 let temp = TempDir::new().unwrap();
4689 let repo = Repository::init(temp.path(), None, false).unwrap();
4690
4691 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";
4692 let adr_path = repo.adr_path().join("0002-last-section.md");
4693 fs::write(&adr_path, content).unwrap();
4694
4695 let mut adr = repo.get(2).unwrap();
4696 adr.consequences = "New consequences.".into();
4697 repo.update(
4698 &adr,
4699 BodySectionPatch::new().with_consequences("New consequences."),
4700 )
4701 .unwrap();
4702
4703 let after = fs::read_to_string(&adr_path).unwrap();
4704 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";
4705 assert_eq!(after, expected);
4706 assert!(
4707 !after.ends_with("\n\n"),
4708 "trailing blank line accumulated at EOF"
4709 );
4710 }
4711
4712 #[test]
4713 fn test_repeated_identical_context_patch_is_idempotent() {
4714 let temp = TempDir::new().unwrap();
4715 let repo = Repository::init(temp.path(), None, false).unwrap();
4716
4717 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";
4718 let adr_path = repo.adr_path().join("0002-idempotent-context.md");
4719 fs::write(&adr_path, content).unwrap();
4720
4721 let adr = repo.get(2).unwrap();
4722 let patch = BodySectionPatch::new().with_context("New context.");
4723
4724 repo.update(&adr, patch.clone()).unwrap();
4725 let after_first = fs::read(&adr_path).unwrap();
4726
4727 repo.update(&adr, patch).unwrap();
4728 let after_second = fs::read(&adr_path).unwrap();
4729
4730 assert_eq!(
4731 after_first, after_second,
4732 "repeating an identical patch must produce identical bytes"
4733 );
4734 }
4735
4736 #[test]
4737 fn test_repeated_identical_madr_consequences_append_is_idempotent() {
4738 let temp = TempDir::new().unwrap();
4743 let repo = Repository::init(temp.path(), None, true).unwrap();
4744
4745 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";
4746 let adr_path = repo.adr_path().join("0002-idempotence.md");
4747 fs::write(&adr_path, content).unwrap();
4748
4749 let adr = repo.get(2).unwrap();
4750 let patch = BodySectionPatch::new()
4751 .with_decision("New intro.")
4752 .with_consequences("* New consequence");
4753
4754 repo.update(&adr, patch.clone()).unwrap();
4755 let after_first = fs::read(&adr_path).unwrap();
4756
4757 repo.update(&adr, patch).unwrap();
4758 let after_second = fs::read(&adr_path).unwrap();
4759
4760 assert_eq!(
4761 after_first, after_second,
4762 "repeating an identical patch must produce identical bytes"
4763 );
4764 }
4765
4766 fn snapshot_dir(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
4771 let mut files: Vec<(PathBuf, Vec<u8>)> = WalkDir::new(dir)
4772 .into_iter()
4773 .filter_map(|e| e.ok())
4774 .filter(|e| e.file_type().is_file())
4775 .map(|e| {
4776 let path = e.path().to_path_buf();
4777 let bytes = fs::read(&path).unwrap();
4778 (path, bytes)
4779 })
4780 .collect();
4781 files.sort_by(|a, b| a.0.cmp(&b.0));
4782 files
4783 }
4784
4785 #[test]
4786 fn test_renumber_nextgen_rewrites_filename_frontmatter_h1_and_inbound_link() {
4787 let temp = TempDir::new().unwrap();
4788 let repo = Repository::init(temp.path(), None, true).unwrap();
4789
4790 repo.new_adr("Use MySQL").unwrap(); repo.supersede("Use PostgreSQL", 2).unwrap(); let old_path = repo.adr_path().join("0002-use-mysql.md");
4799 let new_path = repo.adr_path().join("0005-use-mysql.md");
4800
4801 let result = repo.renumber(2, 5, None, false).unwrap();
4802
4803 assert!(!result.no_op);
4804 assert_eq!(
4805 result.renamed_file,
4806 Some((old_path.clone(), new_path.clone()))
4807 );
4808 assert!(result.frontmatter_updated);
4809 assert!(result.h1_updated);
4810
4811 assert!(!old_path.exists());
4812 assert!(new_path.exists());
4813
4814 let content = fs::read_to_string(&new_path).unwrap();
4815 assert!(
4816 content.contains("number: 5"),
4817 "frontmatter number must be rewritten\n{content}"
4818 );
4819 assert!(
4820 content.contains("# 5. Use MySQL"),
4821 "H1 must be rewritten\n{content}"
4822 );
4823
4824 let adr3 = repo.get(3).unwrap();
4827 assert!(adr3.links.iter().any(|l| l.target == 5));
4828 assert!(!adr3.links.iter().any(|l| l.target == 2));
4829
4830 let adr3_path = repo.adr_path().join("0003-use-postgresql.md");
4831 let adr3_content = fs::read_to_string(&adr3_path).unwrap();
4832 assert!(
4833 adr3_content.contains("Supersedes [5. Use MySQL](0005-use-mysql.md)"),
4834 "inbound body link must be rewritten\n{adr3_content}"
4835 );
4836 assert!(!adr3_content.contains("0002-use-mysql.md"));
4837
4838 assert_eq!(result.updated_references, vec![adr3_path]);
4839 assert!(result.prose_warnings.is_empty());
4840 }
4841
4842 #[test]
4843 fn test_renumber_compatible_rewrites_filename_h1_and_inbound_body_link() {
4844 let temp = TempDir::new().unwrap();
4845 let repo = Repository::init(temp.path(), None, false).unwrap();
4846
4847 repo.new_adr("First decision").unwrap(); repo.new_adr("Second decision").unwrap(); repo.link(3, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
4850 .unwrap();
4851
4852 let old_path = repo.adr_path().join("0002-first-decision.md");
4853 let new_path = repo.adr_path().join("0005-first-decision.md");
4854
4855 let result = repo.renumber(2, 5, None, false).unwrap();
4856
4857 assert!(!result.no_op);
4858 assert!(!result.frontmatter_updated);
4860 assert!(result.h1_updated);
4861
4862 assert!(!old_path.exists());
4863 assert!(new_path.exists());
4864
4865 let content = fs::read_to_string(&new_path).unwrap();
4866 assert!(
4867 content.starts_with("# 5. First decision"),
4868 "H1 must be rewritten\n{content}"
4869 );
4870
4871 let adr3_path = repo.adr_path().join("0003-second-decision.md");
4872 let adr3_content = fs::read_to_string(&adr3_path).unwrap();
4873 assert!(
4874 adr3_content.contains("[5. First decision](0005-first-decision.md)"),
4875 "inbound body link, including its text, must be rewritten\n{adr3_content}"
4876 );
4877 assert!(!adr3_content.contains("0002-first-decision.md"));
4878
4879 assert_eq!(result.updated_references, vec![adr3_path]);
4880 }
4881
4882 #[test]
4883 fn test_renumber_resolves_duplicate_via_file_and_leaves_other_untouched() {
4884 let temp = TempDir::new().unwrap();
4885 let repo = Repository::init(temp.path(), None, true).unwrap();
4886
4887 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";
4888 let path_a = repo.adr_path().join("0003-branch-a.md");
4889 fs::write(&path_a, content_a).unwrap();
4890
4891 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";
4892 let path_b = repo.adr_path().join("0003-branch-b.md");
4893 fs::write(&path_b, content_b).unwrap();
4894
4895 let before = crate::lint::check_all(&repo).unwrap();
4896 assert!(
4897 before.issues.iter().any(|i| i.rule_id == "ADR012"),
4898 "expected ADR012 (duplicate number) before the fix: {:?}",
4899 before.issues
4900 );
4901
4902 let new_path = repo.adr_path().join("0004-branch-b.md");
4903 let result = repo.renumber(3, 4, Some(&path_b), false).unwrap();
4904
4905 assert_eq!(
4906 result.renamed_file,
4907 Some((path_b.clone(), new_path.clone()))
4908 );
4909 assert!(!path_b.exists());
4910 assert!(new_path.exists());
4911
4912 let content_a_after = fs::read(&path_a).unwrap();
4914 assert_eq!(content_a_after, content_a.as_bytes());
4915
4916 let after = crate::lint::check_all(&repo).unwrap();
4917 assert!(
4918 !after.issues.iter().any(|i| i.rule_id == "ADR012"),
4919 "doctor should be clean after the fix: {:?}",
4920 after.issues
4921 );
4922 }
4923
4924 #[test]
4925 fn test_renumber_leaves_ambiguous_number_references_alone_and_reports_them() {
4926 let temp = TempDir::new().unwrap();
4927 let repo = Repository::init(temp.path(), None, true).unwrap();
4928
4929 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";
4935 let path1 = repo
4936 .adr_path()
4937 .join("0001-record-architecture-decisions.md");
4938 fs::write(&path1, adr1).unwrap();
4939
4940 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";
4941 fs::write(repo.adr_path().join("0002-use-postgresql.md"), pg).unwrap();
4942
4943 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";
4944 let redis_path = repo.adr_path().join("0002-use-redis.md");
4945 fs::write(&redis_path, redis).unwrap();
4946
4947 let result = repo.renumber(2, 3, Some(&redis_path), false).unwrap();
4948
4949 assert_eq!(fs::read(&path1).unwrap(), adr1.as_bytes());
4951 assert!(
4952 !result.updated_references.contains(&path1),
4953 "ADR 1 must not be counted as rewritten"
4954 );
4955 assert!(
4956 result.ambiguous_references.contains(&path1),
4957 "ADR 1's ambiguous reference should be reported, got: {:?}",
4958 result.ambiguous_references
4959 );
4960 }
4961
4962 #[test]
4963 fn test_renumber_rewrites_reference_held_by_the_other_duplicate() {
4964 let temp = TempDir::new().unwrap();
4965 let repo = Repository::init(temp.path(), None, true).unwrap();
4966
4967 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";
4971 let path_a = repo.adr_path().join("0003-branch-a.md");
4972 fs::write(&path_a, content_a).unwrap();
4973
4974 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";
4975 let path_b = repo.adr_path().join("0003-branch-b.md");
4976 fs::write(&path_b, content_b).unwrap();
4977
4978 repo.renumber(3, 4, Some(&path_b), false).unwrap();
4979
4980 let after_a = fs::read_to_string(&path_a).unwrap();
4981 assert!(
4982 after_a.contains("[4. Branch B](0004-branch-b.md)"),
4983 "the remaining duplicate's link to the renumbered file should be rewritten, got: {after_a}"
4984 );
4985 assert!(
4986 !after_a.contains("0003-branch-b.md"),
4987 "no reference to the old filename should survive, got: {after_a}"
4988 );
4989 }
4990
4991 #[test]
4992 fn test_renumber_ambiguous_source_without_file_errors() {
4993 let temp = TempDir::new().unwrap();
4994 let repo = Repository::init(temp.path(), None, true).unwrap();
4995
4996 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";
4997 let path_a = repo.adr_path().join("0003-branch-a.md");
4998 fs::write(&path_a, content_a).unwrap();
4999
5000 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";
5001 let path_b = repo.adr_path().join("0003-branch-b.md");
5002 fs::write(&path_b, content_b).unwrap();
5003
5004 let err = repo.renumber(3, 4, None, false).unwrap_err();
5005 let msg = err.to_string();
5006 assert!(
5007 msg.contains("0003-branch-a.md") && msg.contains("0003-branch-b.md"),
5008 "error should name both candidates: {msg}"
5009 );
5010 assert!(
5011 msg.contains("--file"),
5012 "error should direct the user to --file: {msg}"
5013 );
5014
5015 assert_eq!(fs::read(&path_a).unwrap(), content_a.as_bytes());
5017 assert_eq!(fs::read(&path_b).unwrap(), content_b.as_bytes());
5018 }
5019
5020 #[test]
5021 fn test_renumber_occupied_target_errors_and_writes_nothing() {
5022 let temp = TempDir::new().unwrap();
5023 let repo = Repository::init(temp.path(), None, true).unwrap();
5024 repo.new_adr("Second decision").unwrap(); let before = snapshot_dir(&repo.adr_path());
5027
5028 let err = repo.renumber(2, 1, None, false).unwrap_err();
5029 let msg = err.to_string();
5030 assert!(
5031 msg.contains("already used") && msg.contains(crate::init_adr::TITLE),
5032 "error should name the occupying record: {msg}"
5033 );
5034 assert!(
5036 msg.contains("try 3"),
5037 "error should suggest the smallest free number: {msg}"
5038 );
5039
5040 let after = snapshot_dir(&repo.adr_path());
5041 assert_eq!(before, after, "nothing should be written on refusal");
5042 }
5043
5044 #[test]
5045 fn test_renumber_from_equals_to_is_a_reported_noop() {
5046 let temp = TempDir::new().unwrap();
5047 let repo = Repository::init(temp.path(), None, true).unwrap();
5048
5049 let before = snapshot_dir(&repo.adr_path());
5050 let result = repo.renumber(1, 1, None, false).unwrap();
5051 let after = snapshot_dir(&repo.adr_path());
5052
5053 assert!(result.no_op);
5054 assert_eq!(before, after);
5055 }
5056
5057 #[test]
5058 fn test_renumber_dry_run_writes_nothing() {
5059 let temp = TempDir::new().unwrap();
5060 let repo = Repository::init(temp.path(), None, true).unwrap();
5061
5062 repo.new_adr("First decision").unwrap(); repo.new_adr("Second decision").unwrap(); repo.link(3, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
5065 .unwrap();
5066
5067 let before = snapshot_dir(&repo.adr_path());
5068
5069 let result = repo.renumber(2, 5, None, true).unwrap();
5070
5071 assert!(!result.no_op);
5072 assert!(result.renamed_file.is_some());
5073 assert!(result.frontmatter_updated);
5074 assert!(result.h1_updated);
5075 assert_eq!(result.updated_references.len(), 1);
5076
5077 let after = snapshot_dir(&repo.adr_path());
5078 assert_eq!(before, after, "dry run must not write anything");
5079 }
5080
5081 #[test]
5082 fn test_renumber_preserves_crlf_and_leaves_unrelated_file_untouched() {
5083 let temp = TempDir::new().unwrap();
5084 let repo = Repository::init(temp.path(), None, true).unwrap();
5085
5086 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";
5087 let crlf_content = lf_content.replace('\n', "\r\n");
5088 let path = repo.adr_path().join("0002-crlf-renumber.md");
5089 fs::write(&path, crlf_content.as_bytes()).unwrap();
5090
5091 repo.new_adr("Unrelated").unwrap(); let unrelated_path = repo.adr_path().join("0003-unrelated.md");
5093 let unrelated_before = fs::read(&unrelated_path).unwrap();
5094
5095 let result = repo.renumber(2, 5, None, false).unwrap();
5096
5097 let new_path = repo.adr_path().join("0005-crlf-renumber.md");
5098 let after = fs::read_to_string(&new_path).unwrap();
5099 assert!(
5100 is_uniformly_crlf(&after),
5101 "expected uniform CRLF, found a bare \\n\n{after:?}"
5102 );
5103 assert!(after.contains("number: 5"));
5104 assert!(after.contains("# 5. CRLF renumber"));
5105
5106 let unrelated_after = fs::read(&unrelated_path).unwrap();
5107 assert_eq!(
5108 unrelated_before, unrelated_after,
5109 "a record with no reference to `from` must not be rewritten at all"
5110 );
5111 assert!(result.updated_references.is_empty());
5112 }
5113
5114 #[test]
5115 fn test_renumber_preserves_hand_written_body_content() {
5116 let temp = TempDir::new().unwrap();
5120 let repo = Repository::init(temp.path(), None, true).unwrap();
5121
5122 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";
5123 let path = repo.adr_path().join("0002-hand-written.md");
5124 fs::write(&path, content).unwrap();
5125
5126 repo.renumber(2, 5, None, false).unwrap();
5127
5128 let new_path = repo.adr_path().join("0005-hand-written.md");
5129 let after = fs::read_to_string(&new_path).unwrap();
5130
5131 let expected = content
5132 .replace("number: 2", "number: 5")
5133 .replace("# 2. Hand Written", "# 5. Hand Written");
5134 assert_eq!(
5135 after, expected,
5136 "only number/H1 may change; everything else must survive verbatim"
5137 );
5138 }
5139
5140 #[test]
5141 fn test_renumber_reports_prose_references_outside_adr_dir_without_rewriting() {
5142 let temp = TempDir::new().unwrap();
5143 let repo = Repository::init(temp.path(), None, true).unwrap();
5144
5145 repo.new_adr("First decision").unwrap(); let readme_path = temp.path().join("README.md");
5148 let readme_before =
5149 "See [2. First decision](doc/adr/0002-first-decision.md) for details.\n";
5150 fs::write(&readme_path, readme_before).unwrap();
5151
5152 let result = repo.renumber(2, 5, None, false).unwrap();
5153
5154 assert_eq!(result.prose_warnings, vec![readme_path.clone()]);
5155
5156 let readme_after = fs::read_to_string(&readme_path).unwrap();
5158 assert_eq!(readme_after, readme_before);
5159 }
5160}