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