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 let yaml_block = yaml_block.trim_end_matches('\n');
486 Ok(format!("---\n{}{}", yaml_block, after_yaml))
487 }
488
489 fn update_legacy_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
494 let lines: Vec<&str> = content.lines().collect();
495 let mut result = String::with_capacity(content.len());
496
497 let status_idx = lines.iter().position(|l| {
499 l.trim().eq_ignore_ascii_case("## Status") || l.trim().eq_ignore_ascii_case("## STATUS")
500 });
501
502 let Some(status_idx) = status_idx else {
503 return Ok(content.to_string());
505 };
506
507 let next_heading_idx = lines[status_idx + 1..]
509 .iter()
510 .position(|l| l.starts_with("## "))
511 .map(|i| i + status_idx + 1);
512
513 for line in &lines[..=status_idx] {
515 result.push_str(line);
516 result.push('\n');
517 }
518
519 result.push('\n');
521 result.push_str(&adr.status.to_string());
522 result.push('\n');
523
524 let link_titles = self.resolve_link_titles(adr);
526 for link in &adr.links {
527 result.push('\n');
528 if let Some((title, filename)) = link_titles.get(&link.target) {
529 result.push_str(&format!(
530 "{} [{}. {}]({})",
531 link.kind, link.target, title, filename
532 ));
533 } else {
534 result.push_str(&format!(
535 "{} [{}. ...]({:04}-....md)",
536 link.kind, link.target, link.target
537 ));
538 }
539 result.push('\n');
540 }
541
542 if let Some(next_idx) = next_heading_idx {
544 result.push('\n');
545 for (i, line) in lines[next_idx..].iter().enumerate() {
546 result.push_str(line);
547 if next_idx + i < lines.len() - 1 || content.ends_with('\n') {
549 result.push('\n');
550 }
551 }
552 } else if content.ends_with('\n') {
553 }
555
556 Ok(result)
557 }
558
559 fn format_links_yaml(links: &[AdrLink]) -> String {
561 if links.is_empty() {
562 return String::new();
563 }
564 let mut s = String::from("links:\n");
565 for link in links {
566 let kind_str = match &link.kind {
567 LinkKind::Supersedes => "supersedes",
568 LinkKind::SupersededBy => "supersededby",
569 LinkKind::Amends => "amends",
570 LinkKind::AmendedBy => "amendedby",
571 LinkKind::RelatesTo => "relatesto",
572 LinkKind::Custom(c) => c.as_str(),
573 };
574 s.push_str(&format!(
575 " - target: {}\n kind: {}\n",
576 link.target, kind_str
577 ));
578 }
579 s
580 }
581
582 fn format_tags_yaml(tags: &[String]) -> String {
584 if tags.is_empty() {
585 return String::new();
586 }
587 let mut s = String::from("tags:\n");
588 for tag in tags {
589 s.push_str(&format!(" - {}\n", tag));
590 }
591 s
592 }
593}
594
595fn count_existing_adrs(path: &Path) -> usize {
597 if !path.is_dir() {
598 return 0;
599 }
600
601 fs::read_dir(path)
602 .map(|entries| {
603 entries
604 .filter_map(|e| e.ok())
605 .filter(|e| {
606 let path = e.path();
607 path.is_file()
608 && path.extension().is_some_and(|ext| ext == "md")
609 && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
610 n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
612 })
613 })
614 .count()
615 })
616 .unwrap_or(0)
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use tempfile::TempDir;
623
624 #[test]
627 fn test_init_repository() {
628 let temp = TempDir::new().unwrap();
629 let repo = Repository::init(temp.path(), None, false).unwrap();
630
631 assert!(repo.adr_path().exists());
632 assert!(temp.path().join(".adr-dir").exists());
633
634 let adrs = repo.list().unwrap();
635 assert_eq!(adrs.len(), 1);
636 assert_eq!(adrs[0].number, 1);
637 assert_eq!(adrs[0].title, "Record architecture decisions");
638 }
639
640 #[test]
641 fn test_init_repository_ng() {
642 let temp = TempDir::new().unwrap();
643 let repo = Repository::init(temp.path(), None, true).unwrap();
644
645 assert!(temp.path().join("adrs.toml").exists());
646 assert!(repo.config().is_next_gen());
647 }
648
649 #[test]
650 fn test_init_repository_custom_dir() {
651 let temp = TempDir::new().unwrap();
652 let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
653
654 assert!(temp.path().join("decisions").exists());
655 assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
656 }
657
658 #[test]
659 fn test_init_repository_nested_dir() {
660 let temp = TempDir::new().unwrap();
661 let _repo =
662 Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
663
664 assert!(temp.path().join("docs/architecture/adr").exists());
665 }
666
667 #[test]
668 fn test_init_repository_already_exists_skips_initial_adr() {
669 let temp = TempDir::new().unwrap();
670 Repository::init(temp.path(), None, false).unwrap();
671
672 let repo = Repository::init(temp.path(), None, false).unwrap();
674 let adrs = repo.list().unwrap();
675 assert_eq!(adrs.len(), 1); }
677
678 #[test]
679 fn test_init_with_existing_adrs_skips_initial() {
680 let temp = TempDir::new().unwrap();
681 let adr_dir = temp.path().join("doc/adr");
682 fs::create_dir_all(&adr_dir).unwrap();
683
684 fs::write(
686 adr_dir.join("0001-existing-decision.md"),
687 "# 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",
688 )
689 .unwrap();
690 fs::write(
691 adr_dir.join("0002-another-decision.md"),
692 "# 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",
693 )
694 .unwrap();
695
696 let repo = Repository::init(temp.path(), None, false).unwrap();
698 let adrs = repo.list().unwrap();
699 assert_eq!(adrs.len(), 2); assert_eq!(adrs[0].title, "Existing Decision");
701 assert_eq!(adrs[1].title, "Another Decision");
702 }
703
704 #[test]
705 fn test_init_creates_first_adr() {
706 let temp = TempDir::new().unwrap();
707 let repo = Repository::init(temp.path(), None, false).unwrap();
708
709 let adr = repo.get(1).unwrap();
710 assert_eq!(adr.title, "Record architecture decisions");
711 assert_eq!(adr.status, AdrStatus::Accepted);
712 assert!(!adr.context.is_empty());
713 assert!(!adr.decision.is_empty());
714 assert!(!adr.consequences.is_empty());
715 }
716
717 #[test]
720 fn test_open_repository() {
721 let temp = TempDir::new().unwrap();
722 Repository::init(temp.path(), None, false).unwrap();
723
724 let repo = Repository::open(temp.path()).unwrap();
725 assert_eq!(repo.list().unwrap().len(), 1);
726 }
727
728 #[test]
729 fn test_open_repository_not_found() {
730 let temp = TempDir::new().unwrap();
731 let result = Repository::open(temp.path());
732 assert!(result.is_err());
733 }
734
735 #[test]
736 fn test_open_or_default() {
737 let temp = TempDir::new().unwrap();
738 let repo = Repository::open_or_default(temp.path());
739 assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
740 }
741
742 #[test]
743 fn test_open_or_default_existing() {
744 let temp = TempDir::new().unwrap();
745 Repository::init(temp.path(), Some("custom".into()), false).unwrap();
746
747 let repo = Repository::open_or_default(temp.path());
748 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
749 }
750
751 #[test]
754 fn test_create_and_list() {
755 let temp = TempDir::new().unwrap();
756 let repo = Repository::init(temp.path(), None, false).unwrap();
757
758 let (adr, _) = repo.new_adr("Use Rust").unwrap();
759 assert_eq!(adr.number, 2);
760
761 let adrs = repo.list().unwrap();
762 assert_eq!(adrs.len(), 2);
763 }
764
765 #[test]
766 fn test_create_multiple() {
767 let temp = TempDir::new().unwrap();
768 let repo = Repository::init(temp.path(), None, false).unwrap();
769
770 repo.new_adr("Second").unwrap();
771 repo.new_adr("Third").unwrap();
772 repo.new_adr("Fourth").unwrap();
773
774 let adrs = repo.list().unwrap();
775 assert_eq!(adrs.len(), 4);
776 assert_eq!(adrs[0].number, 1);
777 assert_eq!(adrs[1].number, 2);
778 assert_eq!(adrs[2].number, 3);
779 assert_eq!(adrs[3].number, 4);
780 }
781
782 #[test]
783 fn test_list_sorted_by_number() {
784 let temp = TempDir::new().unwrap();
785 let repo = Repository::init(temp.path(), None, false).unwrap();
786
787 repo.new_adr("B").unwrap();
788 repo.new_adr("A").unwrap();
789 repo.new_adr("C").unwrap();
790
791 let adrs = repo.list().unwrap();
792 assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
793 }
794
795 #[test]
796 fn test_next_number() {
797 let temp = TempDir::new().unwrap();
798 let repo = Repository::init(temp.path(), None, false).unwrap();
799
800 assert_eq!(repo.next_number().unwrap(), 2);
801
802 repo.new_adr("Second").unwrap();
803 assert_eq!(repo.next_number().unwrap(), 3);
804 }
805
806 #[test]
807 fn test_create_file_exists() {
808 let temp = TempDir::new().unwrap();
809 let repo = Repository::init(temp.path(), None, false).unwrap();
810
811 let (_, path) = repo.new_adr("Test ADR").unwrap();
812 assert!(path.exists());
813 assert!(path.to_string_lossy().contains("0002-test-adr.md"));
814 }
815
816 #[test]
819 fn test_get_by_number() {
820 let temp = TempDir::new().unwrap();
821 let repo = Repository::init(temp.path(), None, false).unwrap();
822 repo.new_adr("Second").unwrap();
823
824 let adr = repo.get(2).unwrap();
825 assert_eq!(adr.title, "Second");
826 }
827
828 #[test]
829 fn test_get_not_found() {
830 let temp = TempDir::new().unwrap();
831 let repo = Repository::init(temp.path(), None, false).unwrap();
832
833 let result = repo.get(99);
834 assert!(result.is_err());
835 }
836
837 #[test]
838 fn test_find_by_number() {
839 let temp = TempDir::new().unwrap();
840 let repo = Repository::init(temp.path(), None, false).unwrap();
841
842 let adr = repo.find("1").unwrap();
843 assert_eq!(adr.number, 1);
844 }
845
846 #[test]
847 fn test_find_by_title() {
848 let temp = TempDir::new().unwrap();
849 let repo = Repository::init(temp.path(), None, false).unwrap();
850
851 let adr = repo.find("architecture").unwrap();
852 assert_eq!(adr.number, 1);
853 }
854
855 #[test]
856 fn test_find_fuzzy_match() {
857 let temp = TempDir::new().unwrap();
858 let repo = Repository::init(temp.path(), None, false).unwrap();
859 repo.new_adr("Use PostgreSQL for database").unwrap();
860 repo.new_adr("Use Redis for caching").unwrap();
861
862 let adr = repo.find("postgres").unwrap();
863 assert!(adr.title.contains("PostgreSQL"));
864 }
865
866 #[test]
867 fn test_find_not_found() {
868 let temp = TempDir::new().unwrap();
869 let repo = Repository::init(temp.path(), None, false).unwrap();
870
871 let result = repo.find("nonexistent");
872 assert!(result.is_err());
873 }
874
875 #[test]
878 fn test_supersede() {
879 let temp = TempDir::new().unwrap();
880 let repo = Repository::init(temp.path(), None, false).unwrap();
881
882 let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
883 assert_eq!(new_adr.number, 2);
884 assert_eq!(new_adr.links.len(), 1);
885 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
886
887 let old_adr = repo.get(1).unwrap();
888 assert_eq!(old_adr.status, AdrStatus::Superseded);
889 }
890
891 #[test]
892 fn test_supersede_creates_bidirectional_links() {
893 let temp = TempDir::new().unwrap();
894 let repo = Repository::init(temp.path(), None, false).unwrap();
895
896 repo.supersede("New approach", 1).unwrap();
897
898 let old_adr = repo.get(1).unwrap();
899 assert_eq!(old_adr.links.len(), 1);
900 assert_eq!(old_adr.links[0].target, 2);
901 assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
902
903 let new_adr = repo.get(2).unwrap();
904 assert_eq!(new_adr.links.len(), 1);
905 assert_eq!(new_adr.links[0].target, 1);
906 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
907 }
908
909 #[test]
910 fn test_supersede_not_found() {
911 let temp = TempDir::new().unwrap();
912 let repo = Repository::init(temp.path(), None, false).unwrap();
913
914 let result = repo.supersede("New", 99);
915 assert!(result.is_err());
916 }
917
918 #[test]
921 fn test_supersede_generates_functional_links() {
922 let temp = TempDir::new().unwrap();
923 let repo = Repository::init(temp.path(), None, false).unwrap();
924
925 repo.new_adr("Use MySQL for persistence").unwrap();
927 repo.supersede("Use PostgreSQL instead", 2).unwrap();
928
929 let new_content =
931 fs::read_to_string(repo.adr_path().join("0003-use-postgresql-instead.md")).unwrap();
932 assert!(
933 new_content.contains(
934 "Supersedes [2. Use MySQL for persistence](0002-use-mysql-for-persistence.md)"
935 ),
936 "New ADR should have functional Supersedes link. Got:\n{new_content}"
937 );
938
939 let old_content =
941 fs::read_to_string(repo.adr_path().join("0002-use-mysql-for-persistence.md")).unwrap();
942 assert!(
943 old_content.contains(
944 "Superseded by [3. Use PostgreSQL instead](0003-use-postgresql-instead.md)"
945 ),
946 "Old ADR should have functional Superseded by link. Got:\n{old_content}"
947 );
948 }
949
950 #[test]
951 fn test_link_generates_functional_links() {
952 let temp = TempDir::new().unwrap();
953 let repo = Repository::init(temp.path(), None, false).unwrap();
954
955 repo.new_adr("Use REST API").unwrap();
956 repo.new_adr("Use JSON for API responses").unwrap();
957
958 repo.link(3, 2, LinkKind::Amends, LinkKind::AmendedBy)
959 .unwrap();
960
961 let source_content =
963 fs::read_to_string(repo.adr_path().join("0003-use-json-for-api-responses.md")).unwrap();
964 assert!(
965 source_content.contains("Amends [2. Use REST API](0002-use-rest-api.md)"),
966 "Source ADR should have functional Amends link. Got:\n{source_content}"
967 );
968
969 let target_content =
971 fs::read_to_string(repo.adr_path().join("0002-use-rest-api.md")).unwrap();
972 assert!(
973 target_content.contains(
974 "Amended by [3. Use JSON for API responses](0003-use-json-for-api-responses.md)"
975 ),
976 "Target ADR should have functional Amended by link. Got:\n{target_content}"
977 );
978 }
979
980 #[test]
981 fn test_set_status_superseded_generates_functional_link() {
982 let temp = TempDir::new().unwrap();
983 let repo = Repository::init(temp.path(), None, false).unwrap();
984
985 repo.new_adr("First Decision").unwrap();
986 repo.new_adr("Second Decision").unwrap();
987
988 repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
989
990 let content = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
991 assert!(
992 content.contains("Superseded by [3. Second Decision](0003-second-decision.md)"),
993 "ADR should have functional Superseded by link. Got:\n{content}"
994 );
995 }
996
997 #[test]
998 fn test_supersede_chain_generates_functional_links() {
999 let temp = TempDir::new().unwrap();
1000 let repo = Repository::init(temp.path(), None, false).unwrap();
1001
1002 repo.new_adr("Use SQLite").unwrap();
1005 repo.supersede("Use PostgreSQL", 2).unwrap();
1007 repo.supersede("Use CockroachDB", 3).unwrap();
1009
1010 let adr3_content =
1012 fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1013 assert!(
1014 adr3_content.contains("Supersedes [2. Use SQLite](0002-use-sqlite.md)"),
1015 "ADR 3 should supersede ADR 2. Got:\n{adr3_content}"
1016 );
1017 assert!(
1018 adr3_content.contains("Superseded by [4. Use CockroachDB](0004-use-cockroachdb.md)"),
1019 "ADR 3 should be superseded by ADR 4. Got:\n{adr3_content}"
1020 );
1021 }
1022
1023 #[test]
1024 fn test_ng_mode_supersede_generates_functional_links() {
1025 let temp = TempDir::new().unwrap();
1026 let repo = Repository::init(temp.path(), None, true).unwrap();
1027
1028 repo.new_adr("Use MySQL").unwrap();
1029 repo.supersede("Use PostgreSQL", 2).unwrap();
1030
1031 let new_content =
1033 fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1034
1035 assert!(
1037 new_content.contains("Supersedes [2. Use MySQL](0002-use-mysql.md)"),
1038 "NG mode should have functional link in body. Got:\n{new_content}"
1039 );
1040 assert!(new_content.contains("links:"));
1042 assert!(new_content.contains("target: 2"));
1043 }
1044
1045 #[test]
1048 fn test_set_status_accepted() {
1049 let temp = TempDir::new().unwrap();
1050 let repo = Repository::init(temp.path(), None, false).unwrap();
1051 repo.new_adr("Test Decision").unwrap();
1052
1053 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1054
1055 let adr = repo.get(2).unwrap();
1056 assert_eq!(adr.status, AdrStatus::Accepted);
1057 }
1058
1059 #[test]
1060 fn test_set_status_deprecated() {
1061 let temp = TempDir::new().unwrap();
1062 let repo = Repository::init(temp.path(), None, false).unwrap();
1063 repo.new_adr("Old Decision").unwrap();
1064
1065 repo.set_status(2, AdrStatus::Deprecated, None).unwrap();
1066
1067 let adr = repo.get(2).unwrap();
1068 assert_eq!(adr.status, AdrStatus::Deprecated);
1069 }
1070
1071 #[test]
1072 fn test_set_status_superseded_with_link() {
1073 let temp = TempDir::new().unwrap();
1074 let repo = Repository::init(temp.path(), None, false).unwrap();
1075 repo.new_adr("First Decision").unwrap();
1076 repo.new_adr("Second Decision").unwrap();
1077
1078 repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
1079
1080 let adr = repo.get(2).unwrap();
1081 assert_eq!(adr.status, AdrStatus::Superseded);
1082 assert_eq!(adr.links.len(), 1);
1083 assert_eq!(adr.links[0].target, 3);
1084 assert_eq!(adr.links[0].kind, LinkKind::SupersededBy);
1085 }
1086
1087 #[test]
1088 fn test_set_status_superseded_without_link() {
1089 let temp = TempDir::new().unwrap();
1090 let repo = Repository::init(temp.path(), None, false).unwrap();
1091 repo.new_adr("Decision").unwrap();
1092
1093 repo.set_status(2, AdrStatus::Superseded, None).unwrap();
1094
1095 let adr = repo.get(2).unwrap();
1096 assert_eq!(adr.status, AdrStatus::Superseded);
1097 assert_eq!(adr.links.len(), 0);
1098 }
1099
1100 #[test]
1101 fn test_set_status_custom() {
1102 let temp = TempDir::new().unwrap();
1103 let repo = Repository::init(temp.path(), None, false).unwrap();
1104 repo.new_adr("Test Decision").unwrap();
1105
1106 repo.set_status(2, AdrStatus::Custom("Draft".into()), None)
1107 .unwrap();
1108
1109 let adr = repo.get(2).unwrap();
1110 assert_eq!(adr.status, AdrStatus::Custom("Draft".into()));
1111 }
1112
1113 #[test]
1114 fn test_set_status_adr_not_found() {
1115 let temp = TempDir::new().unwrap();
1116 let repo = Repository::init(temp.path(), None, false).unwrap();
1117
1118 let result = repo.set_status(99, AdrStatus::Accepted, None);
1119 assert!(result.is_err());
1120 }
1121
1122 #[test]
1123 fn test_set_status_superseded_by_not_found() {
1124 let temp = TempDir::new().unwrap();
1125 let repo = Repository::init(temp.path(), None, false).unwrap();
1126 repo.new_adr("Decision").unwrap();
1127
1128 let result = repo.set_status(2, AdrStatus::Superseded, Some(99));
1129 assert!(result.is_err());
1130 }
1131
1132 #[test]
1135 fn test_link_adrs() {
1136 let temp = TempDir::new().unwrap();
1137 let repo = Repository::init(temp.path(), None, false).unwrap();
1138 repo.new_adr("Second").unwrap();
1139
1140 repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
1141 .unwrap();
1142
1143 let adr1 = repo.get(1).unwrap();
1144 assert_eq!(adr1.links.len(), 1);
1145 assert_eq!(adr1.links[0].target, 2);
1146 assert_eq!(adr1.links[0].kind, LinkKind::Amends);
1147
1148 let adr2 = repo.get(2).unwrap();
1149 assert_eq!(adr2.links.len(), 1);
1150 assert_eq!(adr2.links[0].target, 1);
1151 assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
1152 }
1153
1154 #[test]
1155 fn test_link_relates_to() {
1156 let temp = TempDir::new().unwrap();
1157 let repo = Repository::init(temp.path(), None, false).unwrap();
1158 repo.new_adr("Second").unwrap();
1159
1160 repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
1161 .unwrap();
1162
1163 let adr1 = repo.get(1).unwrap();
1164 assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
1165
1166 let adr2 = repo.get(2).unwrap();
1167 assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
1168 }
1169
1170 #[test]
1173 fn test_update_adr() {
1174 let temp = TempDir::new().unwrap();
1175 let repo = Repository::init(temp.path(), None, false).unwrap();
1176
1177 let mut adr = repo.get(1).unwrap();
1178 adr.status = AdrStatus::Deprecated;
1179
1180 repo.update(&adr).unwrap();
1181
1182 let updated = repo.get(1).unwrap();
1183 assert_eq!(updated.status, AdrStatus::Deprecated);
1184 }
1185
1186 #[test]
1187 fn test_update_preserves_content() {
1188 let temp = TempDir::new().unwrap();
1189 let repo = Repository::init(temp.path(), None, false).unwrap();
1190
1191 let mut adr = repo.get(1).unwrap();
1192 let original_title = adr.title.clone();
1193 adr.status = AdrStatus::Deprecated;
1194
1195 repo.update(&adr).unwrap();
1196
1197 let updated = repo.get(1).unwrap();
1198 assert_eq!(updated.title, original_title);
1199 }
1200
1201 #[test]
1204 fn test_read_content() {
1205 let temp = TempDir::new().unwrap();
1206 let repo = Repository::init(temp.path(), None, false).unwrap();
1207
1208 let adr = repo.get(1).unwrap();
1209 let content = repo.read_content(&adr).unwrap();
1210
1211 assert!(content.contains("Record architecture decisions"));
1212 assert!(content.contains("## Status"));
1213 }
1214
1215 #[test]
1216 fn test_write_content() {
1217 let temp = TempDir::new().unwrap();
1218 let repo = Repository::init(temp.path(), None, false).unwrap();
1219
1220 let adr = repo.get(1).unwrap();
1221 let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
1222
1223 repo.write_content(&adr, new_content).unwrap();
1224
1225 let content = repo.read_content(&adr).unwrap();
1226 assert!(content.contains("Modified"));
1227 }
1228
1229 #[test]
1232 fn test_with_template_format() {
1233 let temp = TempDir::new().unwrap();
1234 let repo = Repository::init(temp.path(), None, false)
1235 .unwrap()
1236 .with_template_format(TemplateFormat::Madr);
1237
1238 let (_, path) = repo.new_adr("MADR Test").unwrap();
1239 let content = fs::read_to_string(path).unwrap();
1240
1241 assert!(content.contains("Context and Problem Statement"));
1242 }
1243
1244 #[test]
1245 fn test_with_custom_template() {
1246 let temp = TempDir::new().unwrap();
1247 let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
1248 let repo = Repository::init(temp.path(), None, false)
1249 .unwrap()
1250 .with_custom_template(custom);
1251
1252 let (_, path) = repo.new_adr("Custom Test").unwrap();
1253 let content = fs::read_to_string(path).unwrap();
1254
1255 assert_eq!(content, "# ADR 2: Custom Test");
1256 }
1257
1258 #[test]
1261 fn test_root() {
1262 let temp = TempDir::new().unwrap();
1263 let repo = Repository::init(temp.path(), None, false).unwrap();
1264
1265 assert_eq!(repo.root(), temp.path());
1266 }
1267
1268 #[test]
1269 fn test_config() {
1270 let temp = TempDir::new().unwrap();
1271 let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
1272
1273 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
1274 assert!(repo.config().is_next_gen());
1275 }
1276
1277 #[test]
1278 fn test_adr_path() {
1279 let temp = TempDir::new().unwrap();
1280 let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
1281
1282 assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
1283 }
1284
1285 #[test]
1288 fn test_ng_mode_creates_frontmatter() {
1289 let temp = TempDir::new().unwrap();
1290 let repo = Repository::init(temp.path(), None, true).unwrap();
1291
1292 let (_, path) = repo.new_adr("NG Test").unwrap();
1293 let content = fs::read_to_string(path).unwrap();
1294
1295 assert!(content.starts_with("---"));
1296 assert!(content.contains("number: 2"));
1297 assert!(content.contains("title: NG Test"));
1298 }
1299
1300 #[test]
1301 fn test_ng_mode_parses_frontmatter() {
1302 let temp = TempDir::new().unwrap();
1303 let repo = Repository::init(temp.path(), None, true).unwrap();
1304
1305 repo.new_adr("NG ADR").unwrap();
1306
1307 let adr = repo.get(2).unwrap();
1308 assert_eq!(adr.title, "NG ADR");
1309 assert_eq!(adr.number, 2);
1310 }
1311
1312 #[test]
1315 fn test_list_empty_after_init_removal() {
1316 let temp = TempDir::new().unwrap();
1317 let repo = Repository::init(temp.path(), None, false).unwrap();
1318
1319 fs::remove_file(
1321 repo.adr_path()
1322 .join("0001-record-architecture-decisions.md"),
1323 )
1324 .unwrap();
1325
1326 let adrs = repo.list().unwrap();
1327 assert!(adrs.is_empty());
1328 }
1329
1330 #[test]
1331 fn test_list_ignores_non_adr_files() {
1332 let temp = TempDir::new().unwrap();
1333 let repo = Repository::init(temp.path(), None, false).unwrap();
1334
1335 fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
1337 fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
1338
1339 let adrs = repo.list().unwrap();
1340 assert_eq!(adrs.len(), 1); }
1342
1343 #[test]
1344 fn test_special_characters_in_title() {
1345 let temp = TempDir::new().unwrap();
1346 let repo = Repository::init(temp.path(), None, false).unwrap();
1347
1348 let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
1349 assert!(path.exists());
1350 assert_eq!(adr.title, "Use C++ & Rust!");
1351 }
1352
1353 #[test]
1356 fn test_set_status_preserves_madr_body() {
1357 let temp = TempDir::new().unwrap();
1358 let repo = Repository::init(temp.path(), None, true).unwrap();
1359
1360 let madr_content = r#"---
1361number: 2
1362title: Use Redis for caching
1363date: 2026-01-15
1364status: proposed
1365---
1366
1367# Use Redis for caching
1368
1369## Context and Problem Statement
1370
1371We need a **fast** caching layer for our [API](https://api.example.com).
1372
1373## Considered Options
1374
1375* Redis
1376* Memcached
1377* In-memory cache
1378
1379## Decision Outcome
1380
1381Chosen option: "Redis", because it supports data structures beyond simple key-value.
1382
1383### Consequences
1384
1385* Good, because it provides pub/sub
1386* Bad, because it adds operational complexity
1387
1388## Pros and Cons of the Options
1389
1390### Redis
1391
1392* Good, because it supports complex data types
1393* Bad, because it requires a separate server
1394
1395### Memcached
1396
1397* Good, because it's simpler
1398* Bad, because it only supports strings
1399"#;
1400 let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
1401 fs::write(&adr_path, madr_content).unwrap();
1402
1403 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1405
1406 let result = fs::read_to_string(&adr_path).unwrap();
1407
1408 assert!(result.contains("status: accepted"));
1410 assert!(!result.contains("status: proposed"));
1411
1412 let body_start = result.find("\n# Use Redis").unwrap();
1414 let original_body_start = madr_content.find("\n# Use Redis").unwrap();
1415 assert_eq!(
1416 &result[body_start..],
1417 &madr_content[original_body_start..],
1418 "Body content was modified"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_set_status_preserves_yaml_comments() {
1424 let temp = TempDir::new().unwrap();
1425 let repo = Repository::init(temp.path(), None, true).unwrap();
1426
1427 let content_with_comments = r#"---
1428# SPDX-License-Identifier: MIT
1429# SPDX-FileCopyrightText: 2026 Example Corp
1430number: 2
1431title: Use MADR format
1432date: 2026-01-15
1433status: proposed
1434---
1435
1436## Context and Problem Statement
1437
1438We need a standard ADR format.
1439
1440## Decision Outcome
1441
1442Use MADR 4.0.0.
1443"#;
1444 let adr_path = repo.adr_path().join("0002-use-madr-format.md");
1445 fs::write(&adr_path, content_with_comments).unwrap();
1446
1447 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1448
1449 let result = fs::read_to_string(&adr_path).unwrap();
1450
1451 assert!(
1453 result.contains("# SPDX-License-Identifier: MIT"),
1454 "SPDX comment was destroyed"
1455 );
1456 assert!(
1457 result.contains("# SPDX-FileCopyrightText: 2026 Example Corp"),
1458 "Copyright comment was destroyed"
1459 );
1460 assert!(result.contains("status: accepted"));
1461 }
1462
1463 #[test]
1464 fn test_set_status_preserves_markdown_links() {
1465 let temp = TempDir::new().unwrap();
1466 let repo = Repository::init(temp.path(), None, true).unwrap();
1467
1468 let content = r#"---
1469number: 2
1470title: Use PostgreSQL
1471date: 2026-01-15
1472status: proposed
1473---
1474
1475## Context
1476
1477See the [PostgreSQL docs](https://www.postgresql.org/docs/) for details.
1478
1479Also see [RFC 7159](https://tools.ietf.org/html/rfc7159) and `inline code`.
1480
1481## Decision
1482
1483We will use **PostgreSQL** version `16.x`.
1484
1485## Consequences
1486
1487- [Monitoring guide](https://example.com/monitoring)
1488- Performance benchmarks in [this report](./benchmarks.md)
1489"#;
1490 let adr_path = repo.adr_path().join("0002-use-postgresql.md");
1491 fs::write(&adr_path, content).unwrap();
1492
1493 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1494
1495 let result = fs::read_to_string(&adr_path).unwrap();
1496
1497 assert!(result.contains("[PostgreSQL docs](https://www.postgresql.org/docs/)"));
1498 assert!(result.contains("[RFC 7159](https://tools.ietf.org/html/rfc7159)"));
1499 assert!(result.contains("`inline code`"));
1500 assert!(result.contains("**PostgreSQL**"));
1501 assert!(result.contains("[Monitoring guide](https://example.com/monitoring)"));
1502 assert!(result.contains("[this report](./benchmarks.md)"));
1503 }
1504
1505 #[test]
1506 fn test_link_preserves_body_content() {
1507 let temp = TempDir::new().unwrap();
1508 let repo = Repository::init(temp.path(), None, true).unwrap();
1509
1510 let content_1 = r#"---
1511number: 2
1512title: First decision
1513date: 2026-01-15
1514status: accepted
1515---
1516
1517## Context
1518
1519Custom context with **bold** and [links](https://example.com).
1520
1521## Decision
1522
1523A detailed decision paragraph.
1524
1525## Consequences
1526
1527- Important consequence 1
1528- Important consequence 2
1529"#;
1530 let content_2 = r#"---
1531number: 3
1532title: Second decision
1533date: 2026-01-16
1534status: accepted
1535---
1536
1537## Context
1538
1539Different context entirely.
1540
1541## Decision
1542
1543Another decision.
1544
1545## Consequences
1546
1547None significant.
1548"#;
1549 fs::write(repo.adr_path().join("0002-first-decision.md"), content_1).unwrap();
1550 fs::write(repo.adr_path().join("0003-second-decision.md"), content_2).unwrap();
1551
1552 repo.link(2, 3, LinkKind::Amends, LinkKind::AmendedBy)
1553 .unwrap();
1554
1555 let result_1 = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
1556 let result_2 = fs::read_to_string(repo.adr_path().join("0003-second-decision.md")).unwrap();
1557
1558 assert!(result_1.contains("Custom context with **bold** and [links](https://example.com)"));
1560 assert!(result_1.contains("A detailed decision paragraph."));
1561 assert!(result_2.contains("Different context entirely."));
1562 assert!(result_2.contains("None significant."));
1563
1564 assert!(result_1.contains("links:"));
1566 assert!(result_1.contains("target: 3"));
1567 assert!(result_2.contains("links:"));
1568 assert!(result_2.contains("target: 2"));
1569 }
1570
1571 #[test]
1572 fn test_supersede_preserves_old_adr_body() {
1573 let temp = TempDir::new().unwrap();
1574 let repo = Repository::init(temp.path(), None, true).unwrap();
1575
1576 let rich_content = r#"---
1577number: 2
1578title: Original approach
1579date: 2026-01-15
1580status: accepted
1581---
1582
1583## Context and Problem Statement
1584
1585This has **rich** markdown with [links](https://example.com).
1586
1587```rust
1588fn important_code() -> bool {
1589 true
1590}
1591```
1592
1593## Decision Outcome
1594
1595We chose the original approach.
1596
1597| Criteria | Score |
1598|----------|-------|
1599| Speed | 9/10 |
1600| Safety | 8/10 |
1601"#;
1602 fs::write(
1603 repo.adr_path().join("0002-original-approach.md"),
1604 rich_content,
1605 )
1606 .unwrap();
1607
1608 repo.supersede("Better approach", 2).unwrap();
1609
1610 let old_content =
1611 fs::read_to_string(repo.adr_path().join("0002-original-approach.md")).unwrap();
1612
1613 assert!(old_content.contains("```rust"));
1615 assert!(old_content.contains("fn important_code()"));
1616 assert!(old_content.contains("| Criteria | Score |"));
1617 assert!(old_content.contains("[links](https://example.com)"));
1618
1619 assert!(old_content.contains("status: superseded"));
1621 assert!(old_content.contains("target: 3"));
1622 }
1623
1624 #[test]
1625 fn test_set_status_legacy_preserves_sections() {
1626 let temp = TempDir::new().unwrap();
1627 let repo = Repository::init(temp.path(), None, false).unwrap();
1628
1629 let legacy_content = r#"# 2. Use Rust for backend
1630
1631Date: 2026-01-15
1632
1633## Status
1634
1635Proposed
1636
1637## Context
1638
1639We need a fast, safe language for our backend services.
1640
1641See the [Rust book](https://doc.rust-lang.org/book/) for details.
1642
1643## Decision
1644
1645We will use **Rust** with the `tokio` runtime.
1646
1647```toml
1648[dependencies]
1649tokio = { version = "1", features = ["full"] }
1650```
1651
1652## Consequences
1653
1654- Type safety prevents many bugs at compile time
1655- Learning curve for team members
1656"#;
1657 let adr_path = repo.adr_path().join("0002-use-rust-for-backend.md");
1658 fs::write(&adr_path, legacy_content).unwrap();
1659
1660 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1661
1662 let result = fs::read_to_string(&adr_path).unwrap();
1663
1664 assert!(result.contains("Accepted"));
1666
1667 assert!(result.contains("[Rust book](https://doc.rust-lang.org/book/)"));
1669 assert!(result.contains("**Rust**"));
1670 assert!(result.contains("`tokio`"));
1671 assert!(result.contains("```toml"));
1672 assert!(result.contains("tokio = { version = \"1\", features = [\"full\"] }"));
1673 assert!(result.contains("Type safety prevents many bugs"));
1674 }
1675
1676 #[test]
1677 fn test_set_status_frontmatter_with_existing_links() {
1678 let temp = TempDir::new().unwrap();
1679 let repo = Repository::init(temp.path(), None, true).unwrap();
1680
1681 let content = r#"---
1682number: 2
1683title: Updated approach
1684date: 2026-01-15
1685status: proposed
1686links:
1687 - target: 1
1688 kind: amends
1689---
1690
1691## Context
1692
1693Context.
1694
1695## Decision
1696
1697Decision.
1698"#;
1699 let adr_path = repo.adr_path().join("0002-updated-approach.md");
1700 fs::write(&adr_path, content).unwrap();
1701
1702 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1704
1705 let result = fs::read_to_string(&adr_path).unwrap();
1706 assert!(result.contains("status: accepted"));
1707 assert!(result.contains("links:"));
1708 assert!(result.contains("target: 1"));
1709 assert!(result.contains("kind: amends"));
1710 assert!(
1712 !result.contains("\n\n---"),
1713 "Should not have extra blank line before closing ---: {:?}",
1714 result
1715 );
1716 }
1717
1718 #[test]
1719 fn test_set_status_no_extra_newline_before_separator() {
1720 let temp = TempDir::new().unwrap();
1721 let repo = Repository::init(temp.path(), None, true).unwrap();
1722
1723 let content = "---\nnumber: 2\ntitle: Test\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
1724 let adr_path = repo.adr_path().join("0002-test.md");
1725 fs::write(&adr_path, content).unwrap();
1726
1727 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1728
1729 let result = fs::read_to_string(&adr_path).unwrap();
1730 assert!(result.contains("status: accepted"));
1731 assert!(
1733 result.contains("\n---\n"),
1734 "Should have clean closing separator: {:?}",
1735 result
1736 );
1737 assert!(
1738 !result.contains("\n\n---"),
1739 "Should not have extra blank line before closing ---: {:?}",
1740 result
1741 );
1742 }
1743
1744 #[test]
1745 fn test_update_metadata_adds_tags_to_frontmatter() {
1746 let temp = TempDir::new().unwrap();
1747 let repo = Repository::init(temp.path(), None, true).unwrap();
1748
1749 let content = r#"---
1750number: 2
1751title: Tagged ADR
1752date: 2026-01-15
1753status: proposed
1754---
1755
1756## Context
1757
1758Context.
1759"#;
1760 let adr_path = repo.adr_path().join("0002-tagged-adr.md");
1761 fs::write(&adr_path, content).unwrap();
1762
1763 let mut adr = repo.get(2).unwrap();
1764 adr.set_tags(vec!["security".into(), "api".into()]);
1765 repo.update_metadata(&adr).unwrap();
1766
1767 let result = fs::read_to_string(&adr_path).unwrap();
1768 assert!(result.contains("tags:"));
1769 assert!(result.contains(" - security"));
1770 assert!(result.contains(" - api"));
1771 assert!(result.contains("## Context\n\nContext."));
1773 }
1774}