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