Skip to main content

adrs_core/
repository.rs

1//! Repository operations for managing ADRs.
2
3use 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/// A repository of Architecture Decision Records.
14#[derive(Debug)]
15pub struct Repository {
16    /// The root directory of the project.
17    root: PathBuf,
18
19    /// Configuration for this repository.
20    config: Config,
21
22    /// Parser for reading ADRs.
23    parser: Parser,
24
25    /// Template engine for creating ADRs.
26    template_engine: TemplateEngine,
27}
28
29impl Repository {
30    /// Open an existing repository at the given root.
31    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    /// Open a repository, or create default config if not found.
44    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    /// Initialize a new repository at the given root.
57    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        // Check if already initialized
63        if adr_path.exists() {
64            return Err(Error::AdrDirExists(adr_path));
65        }
66
67        // Create the directory
68        fs::create_dir_all(&adr_path)?;
69
70        // Create config
71        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        // Create the initial ADR
90        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    /// Get the repository root path.
101    pub fn root(&self) -> &Path {
102        &self.root
103    }
104
105    /// Get the configuration.
106    pub fn config(&self) -> &Config {
107        &self.config
108    }
109
110    /// Get the full path to the ADR directory.
111    pub fn adr_path(&self) -> PathBuf {
112        self.config.adr_path(&self.root)
113    }
114
115    /// Set the template format.
116    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    /// Set the template variant.
122    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    /// Set a custom template.
128    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    /// List all ADRs in the repository.
134    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    /// Get the next available ADR number.
159    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    /// Find an ADR by number.
165    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    /// Find an ADR by query (number or fuzzy title match).
173    pub fn find(&self, query: &str) -> Result<Adr> {
174        // Try parsing as number first
175        if let Ok(number) = query.parse::<u32>() {
176            return self.get(number);
177        }
178
179        // Fuzzy match on title
180        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 top match is significantly better, use it
198                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    /// Create a new ADR.
215    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    /// Create a new ADR with the given title.
225    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    /// Create a new ADR that supersedes another.
233    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        // Update the superseded ADR
239        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    /// Change the status of an ADR.
249    ///
250    /// If the new status is `Superseded` and `superseded_by` is provided,
251    /// a superseded-by link will be added automatically.
252    pub fn set_status(
253        &self,
254        number: u32,
255        status: AdrStatus,
256        superseded_by: Option<u32>,
257    ) -> Result<PathBuf> {
258        let mut adr = self.get(number)?;
259        adr.status = status.clone();
260
261        // If superseded by another ADR, add the link
262        if let (AdrStatus::Superseded, Some(by)) = (&status, superseded_by) {
263            // Check that the superseding ADR exists
264            let _ = self.get(by)?;
265
266            // Add superseded-by link if not already present
267            if !adr
268                .links
269                .iter()
270                .any(|l| matches!(l.kind, LinkKind::SupersededBy) && l.target == by)
271            {
272                adr.add_link(AdrLink::new(by, LinkKind::SupersededBy));
273            }
274        }
275
276        self.update(&adr)
277    }
278
279    /// Link two ADRs together.
280    pub fn link(
281        &self,
282        source: u32,
283        target: u32,
284        source_kind: LinkKind,
285        target_kind: LinkKind,
286    ) -> Result<()> {
287        let mut source_adr = self.get(source)?;
288        let mut target_adr = self.get(target)?;
289
290        source_adr.add_link(AdrLink::new(target, source_kind));
291        target_adr.add_link(AdrLink::new(source, target_kind));
292
293        self.update(&source_adr)?;
294        self.update(&target_adr)?;
295
296        Ok(())
297    }
298
299    /// Update an existing ADR.
300    pub fn update(&self, adr: &Adr) -> Result<PathBuf> {
301        let path = adr
302            .path
303            .clone()
304            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
305
306        let content = self.template_engine.render(adr, &self.config)?;
307        fs::write(&path, content)?;
308
309        Ok(path)
310    }
311
312    /// Read the content of an ADR file.
313    pub fn read_content(&self, adr: &Adr) -> Result<String> {
314        let path = adr
315            .path
316            .as_ref()
317            .cloned()
318            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
319
320        Ok(fs::read_to_string(path)?)
321    }
322
323    /// Write content to an ADR file.
324    pub fn write_content(&self, adr: &Adr, content: &str) -> Result<PathBuf> {
325        let path = adr
326            .path
327            .as_ref()
328            .cloned()
329            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
330
331        fs::write(&path, content)?;
332        Ok(path)
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use tempfile::TempDir;
340
341    // ========== Initialization Tests ==========
342
343    #[test]
344    fn test_init_repository() {
345        let temp = TempDir::new().unwrap();
346        let repo = Repository::init(temp.path(), None, false).unwrap();
347
348        assert!(repo.adr_path().exists());
349        assert!(temp.path().join(".adr-dir").exists());
350
351        let adrs = repo.list().unwrap();
352        assert_eq!(adrs.len(), 1);
353        assert_eq!(adrs[0].number, 1);
354        assert_eq!(adrs[0].title, "Record architecture decisions");
355    }
356
357    #[test]
358    fn test_init_repository_ng() {
359        let temp = TempDir::new().unwrap();
360        let repo = Repository::init(temp.path(), None, true).unwrap();
361
362        assert!(temp.path().join("adrs.toml").exists());
363        assert!(repo.config().is_next_gen());
364    }
365
366    #[test]
367    fn test_init_repository_custom_dir() {
368        let temp = TempDir::new().unwrap();
369        let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
370
371        assert!(temp.path().join("decisions").exists());
372        assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
373    }
374
375    #[test]
376    fn test_init_repository_nested_dir() {
377        let temp = TempDir::new().unwrap();
378        let _repo =
379            Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
380
381        assert!(temp.path().join("docs/architecture/adr").exists());
382    }
383
384    #[test]
385    fn test_init_repository_already_exists() {
386        let temp = TempDir::new().unwrap();
387        Repository::init(temp.path(), None, false).unwrap();
388
389        let result = Repository::init(temp.path(), None, false);
390        assert!(result.is_err());
391    }
392
393    #[test]
394    fn test_init_creates_first_adr() {
395        let temp = TempDir::new().unwrap();
396        let repo = Repository::init(temp.path(), None, false).unwrap();
397
398        let adr = repo.get(1).unwrap();
399        assert_eq!(adr.title, "Record architecture decisions");
400        assert_eq!(adr.status, AdrStatus::Accepted);
401        assert!(!adr.context.is_empty());
402        assert!(!adr.decision.is_empty());
403        assert!(!adr.consequences.is_empty());
404    }
405
406    // ========== Open Tests ==========
407
408    #[test]
409    fn test_open_repository() {
410        let temp = TempDir::new().unwrap();
411        Repository::init(temp.path(), None, false).unwrap();
412
413        let repo = Repository::open(temp.path()).unwrap();
414        assert_eq!(repo.list().unwrap().len(), 1);
415    }
416
417    #[test]
418    fn test_open_repository_not_found() {
419        let temp = TempDir::new().unwrap();
420        let result = Repository::open(temp.path());
421        assert!(result.is_err());
422    }
423
424    #[test]
425    fn test_open_or_default() {
426        let temp = TempDir::new().unwrap();
427        let repo = Repository::open_or_default(temp.path());
428        assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
429    }
430
431    #[test]
432    fn test_open_or_default_existing() {
433        let temp = TempDir::new().unwrap();
434        Repository::init(temp.path(), Some("custom".into()), false).unwrap();
435
436        let repo = Repository::open_or_default(temp.path());
437        assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
438    }
439
440    // ========== Create and List Tests ==========
441
442    #[test]
443    fn test_create_and_list() {
444        let temp = TempDir::new().unwrap();
445        let repo = Repository::init(temp.path(), None, false).unwrap();
446
447        let (adr, _) = repo.new_adr("Use Rust").unwrap();
448        assert_eq!(adr.number, 2);
449
450        let adrs = repo.list().unwrap();
451        assert_eq!(adrs.len(), 2);
452    }
453
454    #[test]
455    fn test_create_multiple() {
456        let temp = TempDir::new().unwrap();
457        let repo = Repository::init(temp.path(), None, false).unwrap();
458
459        repo.new_adr("Second").unwrap();
460        repo.new_adr("Third").unwrap();
461        repo.new_adr("Fourth").unwrap();
462
463        let adrs = repo.list().unwrap();
464        assert_eq!(adrs.len(), 4);
465        assert_eq!(adrs[0].number, 1);
466        assert_eq!(adrs[1].number, 2);
467        assert_eq!(adrs[2].number, 3);
468        assert_eq!(adrs[3].number, 4);
469    }
470
471    #[test]
472    fn test_list_sorted_by_number() {
473        let temp = TempDir::new().unwrap();
474        let repo = Repository::init(temp.path(), None, false).unwrap();
475
476        repo.new_adr("B").unwrap();
477        repo.new_adr("A").unwrap();
478        repo.new_adr("C").unwrap();
479
480        let adrs = repo.list().unwrap();
481        assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
482    }
483
484    #[test]
485    fn test_next_number() {
486        let temp = TempDir::new().unwrap();
487        let repo = Repository::init(temp.path(), None, false).unwrap();
488
489        assert_eq!(repo.next_number().unwrap(), 2);
490
491        repo.new_adr("Second").unwrap();
492        assert_eq!(repo.next_number().unwrap(), 3);
493    }
494
495    #[test]
496    fn test_create_file_exists() {
497        let temp = TempDir::new().unwrap();
498        let repo = Repository::init(temp.path(), None, false).unwrap();
499
500        let (_, path) = repo.new_adr("Test ADR").unwrap();
501        assert!(path.exists());
502        assert!(path.to_string_lossy().contains("0002-test-adr.md"));
503    }
504
505    // ========== Get and Find Tests ==========
506
507    #[test]
508    fn test_get_by_number() {
509        let temp = TempDir::new().unwrap();
510        let repo = Repository::init(temp.path(), None, false).unwrap();
511        repo.new_adr("Second").unwrap();
512
513        let adr = repo.get(2).unwrap();
514        assert_eq!(adr.title, "Second");
515    }
516
517    #[test]
518    fn test_get_not_found() {
519        let temp = TempDir::new().unwrap();
520        let repo = Repository::init(temp.path(), None, false).unwrap();
521
522        let result = repo.get(99);
523        assert!(result.is_err());
524    }
525
526    #[test]
527    fn test_find_by_number() {
528        let temp = TempDir::new().unwrap();
529        let repo = Repository::init(temp.path(), None, false).unwrap();
530
531        let adr = repo.find("1").unwrap();
532        assert_eq!(adr.number, 1);
533    }
534
535    #[test]
536    fn test_find_by_title() {
537        let temp = TempDir::new().unwrap();
538        let repo = Repository::init(temp.path(), None, false).unwrap();
539
540        let adr = repo.find("architecture").unwrap();
541        assert_eq!(adr.number, 1);
542    }
543
544    #[test]
545    fn test_find_fuzzy_match() {
546        let temp = TempDir::new().unwrap();
547        let repo = Repository::init(temp.path(), None, false).unwrap();
548        repo.new_adr("Use PostgreSQL for database").unwrap();
549        repo.new_adr("Use Redis for caching").unwrap();
550
551        let adr = repo.find("postgres").unwrap();
552        assert!(adr.title.contains("PostgreSQL"));
553    }
554
555    #[test]
556    fn test_find_not_found() {
557        let temp = TempDir::new().unwrap();
558        let repo = Repository::init(temp.path(), None, false).unwrap();
559
560        let result = repo.find("nonexistent");
561        assert!(result.is_err());
562    }
563
564    // ========== Supersede Tests ==========
565
566    #[test]
567    fn test_supersede() {
568        let temp = TempDir::new().unwrap();
569        let repo = Repository::init(temp.path(), None, false).unwrap();
570
571        let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
572        assert_eq!(new_adr.number, 2);
573        assert_eq!(new_adr.links.len(), 1);
574        assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
575
576        let old_adr = repo.get(1).unwrap();
577        assert_eq!(old_adr.status, AdrStatus::Superseded);
578    }
579
580    #[test]
581    fn test_supersede_creates_bidirectional_links() {
582        let temp = TempDir::new().unwrap();
583        let repo = Repository::init(temp.path(), None, false).unwrap();
584
585        repo.supersede("New approach", 1).unwrap();
586
587        let old_adr = repo.get(1).unwrap();
588        assert_eq!(old_adr.links.len(), 1);
589        assert_eq!(old_adr.links[0].target, 2);
590        assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
591
592        let new_adr = repo.get(2).unwrap();
593        assert_eq!(new_adr.links.len(), 1);
594        assert_eq!(new_adr.links[0].target, 1);
595        assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
596    }
597
598    #[test]
599    fn test_supersede_not_found() {
600        let temp = TempDir::new().unwrap();
601        let repo = Repository::init(temp.path(), None, false).unwrap();
602
603        let result = repo.supersede("New", 99);
604        assert!(result.is_err());
605    }
606
607    // ========== Set Status Tests ==========
608
609    #[test]
610    fn test_set_status_accepted() {
611        let temp = TempDir::new().unwrap();
612        let repo = Repository::init(temp.path(), None, false).unwrap();
613        repo.new_adr("Test Decision").unwrap();
614
615        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
616
617        let adr = repo.get(2).unwrap();
618        assert_eq!(adr.status, AdrStatus::Accepted);
619    }
620
621    #[test]
622    fn test_set_status_deprecated() {
623        let temp = TempDir::new().unwrap();
624        let repo = Repository::init(temp.path(), None, false).unwrap();
625        repo.new_adr("Old Decision").unwrap();
626
627        repo.set_status(2, AdrStatus::Deprecated, None).unwrap();
628
629        let adr = repo.get(2).unwrap();
630        assert_eq!(adr.status, AdrStatus::Deprecated);
631    }
632
633    #[test]
634    fn test_set_status_superseded_with_link() {
635        let temp = TempDir::new().unwrap();
636        let repo = Repository::init(temp.path(), None, false).unwrap();
637        repo.new_adr("First Decision").unwrap();
638        repo.new_adr("Second Decision").unwrap();
639
640        repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
641
642        let adr = repo.get(2).unwrap();
643        assert_eq!(adr.status, AdrStatus::Superseded);
644        assert_eq!(adr.links.len(), 1);
645        assert_eq!(adr.links[0].target, 3);
646        assert_eq!(adr.links[0].kind, LinkKind::SupersededBy);
647    }
648
649    #[test]
650    fn test_set_status_superseded_without_link() {
651        let temp = TempDir::new().unwrap();
652        let repo = Repository::init(temp.path(), None, false).unwrap();
653        repo.new_adr("Decision").unwrap();
654
655        repo.set_status(2, AdrStatus::Superseded, None).unwrap();
656
657        let adr = repo.get(2).unwrap();
658        assert_eq!(adr.status, AdrStatus::Superseded);
659        assert_eq!(adr.links.len(), 0);
660    }
661
662    #[test]
663    fn test_set_status_custom() {
664        let temp = TempDir::new().unwrap();
665        let repo = Repository::init(temp.path(), None, false).unwrap();
666        repo.new_adr("Test Decision").unwrap();
667
668        repo.set_status(2, AdrStatus::Custom("Draft".into()), None)
669            .unwrap();
670
671        let adr = repo.get(2).unwrap();
672        assert_eq!(adr.status, AdrStatus::Custom("Draft".into()));
673    }
674
675    #[test]
676    fn test_set_status_adr_not_found() {
677        let temp = TempDir::new().unwrap();
678        let repo = Repository::init(temp.path(), None, false).unwrap();
679
680        let result = repo.set_status(99, AdrStatus::Accepted, None);
681        assert!(result.is_err());
682    }
683
684    #[test]
685    fn test_set_status_superseded_by_not_found() {
686        let temp = TempDir::new().unwrap();
687        let repo = Repository::init(temp.path(), None, false).unwrap();
688        repo.new_adr("Decision").unwrap();
689
690        let result = repo.set_status(2, AdrStatus::Superseded, Some(99));
691        assert!(result.is_err());
692    }
693
694    // ========== Link Tests ==========
695
696    #[test]
697    fn test_link_adrs() {
698        let temp = TempDir::new().unwrap();
699        let repo = Repository::init(temp.path(), None, false).unwrap();
700        repo.new_adr("Second").unwrap();
701
702        repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
703            .unwrap();
704
705        let adr1 = repo.get(1).unwrap();
706        assert_eq!(adr1.links.len(), 1);
707        assert_eq!(adr1.links[0].target, 2);
708        assert_eq!(adr1.links[0].kind, LinkKind::Amends);
709
710        let adr2 = repo.get(2).unwrap();
711        assert_eq!(adr2.links.len(), 1);
712        assert_eq!(adr2.links[0].target, 1);
713        assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
714    }
715
716    #[test]
717    fn test_link_relates_to() {
718        let temp = TempDir::new().unwrap();
719        let repo = Repository::init(temp.path(), None, false).unwrap();
720        repo.new_adr("Second").unwrap();
721
722        repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
723            .unwrap();
724
725        let adr1 = repo.get(1).unwrap();
726        assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
727
728        let adr2 = repo.get(2).unwrap();
729        assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
730    }
731
732    // ========== Update Tests ==========
733
734    #[test]
735    fn test_update_adr() {
736        let temp = TempDir::new().unwrap();
737        let repo = Repository::init(temp.path(), None, false).unwrap();
738
739        let mut adr = repo.get(1).unwrap();
740        adr.status = AdrStatus::Deprecated;
741
742        repo.update(&adr).unwrap();
743
744        let updated = repo.get(1).unwrap();
745        assert_eq!(updated.status, AdrStatus::Deprecated);
746    }
747
748    #[test]
749    fn test_update_preserves_content() {
750        let temp = TempDir::new().unwrap();
751        let repo = Repository::init(temp.path(), None, false).unwrap();
752
753        let mut adr = repo.get(1).unwrap();
754        let original_title = adr.title.clone();
755        adr.status = AdrStatus::Deprecated;
756
757        repo.update(&adr).unwrap();
758
759        let updated = repo.get(1).unwrap();
760        assert_eq!(updated.title, original_title);
761    }
762
763    // ========== Read/Write Content Tests ==========
764
765    #[test]
766    fn test_read_content() {
767        let temp = TempDir::new().unwrap();
768        let repo = Repository::init(temp.path(), None, false).unwrap();
769
770        let adr = repo.get(1).unwrap();
771        let content = repo.read_content(&adr).unwrap();
772
773        assert!(content.contains("Record architecture decisions"));
774        assert!(content.contains("## Status"));
775    }
776
777    #[test]
778    fn test_write_content() {
779        let temp = TempDir::new().unwrap();
780        let repo = Repository::init(temp.path(), None, false).unwrap();
781
782        let adr = repo.get(1).unwrap();
783        let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
784
785        repo.write_content(&adr, new_content).unwrap();
786
787        let content = repo.read_content(&adr).unwrap();
788        assert!(content.contains("Modified"));
789    }
790
791    // ========== Template Configuration Tests ==========
792
793    #[test]
794    fn test_with_template_format() {
795        let temp = TempDir::new().unwrap();
796        let repo = Repository::init(temp.path(), None, false)
797            .unwrap()
798            .with_template_format(TemplateFormat::Madr);
799
800        let (_, path) = repo.new_adr("MADR Test").unwrap();
801        let content = fs::read_to_string(path).unwrap();
802
803        assert!(content.contains("Context and Problem Statement"));
804    }
805
806    #[test]
807    fn test_with_custom_template() {
808        let temp = TempDir::new().unwrap();
809        let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
810        let repo = Repository::init(temp.path(), None, false)
811            .unwrap()
812            .with_custom_template(custom);
813
814        let (_, path) = repo.new_adr("Custom Test").unwrap();
815        let content = fs::read_to_string(path).unwrap();
816
817        assert_eq!(content, "# ADR 2: Custom Test");
818    }
819
820    // ========== Accessor Tests ==========
821
822    #[test]
823    fn test_root() {
824        let temp = TempDir::new().unwrap();
825        let repo = Repository::init(temp.path(), None, false).unwrap();
826
827        assert_eq!(repo.root(), temp.path());
828    }
829
830    #[test]
831    fn test_config() {
832        let temp = TempDir::new().unwrap();
833        let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
834
835        assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
836        assert!(repo.config().is_next_gen());
837    }
838
839    #[test]
840    fn test_adr_path() {
841        let temp = TempDir::new().unwrap();
842        let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
843
844        assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
845    }
846
847    // ========== NextGen Mode Tests ==========
848
849    #[test]
850    fn test_ng_mode_creates_frontmatter() {
851        let temp = TempDir::new().unwrap();
852        let repo = Repository::init(temp.path(), None, true).unwrap();
853
854        let (_, path) = repo.new_adr("NG Test").unwrap();
855        let content = fs::read_to_string(path).unwrap();
856
857        assert!(content.starts_with("---"));
858        assert!(content.contains("number: 2"));
859        assert!(content.contains("title: NG Test"));
860    }
861
862    #[test]
863    fn test_ng_mode_parses_frontmatter() {
864        let temp = TempDir::new().unwrap();
865        let repo = Repository::init(temp.path(), None, true).unwrap();
866
867        repo.new_adr("NG ADR").unwrap();
868
869        let adr = repo.get(2).unwrap();
870        assert_eq!(adr.title, "NG ADR");
871        assert_eq!(adr.number, 2);
872    }
873
874    // ========== Edge Cases ==========
875
876    #[test]
877    fn test_list_empty_after_init_removal() {
878        let temp = TempDir::new().unwrap();
879        let repo = Repository::init(temp.path(), None, false).unwrap();
880
881        // Remove the initial ADR
882        fs::remove_file(
883            repo.adr_path()
884                .join("0001-record-architecture-decisions.md"),
885        )
886        .unwrap();
887
888        let adrs = repo.list().unwrap();
889        assert!(adrs.is_empty());
890    }
891
892    #[test]
893    fn test_list_ignores_non_adr_files() {
894        let temp = TempDir::new().unwrap();
895        let repo = Repository::init(temp.path(), None, false).unwrap();
896
897        // Create non-ADR files
898        fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
899        fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
900
901        let adrs = repo.list().unwrap();
902        assert_eq!(adrs.len(), 1); // Only the initial ADR
903    }
904
905    #[test]
906    fn test_special_characters_in_title() {
907        let temp = TempDir::new().unwrap();
908        let repo = Repository::init(temp.path(), None, false).unwrap();
909
910        let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
911        assert!(path.exists());
912        assert_eq!(adr.title, "Use C++ & Rust!");
913    }
914}