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::fs;
10use std::path::{Path, PathBuf};
11use walkdir::WalkDir;
12
13#[derive(Debug)]
15pub struct Repository {
16 root: PathBuf,
18
19 config: Config,
21
22 parser: Parser,
24
25 template_engine: TemplateEngine,
27}
28
29impl Repository {
30 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
32 let root = root.into();
33 let config = Config::load(&root)?;
34
35 Ok(Self {
36 root,
37 config,
38 parser: Parser::new(),
39 template_engine: TemplateEngine::new(),
40 })
41 }
42
43 pub fn open_or_default(root: impl Into<PathBuf>) -> Self {
45 let root = root.into();
46 let config = Config::load_or_default(&root);
47
48 Self {
49 root,
50 config,
51 parser: Parser::new(),
52 template_engine: TemplateEngine::new(),
53 }
54 }
55
56 pub fn init(root: impl Into<PathBuf>, adr_dir: Option<PathBuf>, ng: bool) -> Result<Self> {
58 let root = root.into();
59 let adr_dir = adr_dir.unwrap_or_else(|| PathBuf::from(crate::config::DEFAULT_ADR_DIR));
60 let adr_path = root.join(&adr_dir);
61
62 if adr_path.exists() {
64 return Err(Error::AdrDirExists(adr_path));
65 }
66
67 fs::create_dir_all(&adr_path)?;
69
70 let config = Config {
72 adr_dir,
73 mode: if ng {
74 ConfigMode::NextGen
75 } else {
76 ConfigMode::Compatible
77 },
78 ..Default::default()
79 };
80 config.save(&root)?;
81
82 let repo = Self {
83 root,
84 config,
85 parser: Parser::new(),
86 template_engine: TemplateEngine::new(),
87 };
88
89 let mut adr = Adr::new(1, "Record architecture decisions");
91 adr.status = AdrStatus::Accepted;
92 adr.context = "We need to record the architectural decisions made on this project.".into();
93 adr.decision = "We will use Architecture Decision Records, as described by Michael Nygard in his article \"Documenting Architecture Decisions\".".into();
94 adr.consequences = "See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's adr-tools.".into();
95 repo.create(&adr)?;
96
97 Ok(repo)
98 }
99
100 pub fn root(&self) -> &Path {
102 &self.root
103 }
104
105 pub fn config(&self) -> &Config {
107 &self.config
108 }
109
110 pub fn adr_path(&self) -> PathBuf {
112 self.config.adr_path(&self.root)
113 }
114
115 pub fn with_template_format(mut self, format: TemplateFormat) -> Self {
117 self.template_engine = self.template_engine.with_format(format);
118 self
119 }
120
121 pub fn with_template_variant(mut self, variant: TemplateVariant) -> Self {
123 self.template_engine = self.template_engine.with_variant(variant);
124 self
125 }
126
127 pub fn with_custom_template(mut self, template: Template) -> Self {
129 self.template_engine = self.template_engine.with_custom_template(template);
130 self
131 }
132
133 pub fn list(&self) -> Result<Vec<Adr>> {
135 let adr_path = self.adr_path();
136 if !adr_path.exists() {
137 return Err(Error::AdrDirNotFound);
138 }
139
140 let mut adrs: Vec<Adr> = WalkDir::new(&adr_path)
141 .max_depth(1)
142 .into_iter()
143 .filter_map(|e| e.ok())
144 .filter(|e| {
145 e.path().extension().is_some_and(|ext| ext == "md")
146 && e.path()
147 .file_name()
148 .and_then(|n| n.to_str())
149 .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
150 })
151 .filter_map(|e| self.parser.parse_file(e.path()).ok())
152 .collect();
153
154 adrs.sort_by_key(|a| a.number);
155 Ok(adrs)
156 }
157
158 pub fn next_number(&self) -> Result<u32> {
160 let adrs = self.list()?;
161 Ok(adrs.last().map(|a| a.number + 1).unwrap_or(1))
162 }
163
164 pub fn get(&self, number: u32) -> Result<Adr> {
166 let adrs = self.list()?;
167 adrs.into_iter()
168 .find(|a| a.number == number)
169 .ok_or_else(|| Error::AdrNotFound(number.to_string()))
170 }
171
172 pub fn find(&self, query: &str) -> Result<Adr> {
174 if let Ok(number) = query.parse::<u32>() {
176 return self.get(number);
177 }
178
179 let adrs = self.list()?;
181 let matcher = SkimMatcherV2::default();
182
183 let mut matches: Vec<_> = adrs
184 .into_iter()
185 .filter_map(|adr| {
186 let score = matcher.fuzzy_match(&adr.title, query)?;
187 Some((adr, score))
188 })
189 .collect();
190
191 matches.sort_by(|a, b| b.1.cmp(&a.1));
192
193 match matches.len() {
194 0 => Err(Error::AdrNotFound(query.to_string())),
195 1 => Ok(matches.remove(0).0),
196 _ => {
197 if matches[0].1 > matches[1].1 * 2 {
199 Ok(matches.remove(0).0)
200 } else {
201 Err(Error::AmbiguousAdr {
202 query: query.to_string(),
203 matches: matches
204 .iter()
205 .take(5)
206 .map(|(a, _)| a.title.clone())
207 .collect(),
208 })
209 }
210 }
211 }
212 }
213
214 pub fn create(&self, adr: &Adr) -> Result<PathBuf> {
216 let path = self.adr_path().join(adr.filename());
217
218 let content = self.template_engine.render(adr, &self.config)?;
219 fs::write(&path, content)?;
220
221 Ok(path)
222 }
223
224 pub fn new_adr(&self, title: impl Into<String>) -> Result<(Adr, PathBuf)> {
226 let number = self.next_number()?;
227 let adr = Adr::new(number, title);
228 let path = self.create(&adr)?;
229 Ok((adr, path))
230 }
231
232 pub fn supersede(&self, title: impl Into<String>, superseded: u32) -> Result<(Adr, PathBuf)> {
234 let number = self.next_number()?;
235 let mut adr = Adr::new(number, title);
236 adr.add_link(AdrLink::new(superseded, LinkKind::Supersedes));
237
238 let mut old_adr = self.get(superseded)?;
240 old_adr.status = AdrStatus::Superseded;
241 old_adr.add_link(AdrLink::new(number, LinkKind::SupersededBy));
242 self.update(&old_adr)?;
243
244 let path = self.create(&adr)?;
245 Ok((adr, path))
246 }
247
248 pub fn link(
250 &self,
251 source: u32,
252 target: u32,
253 source_kind: LinkKind,
254 target_kind: LinkKind,
255 ) -> Result<()> {
256 let mut source_adr = self.get(source)?;
257 let mut target_adr = self.get(target)?;
258
259 source_adr.add_link(AdrLink::new(target, source_kind));
260 target_adr.add_link(AdrLink::new(source, target_kind));
261
262 self.update(&source_adr)?;
263 self.update(&target_adr)?;
264
265 Ok(())
266 }
267
268 pub fn update(&self, adr: &Adr) -> Result<PathBuf> {
270 let path = adr
271 .path
272 .clone()
273 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
274
275 let content = self.template_engine.render(adr, &self.config)?;
276 fs::write(&path, content)?;
277
278 Ok(path)
279 }
280
281 pub fn read_content(&self, adr: &Adr) -> Result<String> {
283 let path = adr
284 .path
285 .as_ref()
286 .cloned()
287 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
288
289 Ok(fs::read_to_string(path)?)
290 }
291
292 pub fn write_content(&self, adr: &Adr, content: &str) -> Result<PathBuf> {
294 let path = adr
295 .path
296 .as_ref()
297 .cloned()
298 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
299
300 fs::write(&path, content)?;
301 Ok(path)
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use tempfile::TempDir;
309
310 #[test]
313 fn test_init_repository() {
314 let temp = TempDir::new().unwrap();
315 let repo = Repository::init(temp.path(), None, false).unwrap();
316
317 assert!(repo.adr_path().exists());
318 assert!(temp.path().join(".adr-dir").exists());
319
320 let adrs = repo.list().unwrap();
321 assert_eq!(adrs.len(), 1);
322 assert_eq!(adrs[0].number, 1);
323 assert_eq!(adrs[0].title, "Record architecture decisions");
324 }
325
326 #[test]
327 fn test_init_repository_ng() {
328 let temp = TempDir::new().unwrap();
329 let repo = Repository::init(temp.path(), None, true).unwrap();
330
331 assert!(temp.path().join("adrs.toml").exists());
332 assert!(repo.config().is_next_gen());
333 }
334
335 #[test]
336 fn test_init_repository_custom_dir() {
337 let temp = TempDir::new().unwrap();
338 let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
339
340 assert!(temp.path().join("decisions").exists());
341 assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
342 }
343
344 #[test]
345 fn test_init_repository_nested_dir() {
346 let temp = TempDir::new().unwrap();
347 let _repo =
348 Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
349
350 assert!(temp.path().join("docs/architecture/adr").exists());
351 }
352
353 #[test]
354 fn test_init_repository_already_exists() {
355 let temp = TempDir::new().unwrap();
356 Repository::init(temp.path(), None, false).unwrap();
357
358 let result = Repository::init(temp.path(), None, false);
359 assert!(result.is_err());
360 }
361
362 #[test]
363 fn test_init_creates_first_adr() {
364 let temp = TempDir::new().unwrap();
365 let repo = Repository::init(temp.path(), None, false).unwrap();
366
367 let adr = repo.get(1).unwrap();
368 assert_eq!(adr.title, "Record architecture decisions");
369 assert_eq!(adr.status, AdrStatus::Accepted);
370 assert!(!adr.context.is_empty());
371 assert!(!adr.decision.is_empty());
372 assert!(!adr.consequences.is_empty());
373 }
374
375 #[test]
378 fn test_open_repository() {
379 let temp = TempDir::new().unwrap();
380 Repository::init(temp.path(), None, false).unwrap();
381
382 let repo = Repository::open(temp.path()).unwrap();
383 assert_eq!(repo.list().unwrap().len(), 1);
384 }
385
386 #[test]
387 fn test_open_repository_not_found() {
388 let temp = TempDir::new().unwrap();
389 let result = Repository::open(temp.path());
390 assert!(result.is_err());
391 }
392
393 #[test]
394 fn test_open_or_default() {
395 let temp = TempDir::new().unwrap();
396 let repo = Repository::open_or_default(temp.path());
397 assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
398 }
399
400 #[test]
401 fn test_open_or_default_existing() {
402 let temp = TempDir::new().unwrap();
403 Repository::init(temp.path(), Some("custom".into()), false).unwrap();
404
405 let repo = Repository::open_or_default(temp.path());
406 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
407 }
408
409 #[test]
412 fn test_create_and_list() {
413 let temp = TempDir::new().unwrap();
414 let repo = Repository::init(temp.path(), None, false).unwrap();
415
416 let (adr, _) = repo.new_adr("Use Rust").unwrap();
417 assert_eq!(adr.number, 2);
418
419 let adrs = repo.list().unwrap();
420 assert_eq!(adrs.len(), 2);
421 }
422
423 #[test]
424 fn test_create_multiple() {
425 let temp = TempDir::new().unwrap();
426 let repo = Repository::init(temp.path(), None, false).unwrap();
427
428 repo.new_adr("Second").unwrap();
429 repo.new_adr("Third").unwrap();
430 repo.new_adr("Fourth").unwrap();
431
432 let adrs = repo.list().unwrap();
433 assert_eq!(adrs.len(), 4);
434 assert_eq!(adrs[0].number, 1);
435 assert_eq!(adrs[1].number, 2);
436 assert_eq!(adrs[2].number, 3);
437 assert_eq!(adrs[3].number, 4);
438 }
439
440 #[test]
441 fn test_list_sorted_by_number() {
442 let temp = TempDir::new().unwrap();
443 let repo = Repository::init(temp.path(), None, false).unwrap();
444
445 repo.new_adr("B").unwrap();
446 repo.new_adr("A").unwrap();
447 repo.new_adr("C").unwrap();
448
449 let adrs = repo.list().unwrap();
450 assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
451 }
452
453 #[test]
454 fn test_next_number() {
455 let temp = TempDir::new().unwrap();
456 let repo = Repository::init(temp.path(), None, false).unwrap();
457
458 assert_eq!(repo.next_number().unwrap(), 2);
459
460 repo.new_adr("Second").unwrap();
461 assert_eq!(repo.next_number().unwrap(), 3);
462 }
463
464 #[test]
465 fn test_create_file_exists() {
466 let temp = TempDir::new().unwrap();
467 let repo = Repository::init(temp.path(), None, false).unwrap();
468
469 let (_, path) = repo.new_adr("Test ADR").unwrap();
470 assert!(path.exists());
471 assert!(path.to_string_lossy().contains("0002-test-adr.md"));
472 }
473
474 #[test]
477 fn test_get_by_number() {
478 let temp = TempDir::new().unwrap();
479 let repo = Repository::init(temp.path(), None, false).unwrap();
480 repo.new_adr("Second").unwrap();
481
482 let adr = repo.get(2).unwrap();
483 assert_eq!(adr.title, "Second");
484 }
485
486 #[test]
487 fn test_get_not_found() {
488 let temp = TempDir::new().unwrap();
489 let repo = Repository::init(temp.path(), None, false).unwrap();
490
491 let result = repo.get(99);
492 assert!(result.is_err());
493 }
494
495 #[test]
496 fn test_find_by_number() {
497 let temp = TempDir::new().unwrap();
498 let repo = Repository::init(temp.path(), None, false).unwrap();
499
500 let adr = repo.find("1").unwrap();
501 assert_eq!(adr.number, 1);
502 }
503
504 #[test]
505 fn test_find_by_title() {
506 let temp = TempDir::new().unwrap();
507 let repo = Repository::init(temp.path(), None, false).unwrap();
508
509 let adr = repo.find("architecture").unwrap();
510 assert_eq!(adr.number, 1);
511 }
512
513 #[test]
514 fn test_find_fuzzy_match() {
515 let temp = TempDir::new().unwrap();
516 let repo = Repository::init(temp.path(), None, false).unwrap();
517 repo.new_adr("Use PostgreSQL for database").unwrap();
518 repo.new_adr("Use Redis for caching").unwrap();
519
520 let adr = repo.find("postgres").unwrap();
521 assert!(adr.title.contains("PostgreSQL"));
522 }
523
524 #[test]
525 fn test_find_not_found() {
526 let temp = TempDir::new().unwrap();
527 let repo = Repository::init(temp.path(), None, false).unwrap();
528
529 let result = repo.find("nonexistent");
530 assert!(result.is_err());
531 }
532
533 #[test]
536 fn test_supersede() {
537 let temp = TempDir::new().unwrap();
538 let repo = Repository::init(temp.path(), None, false).unwrap();
539
540 let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
541 assert_eq!(new_adr.number, 2);
542 assert_eq!(new_adr.links.len(), 1);
543 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
544
545 let old_adr = repo.get(1).unwrap();
546 assert_eq!(old_adr.status, AdrStatus::Superseded);
547 }
548
549 #[test]
550 fn test_supersede_creates_bidirectional_links() {
551 let temp = TempDir::new().unwrap();
552 let repo = Repository::init(temp.path(), None, false).unwrap();
553
554 repo.supersede("New approach", 1).unwrap();
555
556 let old_adr = repo.get(1).unwrap();
557 assert_eq!(old_adr.links.len(), 1);
558 assert_eq!(old_adr.links[0].target, 2);
559 assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
560
561 let new_adr = repo.get(2).unwrap();
562 assert_eq!(new_adr.links.len(), 1);
563 assert_eq!(new_adr.links[0].target, 1);
564 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
565 }
566
567 #[test]
568 fn test_supersede_not_found() {
569 let temp = TempDir::new().unwrap();
570 let repo = Repository::init(temp.path(), None, false).unwrap();
571
572 let result = repo.supersede("New", 99);
573 assert!(result.is_err());
574 }
575
576 #[test]
579 fn test_link_adrs() {
580 let temp = TempDir::new().unwrap();
581 let repo = Repository::init(temp.path(), None, false).unwrap();
582 repo.new_adr("Second").unwrap();
583
584 repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
585 .unwrap();
586
587 let adr1 = repo.get(1).unwrap();
588 assert_eq!(adr1.links.len(), 1);
589 assert_eq!(adr1.links[0].target, 2);
590 assert_eq!(adr1.links[0].kind, LinkKind::Amends);
591
592 let adr2 = repo.get(2).unwrap();
593 assert_eq!(adr2.links.len(), 1);
594 assert_eq!(adr2.links[0].target, 1);
595 assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
596 }
597
598 #[test]
599 fn test_link_relates_to() {
600 let temp = TempDir::new().unwrap();
601 let repo = Repository::init(temp.path(), None, false).unwrap();
602 repo.new_adr("Second").unwrap();
603
604 repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
605 .unwrap();
606
607 let adr1 = repo.get(1).unwrap();
608 assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
609
610 let adr2 = repo.get(2).unwrap();
611 assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
612 }
613
614 #[test]
617 fn test_update_adr() {
618 let temp = TempDir::new().unwrap();
619 let repo = Repository::init(temp.path(), None, false).unwrap();
620
621 let mut adr = repo.get(1).unwrap();
622 adr.status = AdrStatus::Deprecated;
623
624 repo.update(&adr).unwrap();
625
626 let updated = repo.get(1).unwrap();
627 assert_eq!(updated.status, AdrStatus::Deprecated);
628 }
629
630 #[test]
631 fn test_update_preserves_content() {
632 let temp = TempDir::new().unwrap();
633 let repo = Repository::init(temp.path(), None, false).unwrap();
634
635 let mut adr = repo.get(1).unwrap();
636 let original_title = adr.title.clone();
637 adr.status = AdrStatus::Deprecated;
638
639 repo.update(&adr).unwrap();
640
641 let updated = repo.get(1).unwrap();
642 assert_eq!(updated.title, original_title);
643 }
644
645 #[test]
648 fn test_read_content() {
649 let temp = TempDir::new().unwrap();
650 let repo = Repository::init(temp.path(), None, false).unwrap();
651
652 let adr = repo.get(1).unwrap();
653 let content = repo.read_content(&adr).unwrap();
654
655 assert!(content.contains("Record architecture decisions"));
656 assert!(content.contains("## Status"));
657 }
658
659 #[test]
660 fn test_write_content() {
661 let temp = TempDir::new().unwrap();
662 let repo = Repository::init(temp.path(), None, false).unwrap();
663
664 let adr = repo.get(1).unwrap();
665 let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
666
667 repo.write_content(&adr, new_content).unwrap();
668
669 let content = repo.read_content(&adr).unwrap();
670 assert!(content.contains("Modified"));
671 }
672
673 #[test]
676 fn test_with_template_format() {
677 let temp = TempDir::new().unwrap();
678 let repo = Repository::init(temp.path(), None, false)
679 .unwrap()
680 .with_template_format(TemplateFormat::Madr);
681
682 let (_, path) = repo.new_adr("MADR Test").unwrap();
683 let content = fs::read_to_string(path).unwrap();
684
685 assert!(content.contains("Context and Problem Statement"));
686 }
687
688 #[test]
689 fn test_with_custom_template() {
690 let temp = TempDir::new().unwrap();
691 let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
692 let repo = Repository::init(temp.path(), None, false)
693 .unwrap()
694 .with_custom_template(custom);
695
696 let (_, path) = repo.new_adr("Custom Test").unwrap();
697 let content = fs::read_to_string(path).unwrap();
698
699 assert_eq!(content, "# ADR 2: Custom Test");
700 }
701
702 #[test]
705 fn test_root() {
706 let temp = TempDir::new().unwrap();
707 let repo = Repository::init(temp.path(), None, false).unwrap();
708
709 assert_eq!(repo.root(), temp.path());
710 }
711
712 #[test]
713 fn test_config() {
714 let temp = TempDir::new().unwrap();
715 let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
716
717 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
718 assert!(repo.config().is_next_gen());
719 }
720
721 #[test]
722 fn test_adr_path() {
723 let temp = TempDir::new().unwrap();
724 let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
725
726 assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
727 }
728
729 #[test]
732 fn test_ng_mode_creates_frontmatter() {
733 let temp = TempDir::new().unwrap();
734 let repo = Repository::init(temp.path(), None, true).unwrap();
735
736 let (_, path) = repo.new_adr("NG Test").unwrap();
737 let content = fs::read_to_string(path).unwrap();
738
739 assert!(content.starts_with("---"));
740 assert!(content.contains("number: 2"));
741 assert!(content.contains("title: NG Test"));
742 }
743
744 #[test]
745 fn test_ng_mode_parses_frontmatter() {
746 let temp = TempDir::new().unwrap();
747 let repo = Repository::init(temp.path(), None, true).unwrap();
748
749 repo.new_adr("NG ADR").unwrap();
750
751 let adr = repo.get(2).unwrap();
752 assert_eq!(adr.title, "NG ADR");
753 assert_eq!(adr.number, 2);
754 }
755
756 #[test]
759 fn test_list_empty_after_init_removal() {
760 let temp = TempDir::new().unwrap();
761 let repo = Repository::init(temp.path(), None, false).unwrap();
762
763 fs::remove_file(
765 repo.adr_path()
766 .join("0001-record-architecture-decisions.md"),
767 )
768 .unwrap();
769
770 let adrs = repo.list().unwrap();
771 assert!(adrs.is_empty());
772 }
773
774 #[test]
775 fn test_list_ignores_non_adr_files() {
776 let temp = TempDir::new().unwrap();
777 let repo = Repository::init(temp.path(), None, false).unwrap();
778
779 fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
781 fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
782
783 let adrs = repo.list().unwrap();
784 assert_eq!(adrs.len(), 1); }
786
787 #[test]
788 fn test_special_characters_in_title() {
789 let temp = TempDir::new().unwrap();
790 let repo = Repository::init(temp.path(), None, false).unwrap();
791
792 let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
793 assert!(path.exists());
794 assert_eq!(adr.title, "Use C++ & Rust!");
795 }
796}