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 let existing_adrs = if adr_path.exists() {
64 count_existing_adrs(&adr_path)
65 } else {
66 fs::create_dir_all(&adr_path)?;
68 0
69 };
70
71 let config = Config {
73 adr_dir,
74 mode: if ng {
75 ConfigMode::NextGen
76 } else {
77 ConfigMode::Compatible
78 },
79 ..Default::default()
80 };
81 config.save(&root)?;
82
83 let repo = Self {
84 root,
85 config,
86 parser: Parser::new(),
87 template_engine: TemplateEngine::new(),
88 };
89
90 if existing_adrs == 0 {
92 let mut adr = Adr::new(1, "Record architecture decisions");
93 adr.status = AdrStatus::Accepted;
94 adr.context =
95 "We need to record the architectural decisions made on this project.".into();
96 adr.decision = "We will use Architecture Decision Records, as described by Michael Nygard in his article \"Documenting Architecture Decisions\".".into();
97 adr.consequences = "See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's adr-tools.".into();
98 repo.create(&adr)?;
99 }
100
101 Ok(repo)
102 }
103
104 pub fn root(&self) -> &Path {
106 &self.root
107 }
108
109 pub fn config(&self) -> &Config {
111 &self.config
112 }
113
114 pub fn adr_path(&self) -> PathBuf {
116 self.config.adr_path(&self.root)
117 }
118
119 pub fn with_template_format(mut self, format: TemplateFormat) -> Self {
121 self.template_engine = self.template_engine.with_format(format);
122 self
123 }
124
125 pub fn with_template_variant(mut self, variant: TemplateVariant) -> Self {
127 self.template_engine = self.template_engine.with_variant(variant);
128 self
129 }
130
131 pub fn with_custom_template(mut self, template: Template) -> Self {
133 self.template_engine = self.template_engine.with_custom_template(template);
134 self
135 }
136
137 pub fn list(&self) -> Result<Vec<Adr>> {
139 let adr_path = self.adr_path();
140 if !adr_path.exists() {
141 return Err(Error::AdrDirNotFound);
142 }
143
144 let mut adrs: Vec<Adr> = WalkDir::new(&adr_path)
145 .max_depth(1)
146 .into_iter()
147 .filter_map(|e| e.ok())
148 .filter(|e| {
149 e.path().extension().is_some_and(|ext| ext == "md")
150 && e.path()
151 .file_name()
152 .and_then(|n| n.to_str())
153 .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
154 })
155 .filter_map(|e| self.parser.parse_file(e.path()).ok())
156 .collect();
157
158 adrs.sort_by_key(|a| a.number);
159 Ok(adrs)
160 }
161
162 pub fn next_number(&self) -> Result<u32> {
164 let adrs = self.list()?;
165 Ok(adrs.last().map(|a| a.number + 1).unwrap_or(1))
166 }
167
168 pub fn get(&self, number: u32) -> Result<Adr> {
170 let adrs = self.list()?;
171 adrs.into_iter()
172 .find(|a| a.number == number)
173 .ok_or_else(|| Error::AdrNotFound(number.to_string()))
174 }
175
176 pub fn find(&self, query: &str) -> Result<Adr> {
178 if let Ok(number) = query.parse::<u32>() {
180 return self.get(number);
181 }
182
183 let adrs = self.list()?;
185 let matcher = SkimMatcherV2::default();
186
187 let mut matches: Vec<_> = adrs
188 .into_iter()
189 .filter_map(|adr| {
190 let score = matcher.fuzzy_match(&adr.title, query)?;
191 Some((adr, score))
192 })
193 .collect();
194
195 matches.sort_by(|a, b| b.1.cmp(&a.1));
196
197 match matches.len() {
198 0 => Err(Error::AdrNotFound(query.to_string())),
199 1 => Ok(matches.remove(0).0),
200 _ => {
201 if matches[0].1 > matches[1].1 * 2 {
203 Ok(matches.remove(0).0)
204 } else {
205 Err(Error::AmbiguousAdr {
206 query: query.to_string(),
207 matches: matches
208 .iter()
209 .take(5)
210 .map(|(a, _)| a.title.clone())
211 .collect(),
212 })
213 }
214 }
215 }
216 }
217
218 pub fn create(&self, adr: &Adr) -> Result<PathBuf> {
220 let path = self.adr_path().join(adr.filename());
221
222 let content = self.template_engine.render(adr, &self.config)?;
223 fs::write(&path, content)?;
224
225 Ok(path)
226 }
227
228 pub fn new_adr(&self, title: impl Into<String>) -> Result<(Adr, PathBuf)> {
230 let number = self.next_number()?;
231 let adr = Adr::new(number, title);
232 let path = self.create(&adr)?;
233 Ok((adr, path))
234 }
235
236 pub fn supersede(&self, title: impl Into<String>, superseded: u32) -> Result<(Adr, PathBuf)> {
238 let number = self.next_number()?;
239 let mut adr = Adr::new(number, title);
240 adr.add_link(AdrLink::new(superseded, LinkKind::Supersedes));
241
242 let mut old_adr = self.get(superseded)?;
244 old_adr.status = AdrStatus::Superseded;
245 old_adr.add_link(AdrLink::new(number, LinkKind::SupersededBy));
246 self.update(&old_adr)?;
247
248 let path = self.create(&adr)?;
249 Ok((adr, path))
250 }
251
252 pub fn set_status(
257 &self,
258 number: u32,
259 status: AdrStatus,
260 superseded_by: Option<u32>,
261 ) -> Result<PathBuf> {
262 let mut adr = self.get(number)?;
263 adr.status = status.clone();
264
265 if let (AdrStatus::Superseded, Some(by)) = (&status, superseded_by) {
267 let _ = self.get(by)?;
269
270 if !adr
272 .links
273 .iter()
274 .any(|l| matches!(l.kind, LinkKind::SupersededBy) && l.target == by)
275 {
276 adr.add_link(AdrLink::new(by, LinkKind::SupersededBy));
277 }
278 }
279
280 self.update(&adr)
281 }
282
283 pub fn link(
285 &self,
286 source: u32,
287 target: u32,
288 source_kind: LinkKind,
289 target_kind: LinkKind,
290 ) -> Result<()> {
291 let mut source_adr = self.get(source)?;
292 let mut target_adr = self.get(target)?;
293
294 source_adr.add_link(AdrLink::new(target, source_kind));
295 target_adr.add_link(AdrLink::new(source, target_kind));
296
297 self.update(&source_adr)?;
298 self.update(&target_adr)?;
299
300 Ok(())
301 }
302
303 pub fn update(&self, adr: &Adr) -> Result<PathBuf> {
305 let path = adr
306 .path
307 .clone()
308 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
309
310 let content = self.template_engine.render(adr, &self.config)?;
311 fs::write(&path, content)?;
312
313 Ok(path)
314 }
315
316 pub fn read_content(&self, adr: &Adr) -> Result<String> {
318 let path = adr
319 .path
320 .as_ref()
321 .cloned()
322 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
323
324 Ok(fs::read_to_string(path)?)
325 }
326
327 pub fn write_content(&self, adr: &Adr, content: &str) -> Result<PathBuf> {
329 let path = adr
330 .path
331 .as_ref()
332 .cloned()
333 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
334
335 fs::write(&path, content)?;
336 Ok(path)
337 }
338}
339
340fn count_existing_adrs(path: &Path) -> usize {
342 if !path.is_dir() {
343 return 0;
344 }
345
346 fs::read_dir(path)
347 .map(|entries| {
348 entries
349 .filter_map(|e| e.ok())
350 .filter(|e| {
351 let path = e.path();
352 path.is_file()
353 && path.extension().is_some_and(|ext| ext == "md")
354 && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
355 n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
357 })
358 })
359 .count()
360 })
361 .unwrap_or(0)
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use tempfile::TempDir;
368
369 #[test]
372 fn test_init_repository() {
373 let temp = TempDir::new().unwrap();
374 let repo = Repository::init(temp.path(), None, false).unwrap();
375
376 assert!(repo.adr_path().exists());
377 assert!(temp.path().join(".adr-dir").exists());
378
379 let adrs = repo.list().unwrap();
380 assert_eq!(adrs.len(), 1);
381 assert_eq!(adrs[0].number, 1);
382 assert_eq!(adrs[0].title, "Record architecture decisions");
383 }
384
385 #[test]
386 fn test_init_repository_ng() {
387 let temp = TempDir::new().unwrap();
388 let repo = Repository::init(temp.path(), None, true).unwrap();
389
390 assert!(temp.path().join("adrs.toml").exists());
391 assert!(repo.config().is_next_gen());
392 }
393
394 #[test]
395 fn test_init_repository_custom_dir() {
396 let temp = TempDir::new().unwrap();
397 let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
398
399 assert!(temp.path().join("decisions").exists());
400 assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
401 }
402
403 #[test]
404 fn test_init_repository_nested_dir() {
405 let temp = TempDir::new().unwrap();
406 let _repo =
407 Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
408
409 assert!(temp.path().join("docs/architecture/adr").exists());
410 }
411
412 #[test]
413 fn test_init_repository_already_exists_skips_initial_adr() {
414 let temp = TempDir::new().unwrap();
415 Repository::init(temp.path(), None, false).unwrap();
416
417 let repo = Repository::init(temp.path(), None, false).unwrap();
419 let adrs = repo.list().unwrap();
420 assert_eq!(adrs.len(), 1); }
422
423 #[test]
424 fn test_init_with_existing_adrs_skips_initial() {
425 let temp = TempDir::new().unwrap();
426 let adr_dir = temp.path().join("doc/adr");
427 fs::create_dir_all(&adr_dir).unwrap();
428
429 fs::write(
431 adr_dir.join("0001-existing-decision.md"),
432 "# 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",
433 )
434 .unwrap();
435 fs::write(
436 adr_dir.join("0002-another-decision.md"),
437 "# 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",
438 )
439 .unwrap();
440
441 let repo = Repository::init(temp.path(), None, false).unwrap();
443 let adrs = repo.list().unwrap();
444 assert_eq!(adrs.len(), 2); assert_eq!(adrs[0].title, "Existing Decision");
446 assert_eq!(adrs[1].title, "Another Decision");
447 }
448
449 #[test]
450 fn test_init_creates_first_adr() {
451 let temp = TempDir::new().unwrap();
452 let repo = Repository::init(temp.path(), None, false).unwrap();
453
454 let adr = repo.get(1).unwrap();
455 assert_eq!(adr.title, "Record architecture decisions");
456 assert_eq!(adr.status, AdrStatus::Accepted);
457 assert!(!adr.context.is_empty());
458 assert!(!adr.decision.is_empty());
459 assert!(!adr.consequences.is_empty());
460 }
461
462 #[test]
465 fn test_open_repository() {
466 let temp = TempDir::new().unwrap();
467 Repository::init(temp.path(), None, false).unwrap();
468
469 let repo = Repository::open(temp.path()).unwrap();
470 assert_eq!(repo.list().unwrap().len(), 1);
471 }
472
473 #[test]
474 fn test_open_repository_not_found() {
475 let temp = TempDir::new().unwrap();
476 let result = Repository::open(temp.path());
477 assert!(result.is_err());
478 }
479
480 #[test]
481 fn test_open_or_default() {
482 let temp = TempDir::new().unwrap();
483 let repo = Repository::open_or_default(temp.path());
484 assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
485 }
486
487 #[test]
488 fn test_open_or_default_existing() {
489 let temp = TempDir::new().unwrap();
490 Repository::init(temp.path(), Some("custom".into()), false).unwrap();
491
492 let repo = Repository::open_or_default(temp.path());
493 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
494 }
495
496 #[test]
499 fn test_create_and_list() {
500 let temp = TempDir::new().unwrap();
501 let repo = Repository::init(temp.path(), None, false).unwrap();
502
503 let (adr, _) = repo.new_adr("Use Rust").unwrap();
504 assert_eq!(adr.number, 2);
505
506 let adrs = repo.list().unwrap();
507 assert_eq!(adrs.len(), 2);
508 }
509
510 #[test]
511 fn test_create_multiple() {
512 let temp = TempDir::new().unwrap();
513 let repo = Repository::init(temp.path(), None, false).unwrap();
514
515 repo.new_adr("Second").unwrap();
516 repo.new_adr("Third").unwrap();
517 repo.new_adr("Fourth").unwrap();
518
519 let adrs = repo.list().unwrap();
520 assert_eq!(adrs.len(), 4);
521 assert_eq!(adrs[0].number, 1);
522 assert_eq!(adrs[1].number, 2);
523 assert_eq!(adrs[2].number, 3);
524 assert_eq!(adrs[3].number, 4);
525 }
526
527 #[test]
528 fn test_list_sorted_by_number() {
529 let temp = TempDir::new().unwrap();
530 let repo = Repository::init(temp.path(), None, false).unwrap();
531
532 repo.new_adr("B").unwrap();
533 repo.new_adr("A").unwrap();
534 repo.new_adr("C").unwrap();
535
536 let adrs = repo.list().unwrap();
537 assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
538 }
539
540 #[test]
541 fn test_next_number() {
542 let temp = TempDir::new().unwrap();
543 let repo = Repository::init(temp.path(), None, false).unwrap();
544
545 assert_eq!(repo.next_number().unwrap(), 2);
546
547 repo.new_adr("Second").unwrap();
548 assert_eq!(repo.next_number().unwrap(), 3);
549 }
550
551 #[test]
552 fn test_create_file_exists() {
553 let temp = TempDir::new().unwrap();
554 let repo = Repository::init(temp.path(), None, false).unwrap();
555
556 let (_, path) = repo.new_adr("Test ADR").unwrap();
557 assert!(path.exists());
558 assert!(path.to_string_lossy().contains("0002-test-adr.md"));
559 }
560
561 #[test]
564 fn test_get_by_number() {
565 let temp = TempDir::new().unwrap();
566 let repo = Repository::init(temp.path(), None, false).unwrap();
567 repo.new_adr("Second").unwrap();
568
569 let adr = repo.get(2).unwrap();
570 assert_eq!(adr.title, "Second");
571 }
572
573 #[test]
574 fn test_get_not_found() {
575 let temp = TempDir::new().unwrap();
576 let repo = Repository::init(temp.path(), None, false).unwrap();
577
578 let result = repo.get(99);
579 assert!(result.is_err());
580 }
581
582 #[test]
583 fn test_find_by_number() {
584 let temp = TempDir::new().unwrap();
585 let repo = Repository::init(temp.path(), None, false).unwrap();
586
587 let adr = repo.find("1").unwrap();
588 assert_eq!(adr.number, 1);
589 }
590
591 #[test]
592 fn test_find_by_title() {
593 let temp = TempDir::new().unwrap();
594 let repo = Repository::init(temp.path(), None, false).unwrap();
595
596 let adr = repo.find("architecture").unwrap();
597 assert_eq!(adr.number, 1);
598 }
599
600 #[test]
601 fn test_find_fuzzy_match() {
602 let temp = TempDir::new().unwrap();
603 let repo = Repository::init(temp.path(), None, false).unwrap();
604 repo.new_adr("Use PostgreSQL for database").unwrap();
605 repo.new_adr("Use Redis for caching").unwrap();
606
607 let adr = repo.find("postgres").unwrap();
608 assert!(adr.title.contains("PostgreSQL"));
609 }
610
611 #[test]
612 fn test_find_not_found() {
613 let temp = TempDir::new().unwrap();
614 let repo = Repository::init(temp.path(), None, false).unwrap();
615
616 let result = repo.find("nonexistent");
617 assert!(result.is_err());
618 }
619
620 #[test]
623 fn test_supersede() {
624 let temp = TempDir::new().unwrap();
625 let repo = Repository::init(temp.path(), None, false).unwrap();
626
627 let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
628 assert_eq!(new_adr.number, 2);
629 assert_eq!(new_adr.links.len(), 1);
630 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
631
632 let old_adr = repo.get(1).unwrap();
633 assert_eq!(old_adr.status, AdrStatus::Superseded);
634 }
635
636 #[test]
637 fn test_supersede_creates_bidirectional_links() {
638 let temp = TempDir::new().unwrap();
639 let repo = Repository::init(temp.path(), None, false).unwrap();
640
641 repo.supersede("New approach", 1).unwrap();
642
643 let old_adr = repo.get(1).unwrap();
644 assert_eq!(old_adr.links.len(), 1);
645 assert_eq!(old_adr.links[0].target, 2);
646 assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
647
648 let new_adr = repo.get(2).unwrap();
649 assert_eq!(new_adr.links.len(), 1);
650 assert_eq!(new_adr.links[0].target, 1);
651 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
652 }
653
654 #[test]
655 fn test_supersede_not_found() {
656 let temp = TempDir::new().unwrap();
657 let repo = Repository::init(temp.path(), None, false).unwrap();
658
659 let result = repo.supersede("New", 99);
660 assert!(result.is_err());
661 }
662
663 #[test]
666 fn test_set_status_accepted() {
667 let temp = TempDir::new().unwrap();
668 let repo = Repository::init(temp.path(), None, false).unwrap();
669 repo.new_adr("Test Decision").unwrap();
670
671 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
672
673 let adr = repo.get(2).unwrap();
674 assert_eq!(adr.status, AdrStatus::Accepted);
675 }
676
677 #[test]
678 fn test_set_status_deprecated() {
679 let temp = TempDir::new().unwrap();
680 let repo = Repository::init(temp.path(), None, false).unwrap();
681 repo.new_adr("Old Decision").unwrap();
682
683 repo.set_status(2, AdrStatus::Deprecated, None).unwrap();
684
685 let adr = repo.get(2).unwrap();
686 assert_eq!(adr.status, AdrStatus::Deprecated);
687 }
688
689 #[test]
690 fn test_set_status_superseded_with_link() {
691 let temp = TempDir::new().unwrap();
692 let repo = Repository::init(temp.path(), None, false).unwrap();
693 repo.new_adr("First Decision").unwrap();
694 repo.new_adr("Second Decision").unwrap();
695
696 repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
697
698 let adr = repo.get(2).unwrap();
699 assert_eq!(adr.status, AdrStatus::Superseded);
700 assert_eq!(adr.links.len(), 1);
701 assert_eq!(adr.links[0].target, 3);
702 assert_eq!(adr.links[0].kind, LinkKind::SupersededBy);
703 }
704
705 #[test]
706 fn test_set_status_superseded_without_link() {
707 let temp = TempDir::new().unwrap();
708 let repo = Repository::init(temp.path(), None, false).unwrap();
709 repo.new_adr("Decision").unwrap();
710
711 repo.set_status(2, AdrStatus::Superseded, None).unwrap();
712
713 let adr = repo.get(2).unwrap();
714 assert_eq!(adr.status, AdrStatus::Superseded);
715 assert_eq!(adr.links.len(), 0);
716 }
717
718 #[test]
719 fn test_set_status_custom() {
720 let temp = TempDir::new().unwrap();
721 let repo = Repository::init(temp.path(), None, false).unwrap();
722 repo.new_adr("Test Decision").unwrap();
723
724 repo.set_status(2, AdrStatus::Custom("Draft".into()), None)
725 .unwrap();
726
727 let adr = repo.get(2).unwrap();
728 assert_eq!(adr.status, AdrStatus::Custom("Draft".into()));
729 }
730
731 #[test]
732 fn test_set_status_adr_not_found() {
733 let temp = TempDir::new().unwrap();
734 let repo = Repository::init(temp.path(), None, false).unwrap();
735
736 let result = repo.set_status(99, AdrStatus::Accepted, None);
737 assert!(result.is_err());
738 }
739
740 #[test]
741 fn test_set_status_superseded_by_not_found() {
742 let temp = TempDir::new().unwrap();
743 let repo = Repository::init(temp.path(), None, false).unwrap();
744 repo.new_adr("Decision").unwrap();
745
746 let result = repo.set_status(2, AdrStatus::Superseded, Some(99));
747 assert!(result.is_err());
748 }
749
750 #[test]
753 fn test_link_adrs() {
754 let temp = TempDir::new().unwrap();
755 let repo = Repository::init(temp.path(), None, false).unwrap();
756 repo.new_adr("Second").unwrap();
757
758 repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
759 .unwrap();
760
761 let adr1 = repo.get(1).unwrap();
762 assert_eq!(adr1.links.len(), 1);
763 assert_eq!(adr1.links[0].target, 2);
764 assert_eq!(adr1.links[0].kind, LinkKind::Amends);
765
766 let adr2 = repo.get(2).unwrap();
767 assert_eq!(adr2.links.len(), 1);
768 assert_eq!(adr2.links[0].target, 1);
769 assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
770 }
771
772 #[test]
773 fn test_link_relates_to() {
774 let temp = TempDir::new().unwrap();
775 let repo = Repository::init(temp.path(), None, false).unwrap();
776 repo.new_adr("Second").unwrap();
777
778 repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
779 .unwrap();
780
781 let adr1 = repo.get(1).unwrap();
782 assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
783
784 let adr2 = repo.get(2).unwrap();
785 assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
786 }
787
788 #[test]
791 fn test_update_adr() {
792 let temp = TempDir::new().unwrap();
793 let repo = Repository::init(temp.path(), None, false).unwrap();
794
795 let mut adr = repo.get(1).unwrap();
796 adr.status = AdrStatus::Deprecated;
797
798 repo.update(&adr).unwrap();
799
800 let updated = repo.get(1).unwrap();
801 assert_eq!(updated.status, AdrStatus::Deprecated);
802 }
803
804 #[test]
805 fn test_update_preserves_content() {
806 let temp = TempDir::new().unwrap();
807 let repo = Repository::init(temp.path(), None, false).unwrap();
808
809 let mut adr = repo.get(1).unwrap();
810 let original_title = adr.title.clone();
811 adr.status = AdrStatus::Deprecated;
812
813 repo.update(&adr).unwrap();
814
815 let updated = repo.get(1).unwrap();
816 assert_eq!(updated.title, original_title);
817 }
818
819 #[test]
822 fn test_read_content() {
823 let temp = TempDir::new().unwrap();
824 let repo = Repository::init(temp.path(), None, false).unwrap();
825
826 let adr = repo.get(1).unwrap();
827 let content = repo.read_content(&adr).unwrap();
828
829 assert!(content.contains("Record architecture decisions"));
830 assert!(content.contains("## Status"));
831 }
832
833 #[test]
834 fn test_write_content() {
835 let temp = TempDir::new().unwrap();
836 let repo = Repository::init(temp.path(), None, false).unwrap();
837
838 let adr = repo.get(1).unwrap();
839 let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
840
841 repo.write_content(&adr, new_content).unwrap();
842
843 let content = repo.read_content(&adr).unwrap();
844 assert!(content.contains("Modified"));
845 }
846
847 #[test]
850 fn test_with_template_format() {
851 let temp = TempDir::new().unwrap();
852 let repo = Repository::init(temp.path(), None, false)
853 .unwrap()
854 .with_template_format(TemplateFormat::Madr);
855
856 let (_, path) = repo.new_adr("MADR Test").unwrap();
857 let content = fs::read_to_string(path).unwrap();
858
859 assert!(content.contains("Context and Problem Statement"));
860 }
861
862 #[test]
863 fn test_with_custom_template() {
864 let temp = TempDir::new().unwrap();
865 let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
866 let repo = Repository::init(temp.path(), None, false)
867 .unwrap()
868 .with_custom_template(custom);
869
870 let (_, path) = repo.new_adr("Custom Test").unwrap();
871 let content = fs::read_to_string(path).unwrap();
872
873 assert_eq!(content, "# ADR 2: Custom Test");
874 }
875
876 #[test]
879 fn test_root() {
880 let temp = TempDir::new().unwrap();
881 let repo = Repository::init(temp.path(), None, false).unwrap();
882
883 assert_eq!(repo.root(), temp.path());
884 }
885
886 #[test]
887 fn test_config() {
888 let temp = TempDir::new().unwrap();
889 let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
890
891 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
892 assert!(repo.config().is_next_gen());
893 }
894
895 #[test]
896 fn test_adr_path() {
897 let temp = TempDir::new().unwrap();
898 let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
899
900 assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
901 }
902
903 #[test]
906 fn test_ng_mode_creates_frontmatter() {
907 let temp = TempDir::new().unwrap();
908 let repo = Repository::init(temp.path(), None, true).unwrap();
909
910 let (_, path) = repo.new_adr("NG Test").unwrap();
911 let content = fs::read_to_string(path).unwrap();
912
913 assert!(content.starts_with("---"));
914 assert!(content.contains("number: 2"));
915 assert!(content.contains("title: NG Test"));
916 }
917
918 #[test]
919 fn test_ng_mode_parses_frontmatter() {
920 let temp = TempDir::new().unwrap();
921 let repo = Repository::init(temp.path(), None, true).unwrap();
922
923 repo.new_adr("NG ADR").unwrap();
924
925 let adr = repo.get(2).unwrap();
926 assert_eq!(adr.title, "NG ADR");
927 assert_eq!(adr.number, 2);
928 }
929
930 #[test]
933 fn test_list_empty_after_init_removal() {
934 let temp = TempDir::new().unwrap();
935 let repo = Repository::init(temp.path(), None, false).unwrap();
936
937 fs::remove_file(
939 repo.adr_path()
940 .join("0001-record-architecture-decisions.md"),
941 )
942 .unwrap();
943
944 let adrs = repo.list().unwrap();
945 assert!(adrs.is_empty());
946 }
947
948 #[test]
949 fn test_list_ignores_non_adr_files() {
950 let temp = TempDir::new().unwrap();
951 let repo = Repository::init(temp.path(), None, false).unwrap();
952
953 fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
955 fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
956
957 let adrs = repo.list().unwrap();
958 assert_eq!(adrs.len(), 1); }
960
961 #[test]
962 fn test_special_characters_in_title() {
963 let temp = TempDir::new().unwrap();
964 let repo = Repository::init(temp.path(), None, false).unwrap();
965
966 let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
967 assert!(path.exists());
968 assert_eq!(adr.title, "Use C++ & Rust!");
969 }
970}