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 directory exists and count existing ADRs
63        let existing_adrs = if adr_path.exists() {
64            count_existing_adrs(&adr_path)
65        } else {
66            // Create the directory
67            fs::create_dir_all(&adr_path)?;
68            0
69        };
70
71        // Create config
72        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        // Only create initial ADR if no ADRs exist
91        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    /// Get the repository root path.
105    pub fn root(&self) -> &Path {
106        &self.root
107    }
108
109    /// Get the configuration.
110    pub fn config(&self) -> &Config {
111        &self.config
112    }
113
114    /// Get the full path to the ADR directory.
115    pub fn adr_path(&self) -> PathBuf {
116        self.config.adr_path(&self.root)
117    }
118
119    /// Set the template format.
120    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    /// Set the template variant.
126    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    /// Set a custom template.
132    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    /// List all ADRs in the repository.
138    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    /// Get the next available ADR number.
163    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    /// Find an ADR by number.
169    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    /// Find an ADR by query (number or fuzzy title match).
177    pub fn find(&self, query: &str) -> Result<Adr> {
178        // Try parsing as number first
179        if let Ok(number) = query.parse::<u32>() {
180            return self.get(number);
181        }
182
183        // Fuzzy match on title
184        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 top match is significantly better, use it
202                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    /// Create a new ADR.
219    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    /// Create a new ADR with the given title.
229    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    /// Create a new ADR that supersedes another.
237    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        // Update the superseded ADR
243        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    /// Change the status of an ADR.
253    ///
254    /// If the new status is `Superseded` and `superseded_by` is provided,
255    /// a superseded-by link will be added automatically.
256    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 superseded by another ADR, add the link
266        if let (AdrStatus::Superseded, Some(by)) = (&status, superseded_by) {
267            // Check that the superseding ADR exists
268            let _ = self.get(by)?;
269
270            // Add superseded-by link if not already present
271            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    /// Link two ADRs together.
284    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    /// Update an existing ADR.
304    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    /// Read the content of an ADR file.
317    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    /// Write content to an ADR file.
328    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
340/// Count existing ADR files in a directory.
341fn 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                            // Match NNNN-*.md pattern (adr-tools style)
356                            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    // ========== Initialization Tests ==========
370
371    #[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        // Re-init should succeed but not create another ADR
418        let repo = Repository::init(temp.path(), None, false).unwrap();
419        let adrs = repo.list().unwrap();
420        assert_eq!(adrs.len(), 1); // Still just the original initial ADR
421    }
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        // Create some existing ADR files
430        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        // Init should succeed and NOT create initial ADR
442        let repo = Repository::init(temp.path(), None, false).unwrap();
443        let adrs = repo.list().unwrap();
444        assert_eq!(adrs.len(), 2); // Only the existing ADRs, no "Record architecture decisions"
445        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    // ========== Open Tests ==========
463
464    #[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    // ========== Create and List Tests ==========
497
498    #[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    // ========== Get and Find Tests ==========
562
563    #[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    // ========== Supersede Tests ==========
621
622    #[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    // ========== Set Status Tests ==========
664
665    #[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    // ========== Link Tests ==========
751
752    #[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    // ========== Update Tests ==========
789
790    #[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    // ========== Read/Write Content Tests ==========
820
821    #[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    // ========== Template Configuration Tests ==========
848
849    #[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    // ========== Accessor Tests ==========
877
878    #[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    // ========== NextGen Mode Tests ==========
904
905    #[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    // ========== Edge Cases ==========
931
932    #[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        // Remove the initial ADR
938        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        // Create non-ADR files
954        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); // Only the initial ADR
959    }
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}