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 std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::LazyLock;
14use walkdir::WalkDir;
15
16static FM_STATUS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^status:\s*.*$").unwrap());
18
19static FM_LINKS_RE: LazyLock<Regex> =
21 LazyLock::new(|| Regex::new(r"(?m)^links:\n(?:(?: .+\n)*)").unwrap());
22
23static FM_TAGS_RE: LazyLock<Regex> =
25 LazyLock::new(|| Regex::new(r"(?m)^tags:\n(?:(?: .+\n)*)").unwrap());
26
27#[derive(Debug)]
29pub struct Repository {
30 root: PathBuf,
32
33 config: Config,
35
36 parser: Parser,
38
39 template_engine: TemplateEngine,
41}
42
43impl Repository {
44 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
46 let root = root.into();
47 let config = Config::load(&root)?;
48 let template_engine = Self::engine_from_config(&config);
49
50 Ok(Self {
51 root,
52 config,
53 parser: Parser::new(),
54 template_engine,
55 })
56 }
57
58 pub fn open_or_default(root: impl Into<PathBuf>) -> Self {
60 let root = root.into();
61 let config = Config::load_or_default(&root);
62 let template_engine = Self::engine_from_config(&config);
63
64 Self {
65 root,
66 config,
67 parser: Parser::new(),
68 template_engine,
69 }
70 }
71
72 pub fn init(root: impl Into<PathBuf>, adr_dir: Option<PathBuf>, ng: bool) -> Result<Self> {
74 let root = root.into();
75 let adr_dir = adr_dir.unwrap_or_else(|| PathBuf::from(crate::config::DEFAULT_ADR_DIR));
76 let adr_path = root.join(&adr_dir);
77
78 let existing_adrs = if adr_path.exists() {
80 count_existing_adrs(&adr_path)
81 } else {
82 fs::create_dir_all(&adr_path)?;
84 0
85 };
86
87 let config = Config {
89 adr_dir,
90 mode: if ng {
91 ConfigMode::NextGen
92 } else {
93 ConfigMode::Compatible
94 },
95 ..Default::default()
96 };
97 config.save(&root)?;
98
99 let template_engine = Self::engine_from_config(&config);
100
101 let repo = Self {
102 root,
103 config,
104 parser: Parser::new(),
105 template_engine,
106 };
107
108 if existing_adrs == 0 {
110 let mut adr = Adr::new(1, "Record architecture decisions");
111 adr.status = AdrStatus::Accepted;
112 adr.context =
113 "We need to record the architectural decisions made on this project.".into();
114 adr.decision = "We will use Architecture Decision Records, as described by Michael Nygard in his article \"Documenting Architecture Decisions\".".into();
115 adr.consequences = "See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's adr-tools.".into();
116 repo.create(&adr)?;
117 }
118
119 Ok(repo)
120 }
121
122 pub fn root(&self) -> &Path {
124 &self.root
125 }
126
127 pub fn config(&self) -> &Config {
129 &self.config
130 }
131
132 pub fn adr_path(&self) -> PathBuf {
134 self.config.adr_path(&self.root)
135 }
136
137 fn engine_from_config(config: &Config) -> TemplateEngine {
139 let mut engine = TemplateEngine::new();
140 if let Some(ref fmt) = config.templates.format
141 && let Ok(format) = fmt.parse::<TemplateFormat>()
142 {
143 engine = engine.with_format(format);
144 }
145 engine
146 }
147
148 pub fn with_template_format(mut self, format: TemplateFormat) -> Self {
150 self.template_engine = self.template_engine.with_format(format);
151 self
152 }
153
154 pub fn with_template_variant(mut self, variant: TemplateVariant) -> Self {
156 self.template_engine = self.template_engine.with_variant(variant);
157 self
158 }
159
160 pub fn with_mode(mut self, mode: ConfigMode) -> Self {
162 self.config.mode = mode;
163 self
164 }
165
166 pub fn with_custom_template(mut self, template: Template) -> Self {
168 self.template_engine = self.template_engine.with_custom_template(template);
169 self
170 }
171
172 pub fn list(&self) -> Result<Vec<Adr>> {
174 let adr_path = self.adr_path();
175 if !adr_path.exists() {
176 return Err(Error::AdrDirNotFound);
177 }
178
179 let mut adrs: Vec<Adr> = WalkDir::new(&adr_path)
180 .max_depth(1)
181 .into_iter()
182 .filter_map(|e| e.ok())
183 .filter(|e| {
184 e.path().extension().is_some_and(|ext| ext == "md")
185 && e.path()
186 .file_name()
187 .and_then(|n| n.to_str())
188 .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
189 })
190 .filter_map(|e| self.parser.parse_file(e.path()).ok())
191 .collect();
192
193 adrs.sort_by_key(|a| a.number);
194 Ok(adrs)
195 }
196
197 #[allow(clippy::type_complexity)]
203 pub fn list_with_errors(&self) -> Result<(Vec<Adr>, Vec<(PathBuf, crate::Error)>)> {
204 let adr_path = self.adr_path();
205 if !adr_path.exists() {
206 return Err(Error::AdrDirNotFound);
207 }
208
209 let mut adrs = Vec::new();
210 let mut errors = Vec::new();
211
212 let candidates: Vec<_> = WalkDir::new(&adr_path)
213 .max_depth(1)
214 .into_iter()
215 .filter_map(|e| e.ok())
216 .filter(|e| {
217 e.path().extension().is_some_and(|ext| ext == "md")
218 && e.path()
219 .file_name()
220 .and_then(|n| n.to_str())
221 .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
222 })
223 .collect();
224
225 for entry in candidates {
226 match self.parser.parse_file(entry.path()) {
227 Ok(adr) => adrs.push(adr),
228 Err(e) => errors.push((entry.path().to_path_buf(), e)),
229 }
230 }
231
232 adrs.sort_by_key(|a| a.number);
233 Ok((adrs, errors))
234 }
235
236 pub fn next_number(&self) -> Result<u32> {
238 let adrs = self.list()?;
239 Ok(adrs.last().map(|a| a.number + 1).unwrap_or(1))
240 }
241
242 pub fn get(&self, number: u32) -> Result<Adr> {
244 let adrs = self.list()?;
245 adrs.into_iter()
246 .find(|a| a.number == number)
247 .ok_or_else(|| Error::AdrNotFound(number.to_string()))
248 }
249
250 pub fn find(&self, query: &str) -> Result<Adr> {
252 if let Ok(number) = query.parse::<u32>() {
254 return self.get(number);
255 }
256
257 let adrs = self.list()?;
259 let matcher = SkimMatcherV2::default();
260
261 let mut matches: Vec<_> = adrs
262 .into_iter()
263 .filter_map(|adr| {
264 let score = matcher.fuzzy_match(&adr.title, query)?;
265 Some((adr, score))
266 })
267 .collect();
268
269 matches.sort_by_key(|m| std::cmp::Reverse(m.1));
270
271 match matches.len() {
272 0 => Err(Error::AdrNotFound(query.to_string())),
273 1 => Ok(matches.remove(0).0),
274 _ => {
275 if matches[0].1 > matches[1].1 * 2 {
277 Ok(matches.remove(0).0)
278 } else {
279 Err(Error::AmbiguousAdr {
280 query: query.to_string(),
281 matches: matches
282 .iter()
283 .take(5)
284 .map(|(a, _)| a.title.clone())
285 .collect(),
286 })
287 }
288 }
289 }
290 }
291
292 fn resolve_link_titles(&self, adr: &Adr) -> HashMap<u32, (String, String)> {
294 let mut map = HashMap::new();
295 for link in &adr.links {
296 if map.contains_key(&link.target) {
297 continue;
298 }
299 if let Ok(target_adr) = self.get(link.target) {
300 map.insert(
301 link.target,
302 (target_adr.title.clone(), target_adr.filename()),
303 );
304 }
305 }
306 map
307 }
308
309 pub fn create(&self, adr: &Adr) -> Result<PathBuf> {
311 let path = self.adr_path().join(adr.filename());
312
313 let link_titles = self.resolve_link_titles(adr);
314 let content = self
315 .template_engine
316 .render(adr, &self.config, &link_titles)?;
317 fs::write(&path, content)?;
318
319 Ok(path)
320 }
321
322 pub fn new_adr(&self, title: impl Into<String>) -> Result<(Adr, PathBuf)> {
324 let number = self.next_number()?;
325 let mut adr = Adr::new(number, title);
326 if let Some(default_status) = self.config.default_status.as_deref() {
327 adr.status = default_status.parse::<AdrStatus>().unwrap();
328 }
329 let path = self.create(&adr)?;
330 Ok((adr, path))
331 }
332
333 pub fn supersede(&self, title: impl Into<String>, superseded: u32) -> Result<(Adr, PathBuf)> {
335 let number = self.next_number()?;
336 let mut adr = Adr::new(number, title);
337 adr.add_link(AdrLink::new(superseded, LinkKind::Supersedes));
338
339 let path = self.create(&adr)?;
342
343 let mut old_adr = self.get(superseded)?;
346 old_adr.status = AdrStatus::Superseded;
347 old_adr.add_link(AdrLink::new(number, LinkKind::SupersededBy));
348 self.update_metadata(&old_adr)?;
349
350 Ok((adr, path))
351 }
352
353 pub fn set_status(
358 &self,
359 number: u32,
360 status: AdrStatus,
361 superseded_by: Option<u32>,
362 ) -> Result<PathBuf> {
363 let mut adr = self.get(number)?;
364 adr.status = status.clone();
365
366 if let (AdrStatus::Superseded, Some(by)) = (&status, superseded_by) {
368 let _ = self.get(by)?;
370
371 if !adr
373 .links
374 .iter()
375 .any(|l| matches!(l.kind, LinkKind::SupersededBy) && l.target == by)
376 {
377 adr.add_link(AdrLink::new(by, LinkKind::SupersededBy));
378 }
379 }
380
381 self.update_metadata(&adr)
382 }
383
384 pub fn link(
386 &self,
387 source: u32,
388 target: u32,
389 source_kind: LinkKind,
390 target_kind: LinkKind,
391 ) -> Result<()> {
392 let mut source_adr = self.get(source)?;
393 let mut target_adr = self.get(target)?;
394
395 source_adr.add_link(AdrLink::new(target, source_kind));
396 target_adr.add_link(AdrLink::new(source, target_kind));
397
398 self.update_metadata(&source_adr)?;
399 self.update_metadata(&target_adr)?;
400
401 Ok(())
402 }
403
404 pub fn update(&self, adr: &Adr) -> Result<PathBuf> {
406 let path = adr
407 .path
408 .clone()
409 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
410
411 let link_titles = self.resolve_link_titles(adr);
412 let content = self
413 .template_engine
414 .render(adr, &self.config, &link_titles)?;
415 fs::write(&path, content)?;
416
417 Ok(path)
418 }
419
420 pub fn read_content(&self, adr: &Adr) -> Result<String> {
422 let path = adr
423 .path
424 .as_ref()
425 .cloned()
426 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
427
428 Ok(fs::read_to_string(path)?)
429 }
430
431 pub fn write_content(&self, adr: &Adr, content: &str) -> Result<PathBuf> {
433 let path = adr
434 .path
435 .as_ref()
436 .cloned()
437 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
438
439 fs::write(&path, content)?;
440 Ok(path)
441 }
442
443 pub fn update_metadata(&self, adr: &Adr) -> Result<PathBuf> {
446 let path = adr
447 .path
448 .clone()
449 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
450
451 let content = fs::read_to_string(&path)?;
452
453 let updated = if content.starts_with("---\n") {
454 self.update_frontmatter_metadata(adr, &content)?
455 } else {
456 self.update_legacy_metadata(adr, &content)?
457 };
458
459 fs::write(&path, updated)?;
460 Ok(path)
461 }
462
463 fn update_frontmatter_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
469 let Some(rest) = content.strip_prefix("---\n") else {
471 return Err(Error::InvalidFormat {
472 path: Default::default(),
473 reason: "Missing opening frontmatter delimiter".into(),
474 });
475 };
476
477 let Some(end_idx) = rest.find("\n---\n").or_else(|| {
478 if rest.ends_with("\n---") {
480 Some(rest.len() - 3)
481 } else {
482 None
483 }
484 }) else {
485 return Err(Error::InvalidFormat {
486 path: Default::default(),
487 reason: "Missing closing frontmatter delimiter".into(),
488 });
489 };
490
491 let yaml_block = &rest[..end_idx + 1]; let after_yaml = &rest[end_idx..]; let new_status = format!("status: {}", adr.status.to_string().to_lowercase());
496 let yaml_block = FM_STATUS_RE.replace(yaml_block, new_status.as_str());
497
498 let links_yaml = Self::format_links_yaml(&adr.links);
500 let yaml_block = if FM_LINKS_RE.is_match(&yaml_block) {
501 FM_LINKS_RE
502 .replace(&yaml_block, links_yaml.as_str())
503 .into_owned()
504 } else if !links_yaml.is_empty() {
505 let mut s = yaml_block.into_owned();
507 if !s.ends_with('\n') {
508 s.push('\n');
509 }
510 s.push_str(&links_yaml);
511 s
512 } else {
513 yaml_block.into_owned()
514 };
515
516 let tags_yaml = Self::format_tags_yaml(&adr.tags);
518 let yaml_block = if FM_TAGS_RE.is_match(&yaml_block) {
519 FM_TAGS_RE
520 .replace(&yaml_block, tags_yaml.as_str())
521 .into_owned()
522 } else if !tags_yaml.is_empty() {
523 let mut s = yaml_block;
524 if !s.ends_with('\n') {
525 s.push('\n');
526 }
527 s.push_str(&tags_yaml);
528 s
529 } else {
530 yaml_block
531 };
532
533 let yaml_block = yaml_block.trim_end_matches('\n');
534 Ok(format!("---\n{}{}", yaml_block, after_yaml))
535 }
536
537 fn update_legacy_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
542 let lines: Vec<&str> = content.lines().collect();
543 let mut result = String::with_capacity(content.len());
544
545 let status_idx = lines.iter().position(|l| {
547 l.trim().eq_ignore_ascii_case("## Status") || l.trim().eq_ignore_ascii_case("## STATUS")
548 });
549
550 let Some(status_idx) = status_idx else {
551 return Ok(content.to_string());
553 };
554
555 let next_heading_idx = lines[status_idx + 1..]
557 .iter()
558 .position(|l| l.starts_with("## "))
559 .map(|i| i + status_idx + 1);
560
561 for line in &lines[..=status_idx] {
563 result.push_str(line);
564 result.push('\n');
565 }
566
567 result.push('\n');
569 result.push_str(&adr.status.to_string());
570 result.push('\n');
571
572 let link_titles = self.resolve_link_titles(adr);
574 for link in &adr.links {
575 result.push('\n');
576 if let Some((title, filename)) = link_titles.get(&link.target) {
577 result.push_str(&format!(
578 "{} [{}. {}]({})",
579 link.kind, link.target, title, filename
580 ));
581 } else {
582 result.push_str(&format!(
583 "{} [{}. ...]({:04}-....md)",
584 link.kind, link.target, link.target
585 ));
586 }
587 result.push('\n');
588 }
589
590 if let Some(next_idx) = next_heading_idx {
592 result.push('\n');
593 for (i, line) in lines[next_idx..].iter().enumerate() {
594 result.push_str(line);
595 if next_idx + i < lines.len() - 1 || content.ends_with('\n') {
597 result.push('\n');
598 }
599 }
600 } else if content.ends_with('\n') {
601 }
603
604 Ok(result)
605 }
606
607 fn format_links_yaml(links: &[AdrLink]) -> String {
609 if links.is_empty() {
610 return String::new();
611 }
612 let mut s = String::from("links:\n");
613 for link in links {
614 let kind_str = match &link.kind {
615 LinkKind::Supersedes => "supersedes",
616 LinkKind::SupersededBy => "supersededby",
617 LinkKind::Amends => "amends",
618 LinkKind::AmendedBy => "amendedby",
619 LinkKind::RelatesTo => "relatesto",
620 LinkKind::Custom(c) => c.as_str(),
621 };
622 s.push_str(&format!(
623 " - target: {}\n kind: {}\n",
624 link.target, kind_str
625 ));
626 }
627 s
628 }
629
630 fn format_tags_yaml(tags: &[String]) -> String {
632 if tags.is_empty() {
633 return String::new();
634 }
635 let mut s = String::from("tags:\n");
636 for tag in tags {
637 s.push_str(&format!(" - {}\n", tag));
638 }
639 s
640 }
641}
642
643fn count_existing_adrs(path: &Path) -> usize {
645 if !path.is_dir() {
646 return 0;
647 }
648
649 fs::read_dir(path)
650 .map(|entries| {
651 entries
652 .filter_map(|e| e.ok())
653 .filter(|e| {
654 let path = e.path();
655 path.is_file()
656 && path.extension().is_some_and(|ext| ext == "md")
657 && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
658 n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
660 })
661 })
662 .count()
663 })
664 .unwrap_or(0)
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use tempfile::TempDir;
671
672 #[test]
675 fn test_init_repository() {
676 let temp = TempDir::new().unwrap();
677 let repo = Repository::init(temp.path(), None, false).unwrap();
678
679 assert!(repo.adr_path().exists());
680 assert!(temp.path().join(".adr-dir").exists());
681
682 let adrs = repo.list().unwrap();
683 assert_eq!(adrs.len(), 1);
684 assert_eq!(adrs[0].number, 1);
685 assert_eq!(adrs[0].title, "Record architecture decisions");
686 }
687
688 #[test]
689 fn test_init_repository_ng() {
690 let temp = TempDir::new().unwrap();
691 let repo = Repository::init(temp.path(), None, true).unwrap();
692
693 assert!(temp.path().join("adrs.toml").exists());
694 assert!(repo.config().is_next_gen());
695 }
696
697 #[test]
698 fn test_init_repository_custom_dir() {
699 let temp = TempDir::new().unwrap();
700 let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
701
702 assert!(temp.path().join("decisions").exists());
703 assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
704 }
705
706 #[test]
707 fn test_init_repository_nested_dir() {
708 let temp = TempDir::new().unwrap();
709 let _repo =
710 Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
711
712 assert!(temp.path().join("docs/architecture/adr").exists());
713 }
714
715 #[test]
716 fn test_init_repository_already_exists_skips_initial_adr() {
717 let temp = TempDir::new().unwrap();
718 Repository::init(temp.path(), None, false).unwrap();
719
720 let repo = Repository::init(temp.path(), None, false).unwrap();
722 let adrs = repo.list().unwrap();
723 assert_eq!(adrs.len(), 1); }
725
726 #[test]
727 fn test_init_with_existing_adrs_skips_initial() {
728 let temp = TempDir::new().unwrap();
729 let adr_dir = temp.path().join("doc/adr");
730 fs::create_dir_all(&adr_dir).unwrap();
731
732 fs::write(
734 adr_dir.join("0001-existing-decision.md"),
735 "# 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",
736 )
737 .unwrap();
738 fs::write(
739 adr_dir.join("0002-another-decision.md"),
740 "# 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",
741 )
742 .unwrap();
743
744 let repo = Repository::init(temp.path(), None, false).unwrap();
746 let adrs = repo.list().unwrap();
747 assert_eq!(adrs.len(), 2); assert_eq!(adrs[0].title, "Existing Decision");
749 assert_eq!(adrs[1].title, "Another Decision");
750 }
751
752 #[test]
753 fn test_init_creates_first_adr() {
754 let temp = TempDir::new().unwrap();
755 let repo = Repository::init(temp.path(), None, false).unwrap();
756
757 let adr = repo.get(1).unwrap();
758 assert_eq!(adr.title, "Record architecture decisions");
759 assert_eq!(adr.status, AdrStatus::Accepted);
760 assert!(!adr.context.is_empty());
761 assert!(!adr.decision.is_empty());
762 assert!(!adr.consequences.is_empty());
763 }
764
765 #[test]
768 fn test_open_repository() {
769 let temp = TempDir::new().unwrap();
770 Repository::init(temp.path(), None, false).unwrap();
771
772 let repo = Repository::open(temp.path()).unwrap();
773 assert_eq!(repo.list().unwrap().len(), 1);
774 }
775
776 #[test]
777 fn test_open_repository_not_found() {
778 let temp = TempDir::new().unwrap();
779 let result = Repository::open(temp.path());
780 assert!(result.is_err());
781 }
782
783 #[test]
784 fn test_open_or_default() {
785 let temp = TempDir::new().unwrap();
786 let repo = Repository::open_or_default(temp.path());
787 assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
788 }
789
790 #[test]
791 fn test_open_or_default_existing() {
792 let temp = TempDir::new().unwrap();
793 Repository::init(temp.path(), Some("custom".into()), false).unwrap();
794
795 let repo = Repository::open_or_default(temp.path());
796 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
797 }
798
799 #[test]
802 fn test_create_and_list() {
803 let temp = TempDir::new().unwrap();
804 let repo = Repository::init(temp.path(), None, false).unwrap();
805
806 let (adr, _) = repo.new_adr("Use Rust").unwrap();
807 assert_eq!(adr.number, 2);
808
809 let adrs = repo.list().unwrap();
810 assert_eq!(adrs.len(), 2);
811 }
812
813 #[test]
814 fn test_create_multiple() {
815 let temp = TempDir::new().unwrap();
816 let repo = Repository::init(temp.path(), None, false).unwrap();
817
818 repo.new_adr("Second").unwrap();
819 repo.new_adr("Third").unwrap();
820 repo.new_adr("Fourth").unwrap();
821
822 let adrs = repo.list().unwrap();
823 assert_eq!(adrs.len(), 4);
824 assert_eq!(adrs[0].number, 1);
825 assert_eq!(adrs[1].number, 2);
826 assert_eq!(adrs[2].number, 3);
827 assert_eq!(adrs[3].number, 4);
828 }
829
830 #[test]
831 fn test_list_sorted_by_number() {
832 let temp = TempDir::new().unwrap();
833 let repo = Repository::init(temp.path(), None, false).unwrap();
834
835 repo.new_adr("B").unwrap();
836 repo.new_adr("A").unwrap();
837 repo.new_adr("C").unwrap();
838
839 let adrs = repo.list().unwrap();
840 assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
841 }
842
843 #[test]
844 fn test_next_number() {
845 let temp = TempDir::new().unwrap();
846 let repo = Repository::init(temp.path(), None, false).unwrap();
847
848 assert_eq!(repo.next_number().unwrap(), 2);
849
850 repo.new_adr("Second").unwrap();
851 assert_eq!(repo.next_number().unwrap(), 3);
852 }
853
854 #[test]
855 fn test_create_file_exists() {
856 let temp = TempDir::new().unwrap();
857 let repo = Repository::init(temp.path(), None, false).unwrap();
858
859 let (_, path) = repo.new_adr("Test ADR").unwrap();
860 assert!(path.exists());
861 assert!(path.to_string_lossy().contains("0002-test-adr.md"));
862 }
863
864 #[test]
865 fn test_new_adr_uses_custom_default_status_from_config() {
866 let temp = TempDir::new().unwrap();
867 Repository::init(temp.path(), None, false).unwrap();
868
869 std::fs::write(
870 temp.path().join("adrs.toml"),
871 r#"
872adr_dir = "doc/adr"
873mode = "compatible"
874default_status = "draft"
875"#,
876 )
877 .unwrap();
878
879 let repo = Repository::open(temp.path()).unwrap();
880 let (adr, _) = repo.new_adr("Custom status ADR").unwrap();
881
882 assert_eq!(adr.status, AdrStatus::Custom("draft".into()));
883 }
884
885 #[test]
888 fn test_get_by_number() {
889 let temp = TempDir::new().unwrap();
890 let repo = Repository::init(temp.path(), None, false).unwrap();
891 repo.new_adr("Second").unwrap();
892
893 let adr = repo.get(2).unwrap();
894 assert_eq!(adr.title, "Second");
895 }
896
897 #[test]
898 fn test_get_not_found() {
899 let temp = TempDir::new().unwrap();
900 let repo = Repository::init(temp.path(), None, false).unwrap();
901
902 let result = repo.get(99);
903 assert!(result.is_err());
904 }
905
906 #[test]
907 fn test_find_by_number() {
908 let temp = TempDir::new().unwrap();
909 let repo = Repository::init(temp.path(), None, false).unwrap();
910
911 let adr = repo.find("1").unwrap();
912 assert_eq!(adr.number, 1);
913 }
914
915 #[test]
916 fn test_find_by_title() {
917 let temp = TempDir::new().unwrap();
918 let repo = Repository::init(temp.path(), None, false).unwrap();
919
920 let adr = repo.find("architecture").unwrap();
921 assert_eq!(adr.number, 1);
922 }
923
924 #[test]
925 fn test_find_fuzzy_match() {
926 let temp = TempDir::new().unwrap();
927 let repo = Repository::init(temp.path(), None, false).unwrap();
928 repo.new_adr("Use PostgreSQL for database").unwrap();
929 repo.new_adr("Use Redis for caching").unwrap();
930
931 let adr = repo.find("postgres").unwrap();
932 assert!(adr.title.contains("PostgreSQL"));
933 }
934
935 #[test]
936 fn test_find_not_found() {
937 let temp = TempDir::new().unwrap();
938 let repo = Repository::init(temp.path(), None, false).unwrap();
939
940 let result = repo.find("nonexistent");
941 assert!(result.is_err());
942 }
943
944 #[test]
947 fn test_supersede() {
948 let temp = TempDir::new().unwrap();
949 let repo = Repository::init(temp.path(), None, false).unwrap();
950
951 let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
952 assert_eq!(new_adr.number, 2);
953 assert_eq!(new_adr.links.len(), 1);
954 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
955
956 let old_adr = repo.get(1).unwrap();
957 assert_eq!(old_adr.status, AdrStatus::Superseded);
958 }
959
960 #[test]
961 fn test_supersede_creates_bidirectional_links() {
962 let temp = TempDir::new().unwrap();
963 let repo = Repository::init(temp.path(), None, false).unwrap();
964
965 repo.supersede("New approach", 1).unwrap();
966
967 let old_adr = repo.get(1).unwrap();
968 assert_eq!(old_adr.links.len(), 1);
969 assert_eq!(old_adr.links[0].target, 2);
970 assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
971
972 let new_adr = repo.get(2).unwrap();
973 assert_eq!(new_adr.links.len(), 1);
974 assert_eq!(new_adr.links[0].target, 1);
975 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
976 }
977
978 #[test]
979 fn test_supersede_not_found() {
980 let temp = TempDir::new().unwrap();
981 let repo = Repository::init(temp.path(), None, false).unwrap();
982
983 let result = repo.supersede("New", 99);
984 assert!(result.is_err());
985 }
986
987 #[test]
990 fn test_supersede_generates_functional_links() {
991 let temp = TempDir::new().unwrap();
992 let repo = Repository::init(temp.path(), None, false).unwrap();
993
994 repo.new_adr("Use MySQL for persistence").unwrap();
996 repo.supersede("Use PostgreSQL instead", 2).unwrap();
997
998 let new_content =
1000 fs::read_to_string(repo.adr_path().join("0003-use-postgresql-instead.md")).unwrap();
1001 assert!(
1002 new_content.contains(
1003 "Supersedes [2. Use MySQL for persistence](0002-use-mysql-for-persistence.md)"
1004 ),
1005 "New ADR should have functional Supersedes link. Got:\n{new_content}"
1006 );
1007
1008 let old_content =
1010 fs::read_to_string(repo.adr_path().join("0002-use-mysql-for-persistence.md")).unwrap();
1011 assert!(
1012 old_content.contains(
1013 "Superseded by [3. Use PostgreSQL instead](0003-use-postgresql-instead.md)"
1014 ),
1015 "Old ADR should have functional Superseded by link. Got:\n{old_content}"
1016 );
1017 }
1018
1019 #[test]
1020 fn test_link_generates_functional_links() {
1021 let temp = TempDir::new().unwrap();
1022 let repo = Repository::init(temp.path(), None, false).unwrap();
1023
1024 repo.new_adr("Use REST API").unwrap();
1025 repo.new_adr("Use JSON for API responses").unwrap();
1026
1027 repo.link(3, 2, LinkKind::Amends, LinkKind::AmendedBy)
1028 .unwrap();
1029
1030 let source_content =
1032 fs::read_to_string(repo.adr_path().join("0003-use-json-for-api-responses.md")).unwrap();
1033 assert!(
1034 source_content.contains("Amends [2. Use REST API](0002-use-rest-api.md)"),
1035 "Source ADR should have functional Amends link. Got:\n{source_content}"
1036 );
1037
1038 let target_content =
1040 fs::read_to_string(repo.adr_path().join("0002-use-rest-api.md")).unwrap();
1041 assert!(
1042 target_content.contains(
1043 "Amended by [3. Use JSON for API responses](0003-use-json-for-api-responses.md)"
1044 ),
1045 "Target ADR should have functional Amended by link. Got:\n{target_content}"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_set_status_superseded_generates_functional_link() {
1051 let temp = TempDir::new().unwrap();
1052 let repo = Repository::init(temp.path(), None, false).unwrap();
1053
1054 repo.new_adr("First Decision").unwrap();
1055 repo.new_adr("Second Decision").unwrap();
1056
1057 repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
1058
1059 let content = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
1060 assert!(
1061 content.contains("Superseded by [3. Second Decision](0003-second-decision.md)"),
1062 "ADR should have functional Superseded by link. Got:\n{content}"
1063 );
1064 }
1065
1066 #[test]
1067 fn test_supersede_chain_generates_functional_links() {
1068 let temp = TempDir::new().unwrap();
1069 let repo = Repository::init(temp.path(), None, false).unwrap();
1070
1071 repo.new_adr("Use SQLite").unwrap();
1074 repo.supersede("Use PostgreSQL", 2).unwrap();
1076 repo.supersede("Use CockroachDB", 3).unwrap();
1078
1079 let adr3_content =
1081 fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1082 assert!(
1083 adr3_content.contains("Supersedes [2. Use SQLite](0002-use-sqlite.md)"),
1084 "ADR 3 should supersede ADR 2. Got:\n{adr3_content}"
1085 );
1086 assert!(
1087 adr3_content.contains("Superseded by [4. Use CockroachDB](0004-use-cockroachdb.md)"),
1088 "ADR 3 should be superseded by ADR 4. Got:\n{adr3_content}"
1089 );
1090 }
1091
1092 #[test]
1093 fn test_ng_mode_supersede_generates_functional_links() {
1094 let temp = TempDir::new().unwrap();
1095 let repo = Repository::init(temp.path(), None, true).unwrap();
1096
1097 repo.new_adr("Use MySQL").unwrap();
1098 repo.supersede("Use PostgreSQL", 2).unwrap();
1099
1100 let new_content =
1102 fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1103
1104 assert!(
1106 new_content.contains("Supersedes [2. Use MySQL](0002-use-mysql.md)"),
1107 "NG mode should have functional link in body. Got:\n{new_content}"
1108 );
1109 assert!(new_content.contains("links:"));
1111 assert!(new_content.contains("target: 2"));
1112 }
1113
1114 #[test]
1117 fn test_set_status_accepted() {
1118 let temp = TempDir::new().unwrap();
1119 let repo = Repository::init(temp.path(), None, false).unwrap();
1120 repo.new_adr("Test Decision").unwrap();
1121
1122 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1123
1124 let adr = repo.get(2).unwrap();
1125 assert_eq!(adr.status, AdrStatus::Accepted);
1126 }
1127
1128 #[test]
1129 fn test_set_status_deprecated() {
1130 let temp = TempDir::new().unwrap();
1131 let repo = Repository::init(temp.path(), None, false).unwrap();
1132 repo.new_adr("Old Decision").unwrap();
1133
1134 repo.set_status(2, AdrStatus::Deprecated, None).unwrap();
1135
1136 let adr = repo.get(2).unwrap();
1137 assert_eq!(adr.status, AdrStatus::Deprecated);
1138 }
1139
1140 #[test]
1141 fn test_set_status_superseded_with_link() {
1142 let temp = TempDir::new().unwrap();
1143 let repo = Repository::init(temp.path(), None, false).unwrap();
1144 repo.new_adr("First Decision").unwrap();
1145 repo.new_adr("Second Decision").unwrap();
1146
1147 repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
1148
1149 let adr = repo.get(2).unwrap();
1150 assert_eq!(adr.status, AdrStatus::Superseded);
1151 assert_eq!(adr.links.len(), 1);
1152 assert_eq!(adr.links[0].target, 3);
1153 assert_eq!(adr.links[0].kind, LinkKind::SupersededBy);
1154 }
1155
1156 #[test]
1157 fn test_set_status_superseded_without_link() {
1158 let temp = TempDir::new().unwrap();
1159 let repo = Repository::init(temp.path(), None, false).unwrap();
1160 repo.new_adr("Decision").unwrap();
1161
1162 repo.set_status(2, AdrStatus::Superseded, None).unwrap();
1163
1164 let adr = repo.get(2).unwrap();
1165 assert_eq!(adr.status, AdrStatus::Superseded);
1166 assert_eq!(adr.links.len(), 0);
1167 }
1168
1169 #[test]
1170 fn test_set_status_custom() {
1171 let temp = TempDir::new().unwrap();
1172 let repo = Repository::init(temp.path(), None, false).unwrap();
1173 repo.new_adr("Test Decision").unwrap();
1174
1175 repo.set_status(2, AdrStatus::Custom("Draft".into()), None)
1176 .unwrap();
1177
1178 let adr = repo.get(2).unwrap();
1179 assert_eq!(adr.status, AdrStatus::Custom("Draft".into()));
1180 }
1181
1182 #[test]
1183 fn test_set_status_adr_not_found() {
1184 let temp = TempDir::new().unwrap();
1185 let repo = Repository::init(temp.path(), None, false).unwrap();
1186
1187 let result = repo.set_status(99, AdrStatus::Accepted, None);
1188 assert!(result.is_err());
1189 }
1190
1191 #[test]
1192 fn test_set_status_superseded_by_not_found() {
1193 let temp = TempDir::new().unwrap();
1194 let repo = Repository::init(temp.path(), None, false).unwrap();
1195 repo.new_adr("Decision").unwrap();
1196
1197 let result = repo.set_status(2, AdrStatus::Superseded, Some(99));
1198 assert!(result.is_err());
1199 }
1200
1201 #[test]
1204 fn test_link_adrs() {
1205 let temp = TempDir::new().unwrap();
1206 let repo = Repository::init(temp.path(), None, false).unwrap();
1207 repo.new_adr("Second").unwrap();
1208
1209 repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
1210 .unwrap();
1211
1212 let adr1 = repo.get(1).unwrap();
1213 assert_eq!(adr1.links.len(), 1);
1214 assert_eq!(adr1.links[0].target, 2);
1215 assert_eq!(adr1.links[0].kind, LinkKind::Amends);
1216
1217 let adr2 = repo.get(2).unwrap();
1218 assert_eq!(adr2.links.len(), 1);
1219 assert_eq!(adr2.links[0].target, 1);
1220 assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
1221 }
1222
1223 #[test]
1224 fn test_link_relates_to() {
1225 let temp = TempDir::new().unwrap();
1226 let repo = Repository::init(temp.path(), None, false).unwrap();
1227 repo.new_adr("Second").unwrap();
1228
1229 repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
1230 .unwrap();
1231
1232 let adr1 = repo.get(1).unwrap();
1233 assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
1234
1235 let adr2 = repo.get(2).unwrap();
1236 assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
1237 }
1238
1239 #[test]
1242 fn test_update_adr() {
1243 let temp = TempDir::new().unwrap();
1244 let repo = Repository::init(temp.path(), None, false).unwrap();
1245
1246 let mut adr = repo.get(1).unwrap();
1247 adr.status = AdrStatus::Deprecated;
1248
1249 repo.update(&adr).unwrap();
1250
1251 let updated = repo.get(1).unwrap();
1252 assert_eq!(updated.status, AdrStatus::Deprecated);
1253 }
1254
1255 #[test]
1256 fn test_update_preserves_content() {
1257 let temp = TempDir::new().unwrap();
1258 let repo = Repository::init(temp.path(), None, false).unwrap();
1259
1260 let mut adr = repo.get(1).unwrap();
1261 let original_title = adr.title.clone();
1262 adr.status = AdrStatus::Deprecated;
1263
1264 repo.update(&adr).unwrap();
1265
1266 let updated = repo.get(1).unwrap();
1267 assert_eq!(updated.title, original_title);
1268 }
1269
1270 #[test]
1273 fn test_read_content() {
1274 let temp = TempDir::new().unwrap();
1275 let repo = Repository::init(temp.path(), None, false).unwrap();
1276
1277 let adr = repo.get(1).unwrap();
1278 let content = repo.read_content(&adr).unwrap();
1279
1280 assert!(content.contains("Record architecture decisions"));
1281 assert!(content.contains("## Status"));
1282 }
1283
1284 #[test]
1285 fn test_write_content() {
1286 let temp = TempDir::new().unwrap();
1287 let repo = Repository::init(temp.path(), None, false).unwrap();
1288
1289 let adr = repo.get(1).unwrap();
1290 let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
1291
1292 repo.write_content(&adr, new_content).unwrap();
1293
1294 let content = repo.read_content(&adr).unwrap();
1295 assert!(content.contains("Modified"));
1296 }
1297
1298 #[test]
1301 fn test_with_mode_overrides_compatible_to_ng() {
1302 let temp = TempDir::new().unwrap();
1303 let repo = Repository::init(temp.path(), None, false)
1305 .unwrap()
1306 .with_mode(ConfigMode::NextGen);
1307
1308 let (_, path) = repo.new_adr("Mode Override Test").unwrap();
1309 let content = fs::read_to_string(path).unwrap();
1310
1311 assert!(
1312 content.starts_with("---\n"),
1313 "with_mode(NextGen) on compatible repo should produce YAML frontmatter. Got:\n{content}"
1314 );
1315 assert!(content.contains("status: proposed"));
1316 }
1317
1318 #[test]
1319 fn test_with_mode_ng_to_compatible() {
1320 let temp = TempDir::new().unwrap();
1321 let repo = Repository::init(temp.path(), None, true)
1323 .unwrap()
1324 .with_mode(ConfigMode::Compatible);
1325
1326 let (_, path) = repo.new_adr("Downgrade Mode Test").unwrap();
1327 let content = fs::read_to_string(path).unwrap();
1328
1329 assert!(
1330 !content.starts_with("---\n"),
1331 "with_mode(Compatible) on ng repo should NOT produce YAML frontmatter. Got:\n{content}"
1332 );
1333 }
1334
1335 #[test]
1338 fn test_with_template_format() {
1339 let temp = TempDir::new().unwrap();
1340 let repo = Repository::init(temp.path(), None, false)
1341 .unwrap()
1342 .with_template_format(TemplateFormat::Madr);
1343
1344 let (_, path) = repo.new_adr("MADR Test").unwrap();
1345 let content = fs::read_to_string(path).unwrap();
1346
1347 assert!(content.contains("Context and Problem Statement"));
1348 }
1349
1350 #[test]
1351 fn test_with_custom_template() {
1352 let temp = TempDir::new().unwrap();
1353 let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
1354 let repo = Repository::init(temp.path(), None, false)
1355 .unwrap()
1356 .with_custom_template(custom);
1357
1358 let (_, path) = repo.new_adr("Custom Test").unwrap();
1359 let content = fs::read_to_string(path).unwrap();
1360
1361 assert_eq!(content, "# ADR 2: Custom Test");
1362 }
1363
1364 #[test]
1367 fn test_root() {
1368 let temp = TempDir::new().unwrap();
1369 let repo = Repository::init(temp.path(), None, false).unwrap();
1370
1371 assert_eq!(repo.root(), temp.path());
1372 }
1373
1374 #[test]
1375 fn test_config() {
1376 let temp = TempDir::new().unwrap();
1377 let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
1378
1379 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
1380 assert!(repo.config().is_next_gen());
1381 }
1382
1383 #[test]
1384 fn test_adr_path() {
1385 let temp = TempDir::new().unwrap();
1386 let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
1387
1388 assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
1389 }
1390
1391 #[test]
1394 fn test_ng_mode_creates_frontmatter() {
1395 let temp = TempDir::new().unwrap();
1396 let repo = Repository::init(temp.path(), None, true).unwrap();
1397
1398 let (_, path) = repo.new_adr("NG Test").unwrap();
1399 let content = fs::read_to_string(path).unwrap();
1400
1401 assert!(content.starts_with("---"));
1402 assert!(content.contains("number: 2"));
1403 assert!(content.contains("title: NG Test"));
1404 }
1405
1406 #[test]
1407 fn test_ng_mode_parses_frontmatter() {
1408 let temp = TempDir::new().unwrap();
1409 let repo = Repository::init(temp.path(), None, true).unwrap();
1410
1411 repo.new_adr("NG ADR").unwrap();
1412
1413 let adr = repo.get(2).unwrap();
1414 assert_eq!(adr.title, "NG ADR");
1415 assert_eq!(adr.number, 2);
1416 }
1417
1418 #[test]
1421 fn test_list_empty_after_init_removal() {
1422 let temp = TempDir::new().unwrap();
1423 let repo = Repository::init(temp.path(), None, false).unwrap();
1424
1425 fs::remove_file(
1427 repo.adr_path()
1428 .join("0001-record-architecture-decisions.md"),
1429 )
1430 .unwrap();
1431
1432 let adrs = repo.list().unwrap();
1433 assert!(adrs.is_empty());
1434 }
1435
1436 #[test]
1437 fn test_list_ignores_non_adr_files() {
1438 let temp = TempDir::new().unwrap();
1439 let repo = Repository::init(temp.path(), None, false).unwrap();
1440
1441 fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
1443 fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
1444
1445 let adrs = repo.list().unwrap();
1446 assert_eq!(adrs.len(), 1); }
1448
1449 #[test]
1450 fn test_special_characters_in_title() {
1451 let temp = TempDir::new().unwrap();
1452 let repo = Repository::init(temp.path(), None, false).unwrap();
1453
1454 let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
1455 assert!(path.exists());
1456 assert_eq!(adr.title, "Use C++ & Rust!");
1457 }
1458
1459 #[test]
1462 fn test_set_status_preserves_madr_body() {
1463 let temp = TempDir::new().unwrap();
1464 let repo = Repository::init(temp.path(), None, true).unwrap();
1465
1466 let madr_content = r#"---
1467number: 2
1468title: Use Redis for caching
1469date: 2026-01-15
1470status: proposed
1471---
1472
1473# Use Redis for caching
1474
1475## Context and Problem Statement
1476
1477We need a **fast** caching layer for our [API](https://api.example.com).
1478
1479## Considered Options
1480
1481* Redis
1482* Memcached
1483* In-memory cache
1484
1485## Decision Outcome
1486
1487Chosen option: "Redis", because it supports data structures beyond simple key-value.
1488
1489### Consequences
1490
1491* Good, because it provides pub/sub
1492* Bad, because it adds operational complexity
1493
1494## Pros and Cons of the Options
1495
1496### Redis
1497
1498* Good, because it supports complex data types
1499* Bad, because it requires a separate server
1500
1501### Memcached
1502
1503* Good, because it's simpler
1504* Bad, because it only supports strings
1505"#;
1506 let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
1507 fs::write(&adr_path, madr_content).unwrap();
1508
1509 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1511
1512 let result = fs::read_to_string(&adr_path).unwrap();
1513
1514 assert!(result.contains("status: accepted"));
1516 assert!(!result.contains("status: proposed"));
1517
1518 let body_start = result.find("\n# Use Redis").unwrap();
1520 let original_body_start = madr_content.find("\n# Use Redis").unwrap();
1521 assert_eq!(
1522 &result[body_start..],
1523 &madr_content[original_body_start..],
1524 "Body content was modified"
1525 );
1526 }
1527
1528 #[test]
1529 fn test_set_status_preserves_yaml_comments() {
1530 let temp = TempDir::new().unwrap();
1531 let repo = Repository::init(temp.path(), None, true).unwrap();
1532
1533 let content_with_comments = r#"---
1534# SPDX-License-Identifier: MIT
1535# SPDX-FileCopyrightText: 2026 Example Corp
1536number: 2
1537title: Use MADR format
1538date: 2026-01-15
1539status: proposed
1540---
1541
1542## Context and Problem Statement
1543
1544We need a standard ADR format.
1545
1546## Decision Outcome
1547
1548Use MADR 4.0.0.
1549"#;
1550 let adr_path = repo.adr_path().join("0002-use-madr-format.md");
1551 fs::write(&adr_path, content_with_comments).unwrap();
1552
1553 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1554
1555 let result = fs::read_to_string(&adr_path).unwrap();
1556
1557 assert!(
1559 result.contains("# SPDX-License-Identifier: MIT"),
1560 "SPDX comment was destroyed"
1561 );
1562 assert!(
1563 result.contains("# SPDX-FileCopyrightText: 2026 Example Corp"),
1564 "Copyright comment was destroyed"
1565 );
1566 assert!(result.contains("status: accepted"));
1567 }
1568
1569 #[test]
1570 fn test_set_status_preserves_markdown_links() {
1571 let temp = TempDir::new().unwrap();
1572 let repo = Repository::init(temp.path(), None, true).unwrap();
1573
1574 let content = r#"---
1575number: 2
1576title: Use PostgreSQL
1577date: 2026-01-15
1578status: proposed
1579---
1580
1581## Context
1582
1583See the [PostgreSQL docs](https://www.postgresql.org/docs/) for details.
1584
1585Also see [RFC 7159](https://tools.ietf.org/html/rfc7159) and `inline code`.
1586
1587## Decision
1588
1589We will use **PostgreSQL** version `16.x`.
1590
1591## Consequences
1592
1593- [Monitoring guide](https://example.com/monitoring)
1594- Performance benchmarks in [this report](./benchmarks.md)
1595"#;
1596 let adr_path = repo.adr_path().join("0002-use-postgresql.md");
1597 fs::write(&adr_path, content).unwrap();
1598
1599 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1600
1601 let result = fs::read_to_string(&adr_path).unwrap();
1602
1603 assert!(result.contains("[PostgreSQL docs](https://www.postgresql.org/docs/)"));
1604 assert!(result.contains("[RFC 7159](https://tools.ietf.org/html/rfc7159)"));
1605 assert!(result.contains("`inline code`"));
1606 assert!(result.contains("**PostgreSQL**"));
1607 assert!(result.contains("[Monitoring guide](https://example.com/monitoring)"));
1608 assert!(result.contains("[this report](./benchmarks.md)"));
1609 }
1610
1611 #[test]
1612 fn test_link_preserves_body_content() {
1613 let temp = TempDir::new().unwrap();
1614 let repo = Repository::init(temp.path(), None, true).unwrap();
1615
1616 let content_1 = r#"---
1617number: 2
1618title: First decision
1619date: 2026-01-15
1620status: accepted
1621---
1622
1623## Context
1624
1625Custom context with **bold** and [links](https://example.com).
1626
1627## Decision
1628
1629A detailed decision paragraph.
1630
1631## Consequences
1632
1633- Important consequence 1
1634- Important consequence 2
1635"#;
1636 let content_2 = r#"---
1637number: 3
1638title: Second decision
1639date: 2026-01-16
1640status: accepted
1641---
1642
1643## Context
1644
1645Different context entirely.
1646
1647## Decision
1648
1649Another decision.
1650
1651## Consequences
1652
1653None significant.
1654"#;
1655 fs::write(repo.adr_path().join("0002-first-decision.md"), content_1).unwrap();
1656 fs::write(repo.adr_path().join("0003-second-decision.md"), content_2).unwrap();
1657
1658 repo.link(2, 3, LinkKind::Amends, LinkKind::AmendedBy)
1659 .unwrap();
1660
1661 let result_1 = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
1662 let result_2 = fs::read_to_string(repo.adr_path().join("0003-second-decision.md")).unwrap();
1663
1664 assert!(result_1.contains("Custom context with **bold** and [links](https://example.com)"));
1666 assert!(result_1.contains("A detailed decision paragraph."));
1667 assert!(result_2.contains("Different context entirely."));
1668 assert!(result_2.contains("None significant."));
1669
1670 assert!(result_1.contains("links:"));
1672 assert!(result_1.contains("target: 3"));
1673 assert!(result_2.contains("links:"));
1674 assert!(result_2.contains("target: 2"));
1675 }
1676
1677 #[test]
1678 fn test_supersede_preserves_old_adr_body() {
1679 let temp = TempDir::new().unwrap();
1680 let repo = Repository::init(temp.path(), None, true).unwrap();
1681
1682 let rich_content = r#"---
1683number: 2
1684title: Original approach
1685date: 2026-01-15
1686status: accepted
1687---
1688
1689## Context and Problem Statement
1690
1691This has **rich** markdown with [links](https://example.com).
1692
1693```rust
1694fn important_code() -> bool {
1695 true
1696}
1697```
1698
1699## Decision Outcome
1700
1701We chose the original approach.
1702
1703| Criteria | Score |
1704|----------|-------|
1705| Speed | 9/10 |
1706| Safety | 8/10 |
1707"#;
1708 fs::write(
1709 repo.adr_path().join("0002-original-approach.md"),
1710 rich_content,
1711 )
1712 .unwrap();
1713
1714 repo.supersede("Better approach", 2).unwrap();
1715
1716 let old_content =
1717 fs::read_to_string(repo.adr_path().join("0002-original-approach.md")).unwrap();
1718
1719 assert!(old_content.contains("```rust"));
1721 assert!(old_content.contains("fn important_code()"));
1722 assert!(old_content.contains("| Criteria | Score |"));
1723 assert!(old_content.contains("[links](https://example.com)"));
1724
1725 assert!(old_content.contains("status: superseded"));
1727 assert!(old_content.contains("target: 3"));
1728 }
1729
1730 #[test]
1731 fn test_set_status_legacy_preserves_sections() {
1732 let temp = TempDir::new().unwrap();
1733 let repo = Repository::init(temp.path(), None, false).unwrap();
1734
1735 let legacy_content = r#"# 2. Use Rust for backend
1736
1737Date: 2026-01-15
1738
1739## Status
1740
1741Proposed
1742
1743## Context
1744
1745We need a fast, safe language for our backend services.
1746
1747See the [Rust book](https://doc.rust-lang.org/book/) for details.
1748
1749## Decision
1750
1751We will use **Rust** with the `tokio` runtime.
1752
1753```toml
1754[dependencies]
1755tokio = { version = "1", features = ["full"] }
1756```
1757
1758## Consequences
1759
1760- Type safety prevents many bugs at compile time
1761- Learning curve for team members
1762"#;
1763 let adr_path = repo.adr_path().join("0002-use-rust-for-backend.md");
1764 fs::write(&adr_path, legacy_content).unwrap();
1765
1766 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1767
1768 let result = fs::read_to_string(&adr_path).unwrap();
1769
1770 assert!(result.contains("Accepted"));
1772
1773 assert!(result.contains("[Rust book](https://doc.rust-lang.org/book/)"));
1775 assert!(result.contains("**Rust**"));
1776 assert!(result.contains("`tokio`"));
1777 assert!(result.contains("```toml"));
1778 assert!(result.contains("tokio = { version = \"1\", features = [\"full\"] }"));
1779 assert!(result.contains("Type safety prevents many bugs"));
1780 }
1781
1782 #[test]
1783 fn test_set_status_frontmatter_with_existing_links() {
1784 let temp = TempDir::new().unwrap();
1785 let repo = Repository::init(temp.path(), None, true).unwrap();
1786
1787 let content = r#"---
1788number: 2
1789title: Updated approach
1790date: 2026-01-15
1791status: proposed
1792links:
1793 - target: 1
1794 kind: amends
1795---
1796
1797## Context
1798
1799Context.
1800
1801## Decision
1802
1803Decision.
1804"#;
1805 let adr_path = repo.adr_path().join("0002-updated-approach.md");
1806 fs::write(&adr_path, content).unwrap();
1807
1808 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1810
1811 let result = fs::read_to_string(&adr_path).unwrap();
1812 assert!(result.contains("status: accepted"));
1813 assert!(result.contains("links:"));
1814 assert!(result.contains("target: 1"));
1815 assert!(result.contains("kind: amends"));
1816 assert!(
1818 !result.contains("\n\n---"),
1819 "Should not have extra blank line before closing ---: {:?}",
1820 result
1821 );
1822 }
1823
1824 #[test]
1825 fn test_set_status_no_extra_newline_before_separator() {
1826 let temp = TempDir::new().unwrap();
1827 let repo = Repository::init(temp.path(), None, true).unwrap();
1828
1829 let content = "---\nnumber: 2\ntitle: Test\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
1830 let adr_path = repo.adr_path().join("0002-test.md");
1831 fs::write(&adr_path, content).unwrap();
1832
1833 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1834
1835 let result = fs::read_to_string(&adr_path).unwrap();
1836 assert!(result.contains("status: accepted"));
1837 assert!(
1839 result.contains("\n---\n"),
1840 "Should have clean closing separator: {:?}",
1841 result
1842 );
1843 assert!(
1844 !result.contains("\n\n---"),
1845 "Should not have extra blank line before closing ---: {:?}",
1846 result
1847 );
1848 }
1849
1850 #[test]
1851 fn test_update_metadata_adds_tags_to_frontmatter() {
1852 let temp = TempDir::new().unwrap();
1853 let repo = Repository::init(temp.path(), None, true).unwrap();
1854
1855 let content = r#"---
1856number: 2
1857title: Tagged ADR
1858date: 2026-01-15
1859status: proposed
1860---
1861
1862## Context
1863
1864Context.
1865"#;
1866 let adr_path = repo.adr_path().join("0002-tagged-adr.md");
1867 fs::write(&adr_path, content).unwrap();
1868
1869 let mut adr = repo.get(2).unwrap();
1870 adr.set_tags(vec!["security".into(), "api".into()]);
1871 repo.update_metadata(&adr).unwrap();
1872
1873 let result = fs::read_to_string(&adr_path).unwrap();
1874 assert!(result.contains("tags:"));
1875 assert!(result.contains(" - security"));
1876 assert!(result.contains(" - api"));
1877 assert!(result.contains("## Context\n\nContext."));
1879 }
1880
1881 #[test]
1884 fn test_list_with_errors_all_valid() {
1885 let temp = TempDir::new().unwrap();
1886 let repo = Repository::init(temp.path(), None, true).unwrap();
1887 repo.new_adr("Valid ADR").unwrap();
1888
1889 let (adrs, errors) = repo.list_with_errors().unwrap();
1890 assert_eq!(adrs.len(), 2); assert!(errors.is_empty());
1892 }
1893
1894 #[test]
1895 fn test_list_with_errors_captures_invalid_frontmatter() {
1896 let temp = TempDir::new().unwrap();
1897 let repo = Repository::init(temp.path(), None, true).unwrap();
1898
1899 let bad_content =
1901 "---\nnumber: 2\nstatus: accepted\ndate: not-a-date\n---\n\n# 2. Bad ADR\n";
1902 fs::write(repo.adr_path().join("0002-bad-adr.md"), bad_content).unwrap();
1903
1904 let (adrs, errors) = repo.list_with_errors().unwrap();
1905 assert_eq!(adrs.len(), 1); assert_eq!(errors.len(), 1);
1907 assert!(errors[0].0.to_string_lossy().contains("0002-bad-adr.md"));
1908 }
1909
1910 #[test]
1911 fn test_list_with_errors_mixed_valid_and_invalid() {
1912 let temp = TempDir::new().unwrap();
1913 let repo = Repository::init(temp.path(), None, true).unwrap();
1914
1915 repo.new_adr("Good ADR").unwrap();
1917
1918 let bad_content = "---\n: :\n---\n\n# 3. Broken\n";
1920 fs::write(repo.adr_path().join("0003-broken.md"), bad_content).unwrap();
1921
1922 let (adrs, errors) = repo.list_with_errors().unwrap();
1923 assert_eq!(adrs.len(), 2); assert_eq!(errors.len(), 1); }
1926
1927 #[test]
1928 fn test_list_with_errors_string_decision_makers_is_valid() {
1929 let temp = TempDir::new().unwrap();
1930 let repo = Repository::init(temp.path(), None, true).unwrap();
1931
1932 let content = r#"---
1934number: 2
1935status: accepted
1936date: 2026-03-18
1937decision-makers: mschoettle
1938---
1939
1940# 2. Use Markdown Architectural Decision Records
1941"#;
1942 fs::write(repo.adr_path().join("0002-use-markdown-adrs.md"), content).unwrap();
1943
1944 let (adrs, errors) = repo.list_with_errors().unwrap();
1945 assert!(errors.is_empty(), "string decision-makers should parse");
1946 assert_eq!(adrs.len(), 2);
1947
1948 let adr = adrs.iter().find(|a| a.number == 2).unwrap();
1949 assert_eq!(adr.decision_makers, vec!["mschoettle"]);
1950 }
1951}