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_custom_template(mut self, template: Template) -> Self {
162 self.template_engine = self.template_engine.with_custom_template(template);
163 self
164 }
165
166 pub fn list(&self) -> Result<Vec<Adr>> {
168 let adr_path = self.adr_path();
169 if !adr_path.exists() {
170 return Err(Error::AdrDirNotFound);
171 }
172
173 let mut adrs: Vec<Adr> = WalkDir::new(&adr_path)
174 .max_depth(1)
175 .into_iter()
176 .filter_map(|e| e.ok())
177 .filter(|e| {
178 e.path().extension().is_some_and(|ext| ext == "md")
179 && e.path()
180 .file_name()
181 .and_then(|n| n.to_str())
182 .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
183 })
184 .filter_map(|e| self.parser.parse_file(e.path()).ok())
185 .collect();
186
187 adrs.sort_by_key(|a| a.number);
188 Ok(adrs)
189 }
190
191 pub fn next_number(&self) -> Result<u32> {
193 let adrs = self.list()?;
194 Ok(adrs.last().map(|a| a.number + 1).unwrap_or(1))
195 }
196
197 pub fn get(&self, number: u32) -> Result<Adr> {
199 let adrs = self.list()?;
200 adrs.into_iter()
201 .find(|a| a.number == number)
202 .ok_or_else(|| Error::AdrNotFound(number.to_string()))
203 }
204
205 pub fn find(&self, query: &str) -> Result<Adr> {
207 if let Ok(number) = query.parse::<u32>() {
209 return self.get(number);
210 }
211
212 let adrs = self.list()?;
214 let matcher = SkimMatcherV2::default();
215
216 let mut matches: Vec<_> = adrs
217 .into_iter()
218 .filter_map(|adr| {
219 let score = matcher.fuzzy_match(&adr.title, query)?;
220 Some((adr, score))
221 })
222 .collect();
223
224 matches.sort_by(|a, b| b.1.cmp(&a.1));
225
226 match matches.len() {
227 0 => Err(Error::AdrNotFound(query.to_string())),
228 1 => Ok(matches.remove(0).0),
229 _ => {
230 if matches[0].1 > matches[1].1 * 2 {
232 Ok(matches.remove(0).0)
233 } else {
234 Err(Error::AmbiguousAdr {
235 query: query.to_string(),
236 matches: matches
237 .iter()
238 .take(5)
239 .map(|(a, _)| a.title.clone())
240 .collect(),
241 })
242 }
243 }
244 }
245 }
246
247 fn resolve_link_titles(&self, adr: &Adr) -> HashMap<u32, (String, String)> {
249 let mut map = HashMap::new();
250 for link in &adr.links {
251 if map.contains_key(&link.target) {
252 continue;
253 }
254 if let Ok(target_adr) = self.get(link.target) {
255 map.insert(
256 link.target,
257 (target_adr.title.clone(), target_adr.filename()),
258 );
259 }
260 }
261 map
262 }
263
264 pub fn create(&self, adr: &Adr) -> Result<PathBuf> {
266 let path = self.adr_path().join(adr.filename());
267
268 let link_titles = self.resolve_link_titles(adr);
269 let content = self
270 .template_engine
271 .render(adr, &self.config, &link_titles)?;
272 fs::write(&path, content)?;
273
274 Ok(path)
275 }
276
277 pub fn new_adr(&self, title: impl Into<String>) -> Result<(Adr, PathBuf)> {
279 let number = self.next_number()?;
280 let adr = Adr::new(number, title);
281 let path = self.create(&adr)?;
282 Ok((adr, path))
283 }
284
285 pub fn supersede(&self, title: impl Into<String>, superseded: u32) -> Result<(Adr, PathBuf)> {
287 let number = self.next_number()?;
288 let mut adr = Adr::new(number, title);
289 adr.add_link(AdrLink::new(superseded, LinkKind::Supersedes));
290
291 let path = self.create(&adr)?;
294
295 let mut old_adr = self.get(superseded)?;
298 old_adr.status = AdrStatus::Superseded;
299 old_adr.add_link(AdrLink::new(number, LinkKind::SupersededBy));
300 self.update_metadata(&old_adr)?;
301
302 Ok((adr, path))
303 }
304
305 pub fn set_status(
310 &self,
311 number: u32,
312 status: AdrStatus,
313 superseded_by: Option<u32>,
314 ) -> Result<PathBuf> {
315 let mut adr = self.get(number)?;
316 adr.status = status.clone();
317
318 if let (AdrStatus::Superseded, Some(by)) = (&status, superseded_by) {
320 let _ = self.get(by)?;
322
323 if !adr
325 .links
326 .iter()
327 .any(|l| matches!(l.kind, LinkKind::SupersededBy) && l.target == by)
328 {
329 adr.add_link(AdrLink::new(by, LinkKind::SupersededBy));
330 }
331 }
332
333 self.update_metadata(&adr)
334 }
335
336 pub fn link(
338 &self,
339 source: u32,
340 target: u32,
341 source_kind: LinkKind,
342 target_kind: LinkKind,
343 ) -> Result<()> {
344 let mut source_adr = self.get(source)?;
345 let mut target_adr = self.get(target)?;
346
347 source_adr.add_link(AdrLink::new(target, source_kind));
348 target_adr.add_link(AdrLink::new(source, target_kind));
349
350 self.update_metadata(&source_adr)?;
351 self.update_metadata(&target_adr)?;
352
353 Ok(())
354 }
355
356 pub fn update(&self, adr: &Adr) -> Result<PathBuf> {
358 let path = adr
359 .path
360 .clone()
361 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
362
363 let link_titles = self.resolve_link_titles(adr);
364 let content = self
365 .template_engine
366 .render(adr, &self.config, &link_titles)?;
367 fs::write(&path, content)?;
368
369 Ok(path)
370 }
371
372 pub fn read_content(&self, adr: &Adr) -> Result<String> {
374 let path = adr
375 .path
376 .as_ref()
377 .cloned()
378 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
379
380 Ok(fs::read_to_string(path)?)
381 }
382
383 pub fn write_content(&self, adr: &Adr, content: &str) -> Result<PathBuf> {
385 let path = adr
386 .path
387 .as_ref()
388 .cloned()
389 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
390
391 fs::write(&path, content)?;
392 Ok(path)
393 }
394
395 pub fn update_metadata(&self, adr: &Adr) -> Result<PathBuf> {
398 let path = adr
399 .path
400 .clone()
401 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
402
403 let content = fs::read_to_string(&path)?;
404
405 let updated = if content.starts_with("---\n") {
406 self.update_frontmatter_metadata(adr, &content)?
407 } else {
408 self.update_legacy_metadata(adr, &content)?
409 };
410
411 fs::write(&path, updated)?;
412 Ok(path)
413 }
414
415 fn update_frontmatter_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
421 let Some(rest) = content.strip_prefix("---\n") else {
423 return Err(Error::InvalidFormat {
424 path: Default::default(),
425 reason: "Missing opening frontmatter delimiter".into(),
426 });
427 };
428
429 let Some(end_idx) = rest.find("\n---\n").or_else(|| {
430 if rest.ends_with("\n---") {
432 Some(rest.len() - 3)
433 } else {
434 None
435 }
436 }) else {
437 return Err(Error::InvalidFormat {
438 path: Default::default(),
439 reason: "Missing closing frontmatter delimiter".into(),
440 });
441 };
442
443 let yaml_block = &rest[..end_idx + 1]; let after_yaml = &rest[end_idx..]; let new_status = format!("status: {}", adr.status.to_string().to_lowercase());
448 let yaml_block = FM_STATUS_RE.replace(yaml_block, new_status.as_str());
449
450 let links_yaml = Self::format_links_yaml(&adr.links);
452 let yaml_block = if FM_LINKS_RE.is_match(&yaml_block) {
453 FM_LINKS_RE
454 .replace(&yaml_block, links_yaml.as_str())
455 .into_owned()
456 } else if !links_yaml.is_empty() {
457 let mut s = yaml_block.into_owned();
459 if !s.ends_with('\n') {
460 s.push('\n');
461 }
462 s.push_str(&links_yaml);
463 s
464 } else {
465 yaml_block.into_owned()
466 };
467
468 let tags_yaml = Self::format_tags_yaml(&adr.tags);
470 let yaml_block = if FM_TAGS_RE.is_match(&yaml_block) {
471 FM_TAGS_RE
472 .replace(&yaml_block, tags_yaml.as_str())
473 .into_owned()
474 } else if !tags_yaml.is_empty() {
475 let mut s = yaml_block;
476 if !s.ends_with('\n') {
477 s.push('\n');
478 }
479 s.push_str(&tags_yaml);
480 s
481 } else {
482 yaml_block
483 };
484
485 Ok(format!("---\n{}{}", yaml_block, after_yaml))
486 }
487
488 fn update_legacy_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
493 let lines: Vec<&str> = content.lines().collect();
494 let mut result = String::with_capacity(content.len());
495
496 let status_idx = lines.iter().position(|l| {
498 l.trim().eq_ignore_ascii_case("## Status") || l.trim().eq_ignore_ascii_case("## STATUS")
499 });
500
501 let Some(status_idx) = status_idx else {
502 return Ok(content.to_string());
504 };
505
506 let next_heading_idx = lines[status_idx + 1..]
508 .iter()
509 .position(|l| l.starts_with("## "))
510 .map(|i| i + status_idx + 1);
511
512 for line in &lines[..=status_idx] {
514 result.push_str(line);
515 result.push('\n');
516 }
517
518 result.push('\n');
520 result.push_str(&adr.status.to_string());
521 result.push('\n');
522
523 let link_titles = self.resolve_link_titles(adr);
525 for link in &adr.links {
526 result.push('\n');
527 if let Some((title, filename)) = link_titles.get(&link.target) {
528 result.push_str(&format!(
529 "{} [{}. {}]({})",
530 link.kind, link.target, title, filename
531 ));
532 } else {
533 result.push_str(&format!(
534 "{} [{}. ...]({:04}-....md)",
535 link.kind, link.target, link.target
536 ));
537 }
538 result.push('\n');
539 }
540
541 if let Some(next_idx) = next_heading_idx {
543 result.push('\n');
544 for (i, line) in lines[next_idx..].iter().enumerate() {
545 result.push_str(line);
546 if next_idx + i < lines.len() - 1 || content.ends_with('\n') {
548 result.push('\n');
549 }
550 }
551 } else if content.ends_with('\n') {
552 }
554
555 Ok(result)
556 }
557
558 fn format_links_yaml(links: &[AdrLink]) -> String {
560 if links.is_empty() {
561 return String::new();
562 }
563 let mut s = String::from("links:\n");
564 for link in links {
565 let kind_str = match &link.kind {
566 LinkKind::Supersedes => "supersedes",
567 LinkKind::SupersededBy => "supersededby",
568 LinkKind::Amends => "amends",
569 LinkKind::AmendedBy => "amendedby",
570 LinkKind::RelatesTo => "relatesto",
571 LinkKind::Custom(c) => c.as_str(),
572 };
573 s.push_str(&format!(
574 " - target: {}\n kind: {}\n",
575 link.target, kind_str
576 ));
577 }
578 s
579 }
580
581 fn format_tags_yaml(tags: &[String]) -> String {
583 if tags.is_empty() {
584 return String::new();
585 }
586 let mut s = String::from("tags:\n");
587 for tag in tags {
588 s.push_str(&format!(" - {}\n", tag));
589 }
590 s
591 }
592}
593
594fn count_existing_adrs(path: &Path) -> usize {
596 if !path.is_dir() {
597 return 0;
598 }
599
600 fs::read_dir(path)
601 .map(|entries| {
602 entries
603 .filter_map(|e| e.ok())
604 .filter(|e| {
605 let path = e.path();
606 path.is_file()
607 && path.extension().is_some_and(|ext| ext == "md")
608 && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
609 n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
611 })
612 })
613 .count()
614 })
615 .unwrap_or(0)
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use tempfile::TempDir;
622
623 #[test]
626 fn test_init_repository() {
627 let temp = TempDir::new().unwrap();
628 let repo = Repository::init(temp.path(), None, false).unwrap();
629
630 assert!(repo.adr_path().exists());
631 assert!(temp.path().join(".adr-dir").exists());
632
633 let adrs = repo.list().unwrap();
634 assert_eq!(adrs.len(), 1);
635 assert_eq!(adrs[0].number, 1);
636 assert_eq!(adrs[0].title, "Record architecture decisions");
637 }
638
639 #[test]
640 fn test_init_repository_ng() {
641 let temp = TempDir::new().unwrap();
642 let repo = Repository::init(temp.path(), None, true).unwrap();
643
644 assert!(temp.path().join("adrs.toml").exists());
645 assert!(repo.config().is_next_gen());
646 }
647
648 #[test]
649 fn test_init_repository_custom_dir() {
650 let temp = TempDir::new().unwrap();
651 let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
652
653 assert!(temp.path().join("decisions").exists());
654 assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
655 }
656
657 #[test]
658 fn test_init_repository_nested_dir() {
659 let temp = TempDir::new().unwrap();
660 let _repo =
661 Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
662
663 assert!(temp.path().join("docs/architecture/adr").exists());
664 }
665
666 #[test]
667 fn test_init_repository_already_exists_skips_initial_adr() {
668 let temp = TempDir::new().unwrap();
669 Repository::init(temp.path(), None, false).unwrap();
670
671 let repo = Repository::init(temp.path(), None, false).unwrap();
673 let adrs = repo.list().unwrap();
674 assert_eq!(adrs.len(), 1); }
676
677 #[test]
678 fn test_init_with_existing_adrs_skips_initial() {
679 let temp = TempDir::new().unwrap();
680 let adr_dir = temp.path().join("doc/adr");
681 fs::create_dir_all(&adr_dir).unwrap();
682
683 fs::write(
685 adr_dir.join("0001-existing-decision.md"),
686 "# 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",
687 )
688 .unwrap();
689 fs::write(
690 adr_dir.join("0002-another-decision.md"),
691 "# 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",
692 )
693 .unwrap();
694
695 let repo = Repository::init(temp.path(), None, false).unwrap();
697 let adrs = repo.list().unwrap();
698 assert_eq!(adrs.len(), 2); assert_eq!(adrs[0].title, "Existing Decision");
700 assert_eq!(adrs[1].title, "Another Decision");
701 }
702
703 #[test]
704 fn test_init_creates_first_adr() {
705 let temp = TempDir::new().unwrap();
706 let repo = Repository::init(temp.path(), None, false).unwrap();
707
708 let adr = repo.get(1).unwrap();
709 assert_eq!(adr.title, "Record architecture decisions");
710 assert_eq!(adr.status, AdrStatus::Accepted);
711 assert!(!adr.context.is_empty());
712 assert!(!adr.decision.is_empty());
713 assert!(!adr.consequences.is_empty());
714 }
715
716 #[test]
719 fn test_open_repository() {
720 let temp = TempDir::new().unwrap();
721 Repository::init(temp.path(), None, false).unwrap();
722
723 let repo = Repository::open(temp.path()).unwrap();
724 assert_eq!(repo.list().unwrap().len(), 1);
725 }
726
727 #[test]
728 fn test_open_repository_not_found() {
729 let temp = TempDir::new().unwrap();
730 let result = Repository::open(temp.path());
731 assert!(result.is_err());
732 }
733
734 #[test]
735 fn test_open_or_default() {
736 let temp = TempDir::new().unwrap();
737 let repo = Repository::open_or_default(temp.path());
738 assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
739 }
740
741 #[test]
742 fn test_open_or_default_existing() {
743 let temp = TempDir::new().unwrap();
744 Repository::init(temp.path(), Some("custom".into()), false).unwrap();
745
746 let repo = Repository::open_or_default(temp.path());
747 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
748 }
749
750 #[test]
753 fn test_create_and_list() {
754 let temp = TempDir::new().unwrap();
755 let repo = Repository::init(temp.path(), None, false).unwrap();
756
757 let (adr, _) = repo.new_adr("Use Rust").unwrap();
758 assert_eq!(adr.number, 2);
759
760 let adrs = repo.list().unwrap();
761 assert_eq!(adrs.len(), 2);
762 }
763
764 #[test]
765 fn test_create_multiple() {
766 let temp = TempDir::new().unwrap();
767 let repo = Repository::init(temp.path(), None, false).unwrap();
768
769 repo.new_adr("Second").unwrap();
770 repo.new_adr("Third").unwrap();
771 repo.new_adr("Fourth").unwrap();
772
773 let adrs = repo.list().unwrap();
774 assert_eq!(adrs.len(), 4);
775 assert_eq!(adrs[0].number, 1);
776 assert_eq!(adrs[1].number, 2);
777 assert_eq!(adrs[2].number, 3);
778 assert_eq!(adrs[3].number, 4);
779 }
780
781 #[test]
782 fn test_list_sorted_by_number() {
783 let temp = TempDir::new().unwrap();
784 let repo = Repository::init(temp.path(), None, false).unwrap();
785
786 repo.new_adr("B").unwrap();
787 repo.new_adr("A").unwrap();
788 repo.new_adr("C").unwrap();
789
790 let adrs = repo.list().unwrap();
791 assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
792 }
793
794 #[test]
795 fn test_next_number() {
796 let temp = TempDir::new().unwrap();
797 let repo = Repository::init(temp.path(), None, false).unwrap();
798
799 assert_eq!(repo.next_number().unwrap(), 2);
800
801 repo.new_adr("Second").unwrap();
802 assert_eq!(repo.next_number().unwrap(), 3);
803 }
804
805 #[test]
806 fn test_create_file_exists() {
807 let temp = TempDir::new().unwrap();
808 let repo = Repository::init(temp.path(), None, false).unwrap();
809
810 let (_, path) = repo.new_adr("Test ADR").unwrap();
811 assert!(path.exists());
812 assert!(path.to_string_lossy().contains("0002-test-adr.md"));
813 }
814
815 #[test]
818 fn test_get_by_number() {
819 let temp = TempDir::new().unwrap();
820 let repo = Repository::init(temp.path(), None, false).unwrap();
821 repo.new_adr("Second").unwrap();
822
823 let adr = repo.get(2).unwrap();
824 assert_eq!(adr.title, "Second");
825 }
826
827 #[test]
828 fn test_get_not_found() {
829 let temp = TempDir::new().unwrap();
830 let repo = Repository::init(temp.path(), None, false).unwrap();
831
832 let result = repo.get(99);
833 assert!(result.is_err());
834 }
835
836 #[test]
837 fn test_find_by_number() {
838 let temp = TempDir::new().unwrap();
839 let repo = Repository::init(temp.path(), None, false).unwrap();
840
841 let adr = repo.find("1").unwrap();
842 assert_eq!(adr.number, 1);
843 }
844
845 #[test]
846 fn test_find_by_title() {
847 let temp = TempDir::new().unwrap();
848 let repo = Repository::init(temp.path(), None, false).unwrap();
849
850 let adr = repo.find("architecture").unwrap();
851 assert_eq!(adr.number, 1);
852 }
853
854 #[test]
855 fn test_find_fuzzy_match() {
856 let temp = TempDir::new().unwrap();
857 let repo = Repository::init(temp.path(), None, false).unwrap();
858 repo.new_adr("Use PostgreSQL for database").unwrap();
859 repo.new_adr("Use Redis for caching").unwrap();
860
861 let adr = repo.find("postgres").unwrap();
862 assert!(adr.title.contains("PostgreSQL"));
863 }
864
865 #[test]
866 fn test_find_not_found() {
867 let temp = TempDir::new().unwrap();
868 let repo = Repository::init(temp.path(), None, false).unwrap();
869
870 let result = repo.find("nonexistent");
871 assert!(result.is_err());
872 }
873
874 #[test]
877 fn test_supersede() {
878 let temp = TempDir::new().unwrap();
879 let repo = Repository::init(temp.path(), None, false).unwrap();
880
881 let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
882 assert_eq!(new_adr.number, 2);
883 assert_eq!(new_adr.links.len(), 1);
884 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
885
886 let old_adr = repo.get(1).unwrap();
887 assert_eq!(old_adr.status, AdrStatus::Superseded);
888 }
889
890 #[test]
891 fn test_supersede_creates_bidirectional_links() {
892 let temp = TempDir::new().unwrap();
893 let repo = Repository::init(temp.path(), None, false).unwrap();
894
895 repo.supersede("New approach", 1).unwrap();
896
897 let old_adr = repo.get(1).unwrap();
898 assert_eq!(old_adr.links.len(), 1);
899 assert_eq!(old_adr.links[0].target, 2);
900 assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
901
902 let new_adr = repo.get(2).unwrap();
903 assert_eq!(new_adr.links.len(), 1);
904 assert_eq!(new_adr.links[0].target, 1);
905 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
906 }
907
908 #[test]
909 fn test_supersede_not_found() {
910 let temp = TempDir::new().unwrap();
911 let repo = Repository::init(temp.path(), None, false).unwrap();
912
913 let result = repo.supersede("New", 99);
914 assert!(result.is_err());
915 }
916
917 #[test]
920 fn test_supersede_generates_functional_links() {
921 let temp = TempDir::new().unwrap();
922 let repo = Repository::init(temp.path(), None, false).unwrap();
923
924 repo.new_adr("Use MySQL for persistence").unwrap();
926 repo.supersede("Use PostgreSQL instead", 2).unwrap();
927
928 let new_content =
930 fs::read_to_string(repo.adr_path().join("0003-use-postgresql-instead.md")).unwrap();
931 assert!(
932 new_content.contains(
933 "Supersedes [2. Use MySQL for persistence](0002-use-mysql-for-persistence.md)"
934 ),
935 "New ADR should have functional Supersedes link. Got:\n{new_content}"
936 );
937
938 let old_content =
940 fs::read_to_string(repo.adr_path().join("0002-use-mysql-for-persistence.md")).unwrap();
941 assert!(
942 old_content.contains(
943 "Superseded by [3. Use PostgreSQL instead](0003-use-postgresql-instead.md)"
944 ),
945 "Old ADR should have functional Superseded by link. Got:\n{old_content}"
946 );
947 }
948
949 #[test]
950 fn test_link_generates_functional_links() {
951 let temp = TempDir::new().unwrap();
952 let repo = Repository::init(temp.path(), None, false).unwrap();
953
954 repo.new_adr("Use REST API").unwrap();
955 repo.new_adr("Use JSON for API responses").unwrap();
956
957 repo.link(3, 2, LinkKind::Amends, LinkKind::AmendedBy)
958 .unwrap();
959
960 let source_content =
962 fs::read_to_string(repo.adr_path().join("0003-use-json-for-api-responses.md")).unwrap();
963 assert!(
964 source_content.contains("Amends [2. Use REST API](0002-use-rest-api.md)"),
965 "Source ADR should have functional Amends link. Got:\n{source_content}"
966 );
967
968 let target_content =
970 fs::read_to_string(repo.adr_path().join("0002-use-rest-api.md")).unwrap();
971 assert!(
972 target_content.contains(
973 "Amended by [3. Use JSON for API responses](0003-use-json-for-api-responses.md)"
974 ),
975 "Target ADR should have functional Amended by link. Got:\n{target_content}"
976 );
977 }
978
979 #[test]
980 fn test_set_status_superseded_generates_functional_link() {
981 let temp = TempDir::new().unwrap();
982 let repo = Repository::init(temp.path(), None, false).unwrap();
983
984 repo.new_adr("First Decision").unwrap();
985 repo.new_adr("Second Decision").unwrap();
986
987 repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
988
989 let content = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
990 assert!(
991 content.contains("Superseded by [3. Second Decision](0003-second-decision.md)"),
992 "ADR should have functional Superseded by link. Got:\n{content}"
993 );
994 }
995
996 #[test]
997 fn test_supersede_chain_generates_functional_links() {
998 let temp = TempDir::new().unwrap();
999 let repo = Repository::init(temp.path(), None, false).unwrap();
1000
1001 repo.new_adr("Use SQLite").unwrap();
1004 repo.supersede("Use PostgreSQL", 2).unwrap();
1006 repo.supersede("Use CockroachDB", 3).unwrap();
1008
1009 let adr3_content =
1011 fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1012 assert!(
1013 adr3_content.contains("Supersedes [2. Use SQLite](0002-use-sqlite.md)"),
1014 "ADR 3 should supersede ADR 2. Got:\n{adr3_content}"
1015 );
1016 assert!(
1017 adr3_content.contains("Superseded by [4. Use CockroachDB](0004-use-cockroachdb.md)"),
1018 "ADR 3 should be superseded by ADR 4. Got:\n{adr3_content}"
1019 );
1020 }
1021
1022 #[test]
1023 fn test_ng_mode_supersede_generates_functional_links() {
1024 let temp = TempDir::new().unwrap();
1025 let repo = Repository::init(temp.path(), None, true).unwrap();
1026
1027 repo.new_adr("Use MySQL").unwrap();
1028 repo.supersede("Use PostgreSQL", 2).unwrap();
1029
1030 let new_content =
1032 fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1033
1034 assert!(
1036 new_content.contains("Supersedes [2. Use MySQL](0002-use-mysql.md)"),
1037 "NG mode should have functional link in body. Got:\n{new_content}"
1038 );
1039 assert!(new_content.contains("links:"));
1041 assert!(new_content.contains("target: 2"));
1042 }
1043
1044 #[test]
1047 fn test_set_status_accepted() {
1048 let temp = TempDir::new().unwrap();
1049 let repo = Repository::init(temp.path(), None, false).unwrap();
1050 repo.new_adr("Test Decision").unwrap();
1051
1052 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1053
1054 let adr = repo.get(2).unwrap();
1055 assert_eq!(adr.status, AdrStatus::Accepted);
1056 }
1057
1058 #[test]
1059 fn test_set_status_deprecated() {
1060 let temp = TempDir::new().unwrap();
1061 let repo = Repository::init(temp.path(), None, false).unwrap();
1062 repo.new_adr("Old Decision").unwrap();
1063
1064 repo.set_status(2, AdrStatus::Deprecated, None).unwrap();
1065
1066 let adr = repo.get(2).unwrap();
1067 assert_eq!(adr.status, AdrStatus::Deprecated);
1068 }
1069
1070 #[test]
1071 fn test_set_status_superseded_with_link() {
1072 let temp = TempDir::new().unwrap();
1073 let repo = Repository::init(temp.path(), None, false).unwrap();
1074 repo.new_adr("First Decision").unwrap();
1075 repo.new_adr("Second Decision").unwrap();
1076
1077 repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
1078
1079 let adr = repo.get(2).unwrap();
1080 assert_eq!(adr.status, AdrStatus::Superseded);
1081 assert_eq!(adr.links.len(), 1);
1082 assert_eq!(adr.links[0].target, 3);
1083 assert_eq!(adr.links[0].kind, LinkKind::SupersededBy);
1084 }
1085
1086 #[test]
1087 fn test_set_status_superseded_without_link() {
1088 let temp = TempDir::new().unwrap();
1089 let repo = Repository::init(temp.path(), None, false).unwrap();
1090 repo.new_adr("Decision").unwrap();
1091
1092 repo.set_status(2, AdrStatus::Superseded, None).unwrap();
1093
1094 let adr = repo.get(2).unwrap();
1095 assert_eq!(adr.status, AdrStatus::Superseded);
1096 assert_eq!(adr.links.len(), 0);
1097 }
1098
1099 #[test]
1100 fn test_set_status_custom() {
1101 let temp = TempDir::new().unwrap();
1102 let repo = Repository::init(temp.path(), None, false).unwrap();
1103 repo.new_adr("Test Decision").unwrap();
1104
1105 repo.set_status(2, AdrStatus::Custom("Draft".into()), None)
1106 .unwrap();
1107
1108 let adr = repo.get(2).unwrap();
1109 assert_eq!(adr.status, AdrStatus::Custom("Draft".into()));
1110 }
1111
1112 #[test]
1113 fn test_set_status_adr_not_found() {
1114 let temp = TempDir::new().unwrap();
1115 let repo = Repository::init(temp.path(), None, false).unwrap();
1116
1117 let result = repo.set_status(99, AdrStatus::Accepted, None);
1118 assert!(result.is_err());
1119 }
1120
1121 #[test]
1122 fn test_set_status_superseded_by_not_found() {
1123 let temp = TempDir::new().unwrap();
1124 let repo = Repository::init(temp.path(), None, false).unwrap();
1125 repo.new_adr("Decision").unwrap();
1126
1127 let result = repo.set_status(2, AdrStatus::Superseded, Some(99));
1128 assert!(result.is_err());
1129 }
1130
1131 #[test]
1134 fn test_link_adrs() {
1135 let temp = TempDir::new().unwrap();
1136 let repo = Repository::init(temp.path(), None, false).unwrap();
1137 repo.new_adr("Second").unwrap();
1138
1139 repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
1140 .unwrap();
1141
1142 let adr1 = repo.get(1).unwrap();
1143 assert_eq!(adr1.links.len(), 1);
1144 assert_eq!(adr1.links[0].target, 2);
1145 assert_eq!(adr1.links[0].kind, LinkKind::Amends);
1146
1147 let adr2 = repo.get(2).unwrap();
1148 assert_eq!(adr2.links.len(), 1);
1149 assert_eq!(adr2.links[0].target, 1);
1150 assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
1151 }
1152
1153 #[test]
1154 fn test_link_relates_to() {
1155 let temp = TempDir::new().unwrap();
1156 let repo = Repository::init(temp.path(), None, false).unwrap();
1157 repo.new_adr("Second").unwrap();
1158
1159 repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
1160 .unwrap();
1161
1162 let adr1 = repo.get(1).unwrap();
1163 assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
1164
1165 let adr2 = repo.get(2).unwrap();
1166 assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
1167 }
1168
1169 #[test]
1172 fn test_update_adr() {
1173 let temp = TempDir::new().unwrap();
1174 let repo = Repository::init(temp.path(), None, false).unwrap();
1175
1176 let mut adr = repo.get(1).unwrap();
1177 adr.status = AdrStatus::Deprecated;
1178
1179 repo.update(&adr).unwrap();
1180
1181 let updated = repo.get(1).unwrap();
1182 assert_eq!(updated.status, AdrStatus::Deprecated);
1183 }
1184
1185 #[test]
1186 fn test_update_preserves_content() {
1187 let temp = TempDir::new().unwrap();
1188 let repo = Repository::init(temp.path(), None, false).unwrap();
1189
1190 let mut adr = repo.get(1).unwrap();
1191 let original_title = adr.title.clone();
1192 adr.status = AdrStatus::Deprecated;
1193
1194 repo.update(&adr).unwrap();
1195
1196 let updated = repo.get(1).unwrap();
1197 assert_eq!(updated.title, original_title);
1198 }
1199
1200 #[test]
1203 fn test_read_content() {
1204 let temp = TempDir::new().unwrap();
1205 let repo = Repository::init(temp.path(), None, false).unwrap();
1206
1207 let adr = repo.get(1).unwrap();
1208 let content = repo.read_content(&adr).unwrap();
1209
1210 assert!(content.contains("Record architecture decisions"));
1211 assert!(content.contains("## Status"));
1212 }
1213
1214 #[test]
1215 fn test_write_content() {
1216 let temp = TempDir::new().unwrap();
1217 let repo = Repository::init(temp.path(), None, false).unwrap();
1218
1219 let adr = repo.get(1).unwrap();
1220 let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
1221
1222 repo.write_content(&adr, new_content).unwrap();
1223
1224 let content = repo.read_content(&adr).unwrap();
1225 assert!(content.contains("Modified"));
1226 }
1227
1228 #[test]
1231 fn test_with_template_format() {
1232 let temp = TempDir::new().unwrap();
1233 let repo = Repository::init(temp.path(), None, false)
1234 .unwrap()
1235 .with_template_format(TemplateFormat::Madr);
1236
1237 let (_, path) = repo.new_adr("MADR Test").unwrap();
1238 let content = fs::read_to_string(path).unwrap();
1239
1240 assert!(content.contains("Context and Problem Statement"));
1241 }
1242
1243 #[test]
1244 fn test_with_custom_template() {
1245 let temp = TempDir::new().unwrap();
1246 let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
1247 let repo = Repository::init(temp.path(), None, false)
1248 .unwrap()
1249 .with_custom_template(custom);
1250
1251 let (_, path) = repo.new_adr("Custom Test").unwrap();
1252 let content = fs::read_to_string(path).unwrap();
1253
1254 assert_eq!(content, "# ADR 2: Custom Test");
1255 }
1256
1257 #[test]
1260 fn test_root() {
1261 let temp = TempDir::new().unwrap();
1262 let repo = Repository::init(temp.path(), None, false).unwrap();
1263
1264 assert_eq!(repo.root(), temp.path());
1265 }
1266
1267 #[test]
1268 fn test_config() {
1269 let temp = TempDir::new().unwrap();
1270 let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
1271
1272 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
1273 assert!(repo.config().is_next_gen());
1274 }
1275
1276 #[test]
1277 fn test_adr_path() {
1278 let temp = TempDir::new().unwrap();
1279 let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
1280
1281 assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
1282 }
1283
1284 #[test]
1287 fn test_ng_mode_creates_frontmatter() {
1288 let temp = TempDir::new().unwrap();
1289 let repo = Repository::init(temp.path(), None, true).unwrap();
1290
1291 let (_, path) = repo.new_adr("NG Test").unwrap();
1292 let content = fs::read_to_string(path).unwrap();
1293
1294 assert!(content.starts_with("---"));
1295 assert!(content.contains("number: 2"));
1296 assert!(content.contains("title: NG Test"));
1297 }
1298
1299 #[test]
1300 fn test_ng_mode_parses_frontmatter() {
1301 let temp = TempDir::new().unwrap();
1302 let repo = Repository::init(temp.path(), None, true).unwrap();
1303
1304 repo.new_adr("NG ADR").unwrap();
1305
1306 let adr = repo.get(2).unwrap();
1307 assert_eq!(adr.title, "NG ADR");
1308 assert_eq!(adr.number, 2);
1309 }
1310
1311 #[test]
1314 fn test_list_empty_after_init_removal() {
1315 let temp = TempDir::new().unwrap();
1316 let repo = Repository::init(temp.path(), None, false).unwrap();
1317
1318 fs::remove_file(
1320 repo.adr_path()
1321 .join("0001-record-architecture-decisions.md"),
1322 )
1323 .unwrap();
1324
1325 let adrs = repo.list().unwrap();
1326 assert!(adrs.is_empty());
1327 }
1328
1329 #[test]
1330 fn test_list_ignores_non_adr_files() {
1331 let temp = TempDir::new().unwrap();
1332 let repo = Repository::init(temp.path(), None, false).unwrap();
1333
1334 fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
1336 fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
1337
1338 let adrs = repo.list().unwrap();
1339 assert_eq!(adrs.len(), 1); }
1341
1342 #[test]
1343 fn test_special_characters_in_title() {
1344 let temp = TempDir::new().unwrap();
1345 let repo = Repository::init(temp.path(), None, false).unwrap();
1346
1347 let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
1348 assert!(path.exists());
1349 assert_eq!(adr.title, "Use C++ & Rust!");
1350 }
1351
1352 #[test]
1355 fn test_set_status_preserves_madr_body() {
1356 let temp = TempDir::new().unwrap();
1357 let repo = Repository::init(temp.path(), None, true).unwrap();
1358
1359 let madr_content = r#"---
1360number: 2
1361title: Use Redis for caching
1362date: 2026-01-15
1363status: proposed
1364---
1365
1366# Use Redis for caching
1367
1368## Context and Problem Statement
1369
1370We need a **fast** caching layer for our [API](https://api.example.com).
1371
1372## Considered Options
1373
1374* Redis
1375* Memcached
1376* In-memory cache
1377
1378## Decision Outcome
1379
1380Chosen option: "Redis", because it supports data structures beyond simple key-value.
1381
1382### Consequences
1383
1384* Good, because it provides pub/sub
1385* Bad, because it adds operational complexity
1386
1387## Pros and Cons of the Options
1388
1389### Redis
1390
1391* Good, because it supports complex data types
1392* Bad, because it requires a separate server
1393
1394### Memcached
1395
1396* Good, because it's simpler
1397* Bad, because it only supports strings
1398"#;
1399 let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
1400 fs::write(&adr_path, madr_content).unwrap();
1401
1402 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1404
1405 let result = fs::read_to_string(&adr_path).unwrap();
1406
1407 assert!(result.contains("status: accepted"));
1409 assert!(!result.contains("status: proposed"));
1410
1411 let body_start = result.find("\n# Use Redis").unwrap();
1413 let original_body_start = madr_content.find("\n# Use Redis").unwrap();
1414 assert_eq!(
1415 &result[body_start..],
1416 &madr_content[original_body_start..],
1417 "Body content was modified"
1418 );
1419 }
1420
1421 #[test]
1422 fn test_set_status_preserves_yaml_comments() {
1423 let temp = TempDir::new().unwrap();
1424 let repo = Repository::init(temp.path(), None, true).unwrap();
1425
1426 let content_with_comments = r#"---
1427# SPDX-License-Identifier: MIT
1428# SPDX-FileCopyrightText: 2026 Example Corp
1429number: 2
1430title: Use MADR format
1431date: 2026-01-15
1432status: proposed
1433---
1434
1435## Context and Problem Statement
1436
1437We need a standard ADR format.
1438
1439## Decision Outcome
1440
1441Use MADR 4.0.0.
1442"#;
1443 let adr_path = repo.adr_path().join("0002-use-madr-format.md");
1444 fs::write(&adr_path, content_with_comments).unwrap();
1445
1446 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1447
1448 let result = fs::read_to_string(&adr_path).unwrap();
1449
1450 assert!(
1452 result.contains("# SPDX-License-Identifier: MIT"),
1453 "SPDX comment was destroyed"
1454 );
1455 assert!(
1456 result.contains("# SPDX-FileCopyrightText: 2026 Example Corp"),
1457 "Copyright comment was destroyed"
1458 );
1459 assert!(result.contains("status: accepted"));
1460 }
1461
1462 #[test]
1463 fn test_set_status_preserves_markdown_links() {
1464 let temp = TempDir::new().unwrap();
1465 let repo = Repository::init(temp.path(), None, true).unwrap();
1466
1467 let content = r#"---
1468number: 2
1469title: Use PostgreSQL
1470date: 2026-01-15
1471status: proposed
1472---
1473
1474## Context
1475
1476See the [PostgreSQL docs](https://www.postgresql.org/docs/) for details.
1477
1478Also see [RFC 7159](https://tools.ietf.org/html/rfc7159) and `inline code`.
1479
1480## Decision
1481
1482We will use **PostgreSQL** version `16.x`.
1483
1484## Consequences
1485
1486- [Monitoring guide](https://example.com/monitoring)
1487- Performance benchmarks in [this report](./benchmarks.md)
1488"#;
1489 let adr_path = repo.adr_path().join("0002-use-postgresql.md");
1490 fs::write(&adr_path, content).unwrap();
1491
1492 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1493
1494 let result = fs::read_to_string(&adr_path).unwrap();
1495
1496 assert!(result.contains("[PostgreSQL docs](https://www.postgresql.org/docs/)"));
1497 assert!(result.contains("[RFC 7159](https://tools.ietf.org/html/rfc7159)"));
1498 assert!(result.contains("`inline code`"));
1499 assert!(result.contains("**PostgreSQL**"));
1500 assert!(result.contains("[Monitoring guide](https://example.com/monitoring)"));
1501 assert!(result.contains("[this report](./benchmarks.md)"));
1502 }
1503
1504 #[test]
1505 fn test_link_preserves_body_content() {
1506 let temp = TempDir::new().unwrap();
1507 let repo = Repository::init(temp.path(), None, true).unwrap();
1508
1509 let content_1 = r#"---
1510number: 2
1511title: First decision
1512date: 2026-01-15
1513status: accepted
1514---
1515
1516## Context
1517
1518Custom context with **bold** and [links](https://example.com).
1519
1520## Decision
1521
1522A detailed decision paragraph.
1523
1524## Consequences
1525
1526- Important consequence 1
1527- Important consequence 2
1528"#;
1529 let content_2 = r#"---
1530number: 3
1531title: Second decision
1532date: 2026-01-16
1533status: accepted
1534---
1535
1536## Context
1537
1538Different context entirely.
1539
1540## Decision
1541
1542Another decision.
1543
1544## Consequences
1545
1546None significant.
1547"#;
1548 fs::write(repo.adr_path().join("0002-first-decision.md"), content_1).unwrap();
1549 fs::write(repo.adr_path().join("0003-second-decision.md"), content_2).unwrap();
1550
1551 repo.link(2, 3, LinkKind::Amends, LinkKind::AmendedBy)
1552 .unwrap();
1553
1554 let result_1 = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
1555 let result_2 = fs::read_to_string(repo.adr_path().join("0003-second-decision.md")).unwrap();
1556
1557 assert!(result_1.contains("Custom context with **bold** and [links](https://example.com)"));
1559 assert!(result_1.contains("A detailed decision paragraph."));
1560 assert!(result_2.contains("Different context entirely."));
1561 assert!(result_2.contains("None significant."));
1562
1563 assert!(result_1.contains("links:"));
1565 assert!(result_1.contains("target: 3"));
1566 assert!(result_2.contains("links:"));
1567 assert!(result_2.contains("target: 2"));
1568 }
1569
1570 #[test]
1571 fn test_supersede_preserves_old_adr_body() {
1572 let temp = TempDir::new().unwrap();
1573 let repo = Repository::init(temp.path(), None, true).unwrap();
1574
1575 let rich_content = r#"---
1576number: 2
1577title: Original approach
1578date: 2026-01-15
1579status: accepted
1580---
1581
1582## Context and Problem Statement
1583
1584This has **rich** markdown with [links](https://example.com).
1585
1586```rust
1587fn important_code() -> bool {
1588 true
1589}
1590```
1591
1592## Decision Outcome
1593
1594We chose the original approach.
1595
1596| Criteria | Score |
1597|----------|-------|
1598| Speed | 9/10 |
1599| Safety | 8/10 |
1600"#;
1601 fs::write(
1602 repo.adr_path().join("0002-original-approach.md"),
1603 rich_content,
1604 )
1605 .unwrap();
1606
1607 repo.supersede("Better approach", 2).unwrap();
1608
1609 let old_content =
1610 fs::read_to_string(repo.adr_path().join("0002-original-approach.md")).unwrap();
1611
1612 assert!(old_content.contains("```rust"));
1614 assert!(old_content.contains("fn important_code()"));
1615 assert!(old_content.contains("| Criteria | Score |"));
1616 assert!(old_content.contains("[links](https://example.com)"));
1617
1618 assert!(old_content.contains("status: superseded"));
1620 assert!(old_content.contains("target: 3"));
1621 }
1622
1623 #[test]
1624 fn test_set_status_legacy_preserves_sections() {
1625 let temp = TempDir::new().unwrap();
1626 let repo = Repository::init(temp.path(), None, false).unwrap();
1627
1628 let legacy_content = r#"# 2. Use Rust for backend
1629
1630Date: 2026-01-15
1631
1632## Status
1633
1634Proposed
1635
1636## Context
1637
1638We need a fast, safe language for our backend services.
1639
1640See the [Rust book](https://doc.rust-lang.org/book/) for details.
1641
1642## Decision
1643
1644We will use **Rust** with the `tokio` runtime.
1645
1646```toml
1647[dependencies]
1648tokio = { version = "1", features = ["full"] }
1649```
1650
1651## Consequences
1652
1653- Type safety prevents many bugs at compile time
1654- Learning curve for team members
1655"#;
1656 let adr_path = repo.adr_path().join("0002-use-rust-for-backend.md");
1657 fs::write(&adr_path, legacy_content).unwrap();
1658
1659 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1660
1661 let result = fs::read_to_string(&adr_path).unwrap();
1662
1663 assert!(result.contains("Accepted"));
1665
1666 assert!(result.contains("[Rust book](https://doc.rust-lang.org/book/)"));
1668 assert!(result.contains("**Rust**"));
1669 assert!(result.contains("`tokio`"));
1670 assert!(result.contains("```toml"));
1671 assert!(result.contains("tokio = { version = \"1\", features = [\"full\"] }"));
1672 assert!(result.contains("Type safety prevents many bugs"));
1673 }
1674
1675 #[test]
1676 fn test_set_status_frontmatter_with_existing_links() {
1677 let temp = TempDir::new().unwrap();
1678 let repo = Repository::init(temp.path(), None, true).unwrap();
1679
1680 let content = r#"---
1681number: 2
1682title: Updated approach
1683date: 2026-01-15
1684status: proposed
1685links:
1686 - target: 1
1687 kind: amends
1688---
1689
1690## Context
1691
1692Context.
1693
1694## Decision
1695
1696Decision.
1697"#;
1698 let adr_path = repo.adr_path().join("0002-updated-approach.md");
1699 fs::write(&adr_path, content).unwrap();
1700
1701 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1703
1704 let result = fs::read_to_string(&adr_path).unwrap();
1705 assert!(result.contains("status: accepted"));
1706 assert!(result.contains("links:"));
1707 assert!(result.contains("target: 1"));
1708 assert!(result.contains("kind: amends"));
1709 }
1710
1711 #[test]
1712 fn test_update_metadata_adds_tags_to_frontmatter() {
1713 let temp = TempDir::new().unwrap();
1714 let repo = Repository::init(temp.path(), None, true).unwrap();
1715
1716 let content = r#"---
1717number: 2
1718title: Tagged ADR
1719date: 2026-01-15
1720status: proposed
1721---
1722
1723## Context
1724
1725Context.
1726"#;
1727 let adr_path = repo.adr_path().join("0002-tagged-adr.md");
1728 fs::write(&adr_path, content).unwrap();
1729
1730 let mut adr = repo.get(2).unwrap();
1731 adr.set_tags(vec!["security".into(), "api".into()]);
1732 repo.update_metadata(&adr).unwrap();
1733
1734 let result = fs::read_to_string(&adr_path).unwrap();
1735 assert!(result.contains("tags:"));
1736 assert!(result.contains(" - security"));
1737 assert!(result.contains(" - api"));
1738 assert!(result.contains("## Context\n\nContext."));
1740 }
1741}