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 std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use walkdir::WalkDir;
13
14#[derive(Debug)]
16pub struct Repository {
17 root: PathBuf,
19
20 config: Config,
22
23 parser: Parser,
25
26 template_engine: TemplateEngine,
28}
29
30impl Repository {
31 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
33 let root = root.into();
34 let config = Config::load(&root)?;
35
36 Ok(Self {
37 root,
38 config,
39 parser: Parser::new(),
40 template_engine: TemplateEngine::new(),
41 })
42 }
43
44 pub fn open_or_default(root: impl Into<PathBuf>) -> Self {
46 let root = root.into();
47 let config = Config::load_or_default(&root);
48
49 Self {
50 root,
51 config,
52 parser: Parser::new(),
53 template_engine: TemplateEngine::new(),
54 }
55 }
56
57 pub fn init(root: impl Into<PathBuf>, adr_dir: Option<PathBuf>, ng: bool) -> Result<Self> {
59 let root = root.into();
60 let adr_dir = adr_dir.unwrap_or_else(|| PathBuf::from(crate::config::DEFAULT_ADR_DIR));
61 let adr_path = root.join(&adr_dir);
62
63 let existing_adrs = if adr_path.exists() {
65 count_existing_adrs(&adr_path)
66 } else {
67 fs::create_dir_all(&adr_path)?;
69 0
70 };
71
72 let config = Config {
74 adr_dir,
75 mode: if ng {
76 ConfigMode::NextGen
77 } else {
78 ConfigMode::Compatible
79 },
80 ..Default::default()
81 };
82 config.save(&root)?;
83
84 let repo = Self {
85 root,
86 config,
87 parser: Parser::new(),
88 template_engine: TemplateEngine::new(),
89 };
90
91 if existing_adrs == 0 {
93 let mut adr = Adr::new(1, "Record architecture decisions");
94 adr.status = AdrStatus::Accepted;
95 adr.context =
96 "We need to record the architectural decisions made on this project.".into();
97 adr.decision = "We will use Architecture Decision Records, as described by Michael Nygard in his article \"Documenting Architecture Decisions\".".into();
98 adr.consequences = "See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's adr-tools.".into();
99 repo.create(&adr)?;
100 }
101
102 Ok(repo)
103 }
104
105 pub fn root(&self) -> &Path {
107 &self.root
108 }
109
110 pub fn config(&self) -> &Config {
112 &self.config
113 }
114
115 pub fn adr_path(&self) -> PathBuf {
117 self.config.adr_path(&self.root)
118 }
119
120 pub fn with_template_format(mut self, format: TemplateFormat) -> Self {
122 self.template_engine = self.template_engine.with_format(format);
123 self
124 }
125
126 pub fn with_template_variant(mut self, variant: TemplateVariant) -> Self {
128 self.template_engine = self.template_engine.with_variant(variant);
129 self
130 }
131
132 pub fn with_custom_template(mut self, template: Template) -> Self {
134 self.template_engine = self.template_engine.with_custom_template(template);
135 self
136 }
137
138 pub fn list(&self) -> Result<Vec<Adr>> {
140 let adr_path = self.adr_path();
141 if !adr_path.exists() {
142 return Err(Error::AdrDirNotFound);
143 }
144
145 let mut adrs: Vec<Adr> = WalkDir::new(&adr_path)
146 .max_depth(1)
147 .into_iter()
148 .filter_map(|e| e.ok())
149 .filter(|e| {
150 e.path().extension().is_some_and(|ext| ext == "md")
151 && e.path()
152 .file_name()
153 .and_then(|n| n.to_str())
154 .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
155 })
156 .filter_map(|e| self.parser.parse_file(e.path()).ok())
157 .collect();
158
159 adrs.sort_by_key(|a| a.number);
160 Ok(adrs)
161 }
162
163 pub fn next_number(&self) -> Result<u32> {
165 let adrs = self.list()?;
166 Ok(adrs.last().map(|a| a.number + 1).unwrap_or(1))
167 }
168
169 pub fn get(&self, number: u32) -> Result<Adr> {
171 let adrs = self.list()?;
172 adrs.into_iter()
173 .find(|a| a.number == number)
174 .ok_or_else(|| Error::AdrNotFound(number.to_string()))
175 }
176
177 pub fn find(&self, query: &str) -> Result<Adr> {
179 if let Ok(number) = query.parse::<u32>() {
181 return self.get(number);
182 }
183
184 let adrs = self.list()?;
186 let matcher = SkimMatcherV2::default();
187
188 let mut matches: Vec<_> = adrs
189 .into_iter()
190 .filter_map(|adr| {
191 let score = matcher.fuzzy_match(&adr.title, query)?;
192 Some((adr, score))
193 })
194 .collect();
195
196 matches.sort_by(|a, b| b.1.cmp(&a.1));
197
198 match matches.len() {
199 0 => Err(Error::AdrNotFound(query.to_string())),
200 1 => Ok(matches.remove(0).0),
201 _ => {
202 if matches[0].1 > matches[1].1 * 2 {
204 Ok(matches.remove(0).0)
205 } else {
206 Err(Error::AmbiguousAdr {
207 query: query.to_string(),
208 matches: matches
209 .iter()
210 .take(5)
211 .map(|(a, _)| a.title.clone())
212 .collect(),
213 })
214 }
215 }
216 }
217 }
218
219 fn resolve_link_titles(&self, adr: &Adr) -> HashMap<u32, (String, String)> {
221 let mut map = HashMap::new();
222 for link in &adr.links {
223 if map.contains_key(&link.target) {
224 continue;
225 }
226 if let Ok(target_adr) = self.get(link.target) {
227 map.insert(
228 link.target,
229 (target_adr.title.clone(), target_adr.filename()),
230 );
231 }
232 }
233 map
234 }
235
236 pub fn create(&self, adr: &Adr) -> Result<PathBuf> {
238 let path = self.adr_path().join(adr.filename());
239
240 let link_titles = self.resolve_link_titles(adr);
241 let content = self
242 .template_engine
243 .render(adr, &self.config, &link_titles)?;
244 fs::write(&path, content)?;
245
246 Ok(path)
247 }
248
249 pub fn new_adr(&self, title: impl Into<String>) -> Result<(Adr, PathBuf)> {
251 let number = self.next_number()?;
252 let adr = Adr::new(number, title);
253 let path = self.create(&adr)?;
254 Ok((adr, path))
255 }
256
257 pub fn supersede(&self, title: impl Into<String>, superseded: u32) -> Result<(Adr, PathBuf)> {
259 let number = self.next_number()?;
260 let mut adr = Adr::new(number, title);
261 adr.add_link(AdrLink::new(superseded, LinkKind::Supersedes));
262
263 let path = self.create(&adr)?;
266
267 let mut old_adr = self.get(superseded)?;
270 old_adr.status = AdrStatus::Superseded;
271 old_adr.add_link(AdrLink::new(number, LinkKind::SupersededBy));
272 self.update(&old_adr)?;
273
274 Ok((adr, path))
275 }
276
277 pub fn set_status(
282 &self,
283 number: u32,
284 status: AdrStatus,
285 superseded_by: Option<u32>,
286 ) -> Result<PathBuf> {
287 let mut adr = self.get(number)?;
288 adr.status = status.clone();
289
290 if let (AdrStatus::Superseded, Some(by)) = (&status, superseded_by) {
292 let _ = self.get(by)?;
294
295 if !adr
297 .links
298 .iter()
299 .any(|l| matches!(l.kind, LinkKind::SupersededBy) && l.target == by)
300 {
301 adr.add_link(AdrLink::new(by, LinkKind::SupersededBy));
302 }
303 }
304
305 self.update(&adr)
306 }
307
308 pub fn link(
310 &self,
311 source: u32,
312 target: u32,
313 source_kind: LinkKind,
314 target_kind: LinkKind,
315 ) -> Result<()> {
316 let mut source_adr = self.get(source)?;
317 let mut target_adr = self.get(target)?;
318
319 source_adr.add_link(AdrLink::new(target, source_kind));
320 target_adr.add_link(AdrLink::new(source, target_kind));
321
322 self.update(&source_adr)?;
323 self.update(&target_adr)?;
324
325 Ok(())
326 }
327
328 pub fn update(&self, adr: &Adr) -> Result<PathBuf> {
330 let path = adr
331 .path
332 .clone()
333 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
334
335 let link_titles = self.resolve_link_titles(adr);
336 let content = self
337 .template_engine
338 .render(adr, &self.config, &link_titles)?;
339 fs::write(&path, content)?;
340
341 Ok(path)
342 }
343
344 pub fn read_content(&self, adr: &Adr) -> Result<String> {
346 let path = adr
347 .path
348 .as_ref()
349 .cloned()
350 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
351
352 Ok(fs::read_to_string(path)?)
353 }
354
355 pub fn write_content(&self, adr: &Adr, content: &str) -> Result<PathBuf> {
357 let path = adr
358 .path
359 .as_ref()
360 .cloned()
361 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
362
363 fs::write(&path, content)?;
364 Ok(path)
365 }
366}
367
368fn count_existing_adrs(path: &Path) -> usize {
370 if !path.is_dir() {
371 return 0;
372 }
373
374 fs::read_dir(path)
375 .map(|entries| {
376 entries
377 .filter_map(|e| e.ok())
378 .filter(|e| {
379 let path = e.path();
380 path.is_file()
381 && path.extension().is_some_and(|ext| ext == "md")
382 && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
383 n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
385 })
386 })
387 .count()
388 })
389 .unwrap_or(0)
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use tempfile::TempDir;
396
397 #[test]
400 fn test_init_repository() {
401 let temp = TempDir::new().unwrap();
402 let repo = Repository::init(temp.path(), None, false).unwrap();
403
404 assert!(repo.adr_path().exists());
405 assert!(temp.path().join(".adr-dir").exists());
406
407 let adrs = repo.list().unwrap();
408 assert_eq!(adrs.len(), 1);
409 assert_eq!(adrs[0].number, 1);
410 assert_eq!(adrs[0].title, "Record architecture decisions");
411 }
412
413 #[test]
414 fn test_init_repository_ng() {
415 let temp = TempDir::new().unwrap();
416 let repo = Repository::init(temp.path(), None, true).unwrap();
417
418 assert!(temp.path().join("adrs.toml").exists());
419 assert!(repo.config().is_next_gen());
420 }
421
422 #[test]
423 fn test_init_repository_custom_dir() {
424 let temp = TempDir::new().unwrap();
425 let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
426
427 assert!(temp.path().join("decisions").exists());
428 assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
429 }
430
431 #[test]
432 fn test_init_repository_nested_dir() {
433 let temp = TempDir::new().unwrap();
434 let _repo =
435 Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
436
437 assert!(temp.path().join("docs/architecture/adr").exists());
438 }
439
440 #[test]
441 fn test_init_repository_already_exists_skips_initial_adr() {
442 let temp = TempDir::new().unwrap();
443 Repository::init(temp.path(), None, false).unwrap();
444
445 let repo = Repository::init(temp.path(), None, false).unwrap();
447 let adrs = repo.list().unwrap();
448 assert_eq!(adrs.len(), 1); }
450
451 #[test]
452 fn test_init_with_existing_adrs_skips_initial() {
453 let temp = TempDir::new().unwrap();
454 let adr_dir = temp.path().join("doc/adr");
455 fs::create_dir_all(&adr_dir).unwrap();
456
457 fs::write(
459 adr_dir.join("0001-existing-decision.md"),
460 "# 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",
461 )
462 .unwrap();
463 fs::write(
464 adr_dir.join("0002-another-decision.md"),
465 "# 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",
466 )
467 .unwrap();
468
469 let repo = Repository::init(temp.path(), None, false).unwrap();
471 let adrs = repo.list().unwrap();
472 assert_eq!(adrs.len(), 2); assert_eq!(adrs[0].title, "Existing Decision");
474 assert_eq!(adrs[1].title, "Another Decision");
475 }
476
477 #[test]
478 fn test_init_creates_first_adr() {
479 let temp = TempDir::new().unwrap();
480 let repo = Repository::init(temp.path(), None, false).unwrap();
481
482 let adr = repo.get(1).unwrap();
483 assert_eq!(adr.title, "Record architecture decisions");
484 assert_eq!(adr.status, AdrStatus::Accepted);
485 assert!(!adr.context.is_empty());
486 assert!(!adr.decision.is_empty());
487 assert!(!adr.consequences.is_empty());
488 }
489
490 #[test]
493 fn test_open_repository() {
494 let temp = TempDir::new().unwrap();
495 Repository::init(temp.path(), None, false).unwrap();
496
497 let repo = Repository::open(temp.path()).unwrap();
498 assert_eq!(repo.list().unwrap().len(), 1);
499 }
500
501 #[test]
502 fn test_open_repository_not_found() {
503 let temp = TempDir::new().unwrap();
504 let result = Repository::open(temp.path());
505 assert!(result.is_err());
506 }
507
508 #[test]
509 fn test_open_or_default() {
510 let temp = TempDir::new().unwrap();
511 let repo = Repository::open_or_default(temp.path());
512 assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
513 }
514
515 #[test]
516 fn test_open_or_default_existing() {
517 let temp = TempDir::new().unwrap();
518 Repository::init(temp.path(), Some("custom".into()), false).unwrap();
519
520 let repo = Repository::open_or_default(temp.path());
521 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
522 }
523
524 #[test]
527 fn test_create_and_list() {
528 let temp = TempDir::new().unwrap();
529 let repo = Repository::init(temp.path(), None, false).unwrap();
530
531 let (adr, _) = repo.new_adr("Use Rust").unwrap();
532 assert_eq!(adr.number, 2);
533
534 let adrs = repo.list().unwrap();
535 assert_eq!(adrs.len(), 2);
536 }
537
538 #[test]
539 fn test_create_multiple() {
540 let temp = TempDir::new().unwrap();
541 let repo = Repository::init(temp.path(), None, false).unwrap();
542
543 repo.new_adr("Second").unwrap();
544 repo.new_adr("Third").unwrap();
545 repo.new_adr("Fourth").unwrap();
546
547 let adrs = repo.list().unwrap();
548 assert_eq!(adrs.len(), 4);
549 assert_eq!(adrs[0].number, 1);
550 assert_eq!(adrs[1].number, 2);
551 assert_eq!(adrs[2].number, 3);
552 assert_eq!(adrs[3].number, 4);
553 }
554
555 #[test]
556 fn test_list_sorted_by_number() {
557 let temp = TempDir::new().unwrap();
558 let repo = Repository::init(temp.path(), None, false).unwrap();
559
560 repo.new_adr("B").unwrap();
561 repo.new_adr("A").unwrap();
562 repo.new_adr("C").unwrap();
563
564 let adrs = repo.list().unwrap();
565 assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
566 }
567
568 #[test]
569 fn test_next_number() {
570 let temp = TempDir::new().unwrap();
571 let repo = Repository::init(temp.path(), None, false).unwrap();
572
573 assert_eq!(repo.next_number().unwrap(), 2);
574
575 repo.new_adr("Second").unwrap();
576 assert_eq!(repo.next_number().unwrap(), 3);
577 }
578
579 #[test]
580 fn test_create_file_exists() {
581 let temp = TempDir::new().unwrap();
582 let repo = Repository::init(temp.path(), None, false).unwrap();
583
584 let (_, path) = repo.new_adr("Test ADR").unwrap();
585 assert!(path.exists());
586 assert!(path.to_string_lossy().contains("0002-test-adr.md"));
587 }
588
589 #[test]
592 fn test_get_by_number() {
593 let temp = TempDir::new().unwrap();
594 let repo = Repository::init(temp.path(), None, false).unwrap();
595 repo.new_adr("Second").unwrap();
596
597 let adr = repo.get(2).unwrap();
598 assert_eq!(adr.title, "Second");
599 }
600
601 #[test]
602 fn test_get_not_found() {
603 let temp = TempDir::new().unwrap();
604 let repo = Repository::init(temp.path(), None, false).unwrap();
605
606 let result = repo.get(99);
607 assert!(result.is_err());
608 }
609
610 #[test]
611 fn test_find_by_number() {
612 let temp = TempDir::new().unwrap();
613 let repo = Repository::init(temp.path(), None, false).unwrap();
614
615 let adr = repo.find("1").unwrap();
616 assert_eq!(adr.number, 1);
617 }
618
619 #[test]
620 fn test_find_by_title() {
621 let temp = TempDir::new().unwrap();
622 let repo = Repository::init(temp.path(), None, false).unwrap();
623
624 let adr = repo.find("architecture").unwrap();
625 assert_eq!(adr.number, 1);
626 }
627
628 #[test]
629 fn test_find_fuzzy_match() {
630 let temp = TempDir::new().unwrap();
631 let repo = Repository::init(temp.path(), None, false).unwrap();
632 repo.new_adr("Use PostgreSQL for database").unwrap();
633 repo.new_adr("Use Redis for caching").unwrap();
634
635 let adr = repo.find("postgres").unwrap();
636 assert!(adr.title.contains("PostgreSQL"));
637 }
638
639 #[test]
640 fn test_find_not_found() {
641 let temp = TempDir::new().unwrap();
642 let repo = Repository::init(temp.path(), None, false).unwrap();
643
644 let result = repo.find("nonexistent");
645 assert!(result.is_err());
646 }
647
648 #[test]
651 fn test_supersede() {
652 let temp = TempDir::new().unwrap();
653 let repo = Repository::init(temp.path(), None, false).unwrap();
654
655 let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
656 assert_eq!(new_adr.number, 2);
657 assert_eq!(new_adr.links.len(), 1);
658 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
659
660 let old_adr = repo.get(1).unwrap();
661 assert_eq!(old_adr.status, AdrStatus::Superseded);
662 }
663
664 #[test]
665 fn test_supersede_creates_bidirectional_links() {
666 let temp = TempDir::new().unwrap();
667 let repo = Repository::init(temp.path(), None, false).unwrap();
668
669 repo.supersede("New approach", 1).unwrap();
670
671 let old_adr = repo.get(1).unwrap();
672 assert_eq!(old_adr.links.len(), 1);
673 assert_eq!(old_adr.links[0].target, 2);
674 assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
675
676 let new_adr = repo.get(2).unwrap();
677 assert_eq!(new_adr.links.len(), 1);
678 assert_eq!(new_adr.links[0].target, 1);
679 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
680 }
681
682 #[test]
683 fn test_supersede_not_found() {
684 let temp = TempDir::new().unwrap();
685 let repo = Repository::init(temp.path(), None, false).unwrap();
686
687 let result = repo.supersede("New", 99);
688 assert!(result.is_err());
689 }
690
691 #[test]
694 fn test_supersede_generates_functional_links() {
695 let temp = TempDir::new().unwrap();
696 let repo = Repository::init(temp.path(), None, false).unwrap();
697
698 repo.new_adr("Use MySQL for persistence").unwrap();
700 repo.supersede("Use PostgreSQL instead", 2).unwrap();
701
702 let new_content =
704 fs::read_to_string(repo.adr_path().join("0003-use-postgresql-instead.md")).unwrap();
705 assert!(
706 new_content.contains(
707 "Supersedes [2. Use MySQL for persistence](0002-use-mysql-for-persistence.md)"
708 ),
709 "New ADR should have functional Supersedes link. Got:\n{new_content}"
710 );
711
712 let old_content =
714 fs::read_to_string(repo.adr_path().join("0002-use-mysql-for-persistence.md")).unwrap();
715 assert!(
716 old_content.contains(
717 "Superseded by [3. Use PostgreSQL instead](0003-use-postgresql-instead.md)"
718 ),
719 "Old ADR should have functional Superseded by link. Got:\n{old_content}"
720 );
721 }
722
723 #[test]
724 fn test_link_generates_functional_links() {
725 let temp = TempDir::new().unwrap();
726 let repo = Repository::init(temp.path(), None, false).unwrap();
727
728 repo.new_adr("Use REST API").unwrap();
729 repo.new_adr("Use JSON for API responses").unwrap();
730
731 repo.link(3, 2, LinkKind::Amends, LinkKind::AmendedBy)
732 .unwrap();
733
734 let source_content =
736 fs::read_to_string(repo.adr_path().join("0003-use-json-for-api-responses.md")).unwrap();
737 assert!(
738 source_content.contains("Amends [2. Use REST API](0002-use-rest-api.md)"),
739 "Source ADR should have functional Amends link. Got:\n{source_content}"
740 );
741
742 let target_content =
744 fs::read_to_string(repo.adr_path().join("0002-use-rest-api.md")).unwrap();
745 assert!(
746 target_content.contains(
747 "Amended by [3. Use JSON for API responses](0003-use-json-for-api-responses.md)"
748 ),
749 "Target ADR should have functional Amended by link. Got:\n{target_content}"
750 );
751 }
752
753 #[test]
754 fn test_set_status_superseded_generates_functional_link() {
755 let temp = TempDir::new().unwrap();
756 let repo = Repository::init(temp.path(), None, false).unwrap();
757
758 repo.new_adr("First Decision").unwrap();
759 repo.new_adr("Second Decision").unwrap();
760
761 repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
762
763 let content = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
764 assert!(
765 content.contains("Superseded by [3. Second Decision](0003-second-decision.md)"),
766 "ADR should have functional Superseded by link. Got:\n{content}"
767 );
768 }
769
770 #[test]
771 fn test_supersede_chain_generates_functional_links() {
772 let temp = TempDir::new().unwrap();
773 let repo = Repository::init(temp.path(), None, false).unwrap();
774
775 repo.new_adr("Use SQLite").unwrap();
778 repo.supersede("Use PostgreSQL", 2).unwrap();
780 repo.supersede("Use CockroachDB", 3).unwrap();
782
783 let adr3_content =
785 fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
786 assert!(
787 adr3_content.contains("Supersedes [2. Use SQLite](0002-use-sqlite.md)"),
788 "ADR 3 should supersede ADR 2. Got:\n{adr3_content}"
789 );
790 assert!(
791 adr3_content.contains("Superseded by [4. Use CockroachDB](0004-use-cockroachdb.md)"),
792 "ADR 3 should be superseded by ADR 4. Got:\n{adr3_content}"
793 );
794 }
795
796 #[test]
797 fn test_ng_mode_supersede_generates_functional_links() {
798 let temp = TempDir::new().unwrap();
799 let repo = Repository::init(temp.path(), None, true).unwrap();
800
801 repo.new_adr("Use MySQL").unwrap();
802 repo.supersede("Use PostgreSQL", 2).unwrap();
803
804 let new_content =
806 fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
807
808 assert!(
810 new_content.contains("Supersedes [2. Use MySQL](0002-use-mysql.md)"),
811 "NG mode should have functional link in body. Got:\n{new_content}"
812 );
813 assert!(new_content.contains("links:"));
815 assert!(new_content.contains("target: 2"));
816 }
817
818 #[test]
821 fn test_set_status_accepted() {
822 let temp = TempDir::new().unwrap();
823 let repo = Repository::init(temp.path(), None, false).unwrap();
824 repo.new_adr("Test Decision").unwrap();
825
826 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
827
828 let adr = repo.get(2).unwrap();
829 assert_eq!(adr.status, AdrStatus::Accepted);
830 }
831
832 #[test]
833 fn test_set_status_deprecated() {
834 let temp = TempDir::new().unwrap();
835 let repo = Repository::init(temp.path(), None, false).unwrap();
836 repo.new_adr("Old Decision").unwrap();
837
838 repo.set_status(2, AdrStatus::Deprecated, None).unwrap();
839
840 let adr = repo.get(2).unwrap();
841 assert_eq!(adr.status, AdrStatus::Deprecated);
842 }
843
844 #[test]
845 fn test_set_status_superseded_with_link() {
846 let temp = TempDir::new().unwrap();
847 let repo = Repository::init(temp.path(), None, false).unwrap();
848 repo.new_adr("First Decision").unwrap();
849 repo.new_adr("Second Decision").unwrap();
850
851 repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
852
853 let adr = repo.get(2).unwrap();
854 assert_eq!(adr.status, AdrStatus::Superseded);
855 assert_eq!(adr.links.len(), 1);
856 assert_eq!(adr.links[0].target, 3);
857 assert_eq!(adr.links[0].kind, LinkKind::SupersededBy);
858 }
859
860 #[test]
861 fn test_set_status_superseded_without_link() {
862 let temp = TempDir::new().unwrap();
863 let repo = Repository::init(temp.path(), None, false).unwrap();
864 repo.new_adr("Decision").unwrap();
865
866 repo.set_status(2, AdrStatus::Superseded, None).unwrap();
867
868 let adr = repo.get(2).unwrap();
869 assert_eq!(adr.status, AdrStatus::Superseded);
870 assert_eq!(adr.links.len(), 0);
871 }
872
873 #[test]
874 fn test_set_status_custom() {
875 let temp = TempDir::new().unwrap();
876 let repo = Repository::init(temp.path(), None, false).unwrap();
877 repo.new_adr("Test Decision").unwrap();
878
879 repo.set_status(2, AdrStatus::Custom("Draft".into()), None)
880 .unwrap();
881
882 let adr = repo.get(2).unwrap();
883 assert_eq!(adr.status, AdrStatus::Custom("Draft".into()));
884 }
885
886 #[test]
887 fn test_set_status_adr_not_found() {
888 let temp = TempDir::new().unwrap();
889 let repo = Repository::init(temp.path(), None, false).unwrap();
890
891 let result = repo.set_status(99, AdrStatus::Accepted, None);
892 assert!(result.is_err());
893 }
894
895 #[test]
896 fn test_set_status_superseded_by_not_found() {
897 let temp = TempDir::new().unwrap();
898 let repo = Repository::init(temp.path(), None, false).unwrap();
899 repo.new_adr("Decision").unwrap();
900
901 let result = repo.set_status(2, AdrStatus::Superseded, Some(99));
902 assert!(result.is_err());
903 }
904
905 #[test]
908 fn test_link_adrs() {
909 let temp = TempDir::new().unwrap();
910 let repo = Repository::init(temp.path(), None, false).unwrap();
911 repo.new_adr("Second").unwrap();
912
913 repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
914 .unwrap();
915
916 let adr1 = repo.get(1).unwrap();
917 assert_eq!(adr1.links.len(), 1);
918 assert_eq!(adr1.links[0].target, 2);
919 assert_eq!(adr1.links[0].kind, LinkKind::Amends);
920
921 let adr2 = repo.get(2).unwrap();
922 assert_eq!(adr2.links.len(), 1);
923 assert_eq!(adr2.links[0].target, 1);
924 assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
925 }
926
927 #[test]
928 fn test_link_relates_to() {
929 let temp = TempDir::new().unwrap();
930 let repo = Repository::init(temp.path(), None, false).unwrap();
931 repo.new_adr("Second").unwrap();
932
933 repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
934 .unwrap();
935
936 let adr1 = repo.get(1).unwrap();
937 assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
938
939 let adr2 = repo.get(2).unwrap();
940 assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
941 }
942
943 #[test]
946 fn test_update_adr() {
947 let temp = TempDir::new().unwrap();
948 let repo = Repository::init(temp.path(), None, false).unwrap();
949
950 let mut adr = repo.get(1).unwrap();
951 adr.status = AdrStatus::Deprecated;
952
953 repo.update(&adr).unwrap();
954
955 let updated = repo.get(1).unwrap();
956 assert_eq!(updated.status, AdrStatus::Deprecated);
957 }
958
959 #[test]
960 fn test_update_preserves_content() {
961 let temp = TempDir::new().unwrap();
962 let repo = Repository::init(temp.path(), None, false).unwrap();
963
964 let mut adr = repo.get(1).unwrap();
965 let original_title = adr.title.clone();
966 adr.status = AdrStatus::Deprecated;
967
968 repo.update(&adr).unwrap();
969
970 let updated = repo.get(1).unwrap();
971 assert_eq!(updated.title, original_title);
972 }
973
974 #[test]
977 fn test_read_content() {
978 let temp = TempDir::new().unwrap();
979 let repo = Repository::init(temp.path(), None, false).unwrap();
980
981 let adr = repo.get(1).unwrap();
982 let content = repo.read_content(&adr).unwrap();
983
984 assert!(content.contains("Record architecture decisions"));
985 assert!(content.contains("## Status"));
986 }
987
988 #[test]
989 fn test_write_content() {
990 let temp = TempDir::new().unwrap();
991 let repo = Repository::init(temp.path(), None, false).unwrap();
992
993 let adr = repo.get(1).unwrap();
994 let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
995
996 repo.write_content(&adr, new_content).unwrap();
997
998 let content = repo.read_content(&adr).unwrap();
999 assert!(content.contains("Modified"));
1000 }
1001
1002 #[test]
1005 fn test_with_template_format() {
1006 let temp = TempDir::new().unwrap();
1007 let repo = Repository::init(temp.path(), None, false)
1008 .unwrap()
1009 .with_template_format(TemplateFormat::Madr);
1010
1011 let (_, path) = repo.new_adr("MADR Test").unwrap();
1012 let content = fs::read_to_string(path).unwrap();
1013
1014 assert!(content.contains("Context and Problem Statement"));
1015 }
1016
1017 #[test]
1018 fn test_with_custom_template() {
1019 let temp = TempDir::new().unwrap();
1020 let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
1021 let repo = Repository::init(temp.path(), None, false)
1022 .unwrap()
1023 .with_custom_template(custom);
1024
1025 let (_, path) = repo.new_adr("Custom Test").unwrap();
1026 let content = fs::read_to_string(path).unwrap();
1027
1028 assert_eq!(content, "# ADR 2: Custom Test");
1029 }
1030
1031 #[test]
1034 fn test_root() {
1035 let temp = TempDir::new().unwrap();
1036 let repo = Repository::init(temp.path(), None, false).unwrap();
1037
1038 assert_eq!(repo.root(), temp.path());
1039 }
1040
1041 #[test]
1042 fn test_config() {
1043 let temp = TempDir::new().unwrap();
1044 let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
1045
1046 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
1047 assert!(repo.config().is_next_gen());
1048 }
1049
1050 #[test]
1051 fn test_adr_path() {
1052 let temp = TempDir::new().unwrap();
1053 let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
1054
1055 assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
1056 }
1057
1058 #[test]
1061 fn test_ng_mode_creates_frontmatter() {
1062 let temp = TempDir::new().unwrap();
1063 let repo = Repository::init(temp.path(), None, true).unwrap();
1064
1065 let (_, path) = repo.new_adr("NG Test").unwrap();
1066 let content = fs::read_to_string(path).unwrap();
1067
1068 assert!(content.starts_with("---"));
1069 assert!(content.contains("number: 2"));
1070 assert!(content.contains("title: NG Test"));
1071 }
1072
1073 #[test]
1074 fn test_ng_mode_parses_frontmatter() {
1075 let temp = TempDir::new().unwrap();
1076 let repo = Repository::init(temp.path(), None, true).unwrap();
1077
1078 repo.new_adr("NG ADR").unwrap();
1079
1080 let adr = repo.get(2).unwrap();
1081 assert_eq!(adr.title, "NG ADR");
1082 assert_eq!(adr.number, 2);
1083 }
1084
1085 #[test]
1088 fn test_list_empty_after_init_removal() {
1089 let temp = TempDir::new().unwrap();
1090 let repo = Repository::init(temp.path(), None, false).unwrap();
1091
1092 fs::remove_file(
1094 repo.adr_path()
1095 .join("0001-record-architecture-decisions.md"),
1096 )
1097 .unwrap();
1098
1099 let adrs = repo.list().unwrap();
1100 assert!(adrs.is_empty());
1101 }
1102
1103 #[test]
1104 fn test_list_ignores_non_adr_files() {
1105 let temp = TempDir::new().unwrap();
1106 let repo = Repository::init(temp.path(), None, false).unwrap();
1107
1108 fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
1110 fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
1111
1112 let adrs = repo.list().unwrap();
1113 assert_eq!(adrs.len(), 1); }
1115
1116 #[test]
1117 fn test_special_characters_in_title() {
1118 let temp = TempDir::new().unwrap();
1119 let repo = Repository::init(temp.path(), None, false).unwrap();
1120
1121 let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
1122 assert!(path.exists());
1123 assert_eq!(adr.title, "Use C++ & Rust!");
1124 }
1125}