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 regex::Regex;
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::LazyLock;
14use walkdir::WalkDir;
15
16/// Regex for matching the status line in YAML frontmatter.
17static FM_STATUS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^status:\s*.*$").unwrap());
18
19/// Regex for matching the links block in YAML frontmatter (multi-line).
20static FM_LINKS_RE: LazyLock<Regex> =
21    LazyLock::new(|| Regex::new(r"(?m)^links:\n(?:(?:  .+\n)*)").unwrap());
22
23/// Regex for matching the tags block in YAML frontmatter (multi-line).
24static FM_TAGS_RE: LazyLock<Regex> =
25    LazyLock::new(|| Regex::new(r"(?m)^tags:\n(?:(?:  .+\n)*)").unwrap());
26
27/// A repository of Architecture Decision Records.
28#[derive(Debug)]
29pub struct Repository {
30    /// The root directory of the project.
31    root: PathBuf,
32
33    /// Configuration for this repository.
34    config: Config,
35
36    /// Parser for reading ADRs.
37    parser: Parser,
38
39    /// Template engine for creating ADRs.
40    template_engine: TemplateEngine,
41}
42
43impl Repository {
44    /// Open an existing repository at the given root.
45    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
46        let root = root.into();
47        let config = Config::load(&root)?;
48        let template_engine = Self::engine_from_config(&config);
49
50        Ok(Self {
51            root,
52            config,
53            parser: Parser::new(),
54            template_engine,
55        })
56    }
57
58    /// Open a repository, or create default config if not found.
59    pub fn open_or_default(root: impl Into<PathBuf>) -> Self {
60        let root = root.into();
61        let config = Config::load_or_default(&root);
62        let template_engine = Self::engine_from_config(&config);
63
64        Self {
65            root,
66            config,
67            parser: Parser::new(),
68            template_engine,
69        }
70    }
71
72    /// Initialize a new repository at the given root.
73    pub fn init(root: impl Into<PathBuf>, adr_dir: Option<PathBuf>, ng: bool) -> Result<Self> {
74        let root = root.into();
75        let adr_dir = adr_dir.unwrap_or_else(|| PathBuf::from(crate::config::DEFAULT_ADR_DIR));
76        let adr_path = root.join(&adr_dir);
77
78        // Check if directory exists and count existing ADRs
79        let existing_adrs = if adr_path.exists() {
80            count_existing_adrs(&adr_path)
81        } else {
82            // Create the directory
83            fs::create_dir_all(&adr_path)?;
84            0
85        };
86
87        // Create config
88        let config = Config {
89            adr_dir,
90            mode: if ng {
91                ConfigMode::NextGen
92            } else {
93                ConfigMode::Compatible
94            },
95            ..Default::default()
96        };
97        config.save(&root)?;
98
99        let template_engine = Self::engine_from_config(&config);
100
101        let repo = Self {
102            root,
103            config,
104            parser: Parser::new(),
105            template_engine,
106        };
107
108        // Only create initial ADR if no ADRs exist
109        if existing_adrs == 0 {
110            let mut adr = Adr::new(1, "Record architecture decisions");
111            adr.status = AdrStatus::Accepted;
112            adr.context =
113                "We need to record the architectural decisions made on this project.".into();
114            adr.decision = "We will use Architecture Decision Records, as described by Michael Nygard in his article \"Documenting Architecture Decisions\".".into();
115            adr.consequences = "See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's adr-tools.".into();
116            repo.create(&adr)?;
117        }
118
119        Ok(repo)
120    }
121
122    /// Get the repository root path.
123    pub fn root(&self) -> &Path {
124        &self.root
125    }
126
127    /// Get the configuration.
128    pub fn config(&self) -> &Config {
129        &self.config
130    }
131
132    /// Get the full path to the ADR directory.
133    pub fn adr_path(&self) -> PathBuf {
134        self.config.adr_path(&self.root)
135    }
136
137    /// Build a template engine that respects the config's template format.
138    fn engine_from_config(config: &Config) -> TemplateEngine {
139        let mut engine = TemplateEngine::new();
140        if let Some(ref fmt) = config.templates.format
141            && let Ok(format) = fmt.parse::<TemplateFormat>()
142        {
143            engine = engine.with_format(format);
144        }
145        engine
146    }
147
148    /// Set the template format.
149    pub fn with_template_format(mut self, format: TemplateFormat) -> Self {
150        self.template_engine = self.template_engine.with_format(format);
151        self
152    }
153
154    /// Set the template variant.
155    pub fn with_template_variant(mut self, variant: TemplateVariant) -> Self {
156        self.template_engine = self.template_engine.with_variant(variant);
157        self
158    }
159
160    /// Set a custom template.
161    pub fn with_custom_template(mut self, template: Template) -> Self {
162        self.template_engine = self.template_engine.with_custom_template(template);
163        self
164    }
165
166    /// List all ADRs in the repository.
167    pub fn list(&self) -> Result<Vec<Adr>> {
168        let adr_path = self.adr_path();
169        if !adr_path.exists() {
170            return Err(Error::AdrDirNotFound);
171        }
172
173        let mut adrs: Vec<Adr> = WalkDir::new(&adr_path)
174            .max_depth(1)
175            .into_iter()
176            .filter_map(|e| e.ok())
177            .filter(|e| {
178                e.path().extension().is_some_and(|ext| ext == "md")
179                    && e.path()
180                        .file_name()
181                        .and_then(|n| n.to_str())
182                        .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
183            })
184            .filter_map(|e| self.parser.parse_file(e.path()).ok())
185            .collect();
186
187        adrs.sort_by_key(|a| a.number);
188        Ok(adrs)
189    }
190
191    /// Get the next available ADR number.
192    pub fn next_number(&self) -> Result<u32> {
193        let adrs = self.list()?;
194        Ok(adrs.last().map(|a| a.number + 1).unwrap_or(1))
195    }
196
197    /// Find an ADR by number.
198    pub fn get(&self, number: u32) -> Result<Adr> {
199        let adrs = self.list()?;
200        adrs.into_iter()
201            .find(|a| a.number == number)
202            .ok_or_else(|| Error::AdrNotFound(number.to_string()))
203    }
204
205    /// Find an ADR by query (number or fuzzy title match).
206    pub fn find(&self, query: &str) -> Result<Adr> {
207        // Try parsing as number first
208        if let Ok(number) = query.parse::<u32>() {
209            return self.get(number);
210        }
211
212        // Fuzzy match on title
213        let adrs = self.list()?;
214        let matcher = SkimMatcherV2::default();
215
216        let mut matches: Vec<_> = adrs
217            .into_iter()
218            .filter_map(|adr| {
219                let score = matcher.fuzzy_match(&adr.title, query)?;
220                Some((adr, score))
221            })
222            .collect();
223
224        matches.sort_by(|a, b| b.1.cmp(&a.1));
225
226        match matches.len() {
227            0 => Err(Error::AdrNotFound(query.to_string())),
228            1 => Ok(matches.remove(0).0),
229            _ => {
230                // If top match is significantly better, use it
231                if matches[0].1 > matches[1].1 * 2 {
232                    Ok(matches.remove(0).0)
233                } else {
234                    Err(Error::AmbiguousAdr {
235                        query: query.to_string(),
236                        matches: matches
237                            .iter()
238                            .take(5)
239                            .map(|(a, _)| a.title.clone())
240                            .collect(),
241                    })
242                }
243            }
244        }
245    }
246
247    /// Resolve link target titles and filenames for an ADR's links.
248    fn resolve_link_titles(&self, adr: &Adr) -> HashMap<u32, (String, String)> {
249        let mut map = HashMap::new();
250        for link in &adr.links {
251            if map.contains_key(&link.target) {
252                continue;
253            }
254            if let Ok(target_adr) = self.get(link.target) {
255                map.insert(
256                    link.target,
257                    (target_adr.title.clone(), target_adr.filename()),
258                );
259            }
260        }
261        map
262    }
263
264    /// Create a new ADR.
265    pub fn create(&self, adr: &Adr) -> Result<PathBuf> {
266        let path = self.adr_path().join(adr.filename());
267
268        let link_titles = self.resolve_link_titles(adr);
269        let content = self
270            .template_engine
271            .render(adr, &self.config, &link_titles)?;
272        fs::write(&path, content)?;
273
274        Ok(path)
275    }
276
277    /// Create a new ADR with the given title.
278    pub fn new_adr(&self, title: impl Into<String>) -> Result<(Adr, PathBuf)> {
279        let number = self.next_number()?;
280        let adr = Adr::new(number, title);
281        let path = self.create(&adr)?;
282        Ok((adr, path))
283    }
284
285    /// Create a new ADR that supersedes another.
286    pub fn supersede(&self, title: impl Into<String>, superseded: u32) -> Result<(Adr, PathBuf)> {
287        let number = self.next_number()?;
288        let mut adr = Adr::new(number, title);
289        adr.add_link(AdrLink::new(superseded, LinkKind::Supersedes));
290
291        // Create the new ADR first so its file exists on disk when
292        // the old ADR's "Superseded by" link is resolved.
293        let path = self.create(&adr)?;
294
295        // Now update the superseded ADR — the new ADR is on disk so
296        // its title and filename can be resolved for the link.
297        let mut old_adr = self.get(superseded)?;
298        old_adr.status = AdrStatus::Superseded;
299        old_adr.add_link(AdrLink::new(number, LinkKind::SupersededBy));
300        self.update_metadata(&old_adr)?;
301
302        Ok((adr, path))
303    }
304
305    /// Change the status of an ADR.
306    ///
307    /// If the new status is `Superseded` and `superseded_by` is provided,
308    /// a superseded-by link will be added automatically.
309    pub fn set_status(
310        &self,
311        number: u32,
312        status: AdrStatus,
313        superseded_by: Option<u32>,
314    ) -> Result<PathBuf> {
315        let mut adr = self.get(number)?;
316        adr.status = status.clone();
317
318        // If superseded by another ADR, add the link
319        if let (AdrStatus::Superseded, Some(by)) = (&status, superseded_by) {
320            // Check that the superseding ADR exists
321            let _ = self.get(by)?;
322
323            // Add superseded-by link if not already present
324            if !adr
325                .links
326                .iter()
327                .any(|l| matches!(l.kind, LinkKind::SupersededBy) && l.target == by)
328            {
329                adr.add_link(AdrLink::new(by, LinkKind::SupersededBy));
330            }
331        }
332
333        self.update_metadata(&adr)
334    }
335
336    /// Link two ADRs together.
337    pub fn link(
338        &self,
339        source: u32,
340        target: u32,
341        source_kind: LinkKind,
342        target_kind: LinkKind,
343    ) -> Result<()> {
344        let mut source_adr = self.get(source)?;
345        let mut target_adr = self.get(target)?;
346
347        source_adr.add_link(AdrLink::new(target, source_kind));
348        target_adr.add_link(AdrLink::new(source, target_kind));
349
350        self.update_metadata(&source_adr)?;
351        self.update_metadata(&target_adr)?;
352
353        Ok(())
354    }
355
356    /// Update an existing ADR.
357    pub fn update(&self, adr: &Adr) -> Result<PathBuf> {
358        let path = adr
359            .path
360            .clone()
361            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
362
363        let link_titles = self.resolve_link_titles(adr);
364        let content = self
365            .template_engine
366            .render(adr, &self.config, &link_titles)?;
367        fs::write(&path, content)?;
368
369        Ok(path)
370    }
371
372    /// Read the content of an ADR file.
373    pub fn read_content(&self, adr: &Adr) -> Result<String> {
374        let path = adr
375            .path
376            .as_ref()
377            .cloned()
378            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
379
380        Ok(fs::read_to_string(path)?)
381    }
382
383    /// Write content to an ADR file.
384    pub fn write_content(&self, adr: &Adr, content: &str) -> Result<PathBuf> {
385        let path = adr
386            .path
387            .as_ref()
388            .cloned()
389            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
390
391        fs::write(&path, content)?;
392        Ok(path)
393    }
394
395    /// Update only the metadata (status, links, tags) of an existing ADR file,
396    /// preserving all other content byte-for-byte.
397    pub fn update_metadata(&self, adr: &Adr) -> Result<PathBuf> {
398        let path = adr
399            .path
400            .clone()
401            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
402
403        let content = fs::read_to_string(&path)?;
404
405        let updated = if content.starts_with("---\n") {
406            self.update_frontmatter_metadata(adr, &content)?
407        } else {
408            self.update_legacy_metadata(adr, &content)?
409        };
410
411        fs::write(&path, updated)?;
412        Ok(path)
413    }
414
415    /// Surgically update metadata fields in a YAML frontmatter file.
416    ///
417    /// Replaces only `status:`, `links:`, and `tags:` blocks in the frontmatter.
418    /// YAML comments (e.g., SPDX headers), unknown fields, and the entire
419    /// markdown body are preserved untouched.
420    fn update_frontmatter_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
421        // Split into frontmatter and body at the closing `---`
422        let Some(rest) = content.strip_prefix("---\n") else {
423            return Err(Error::InvalidFormat {
424                path: Default::default(),
425                reason: "Missing opening frontmatter delimiter".into(),
426            });
427        };
428
429        let Some(end_idx) = rest.find("\n---\n").or_else(|| {
430            // Handle case where closing delimiter is at end of file with no trailing newline
431            if rest.ends_with("\n---") {
432                Some(rest.len() - 3)
433            } else {
434                None
435            }
436        }) else {
437            return Err(Error::InvalidFormat {
438                path: Default::default(),
439                reason: "Missing closing frontmatter delimiter".into(),
440            });
441        };
442
443        let yaml_block = &rest[..end_idx + 1]; // include trailing \n
444        let after_yaml = &rest[end_idx..]; // starts with \n---\n...
445
446        // 1. Replace status line
447        let new_status = format!("status: {}", adr.status.to_string().to_lowercase());
448        let yaml_block = FM_STATUS_RE.replace(yaml_block, new_status.as_str());
449
450        // 2. Replace or remove links block
451        let links_yaml = Self::format_links_yaml(&adr.links);
452        let yaml_block = if FM_LINKS_RE.is_match(&yaml_block) {
453            FM_LINKS_RE
454                .replace(&yaml_block, links_yaml.as_str())
455                .into_owned()
456        } else if !links_yaml.is_empty() {
457            // Append links before end of frontmatter
458            let mut s = yaml_block.into_owned();
459            if !s.ends_with('\n') {
460                s.push('\n');
461            }
462            s.push_str(&links_yaml);
463            s
464        } else {
465            yaml_block.into_owned()
466        };
467
468        // 3. Replace or remove tags block
469        let tags_yaml = Self::format_tags_yaml(&adr.tags);
470        let yaml_block = if FM_TAGS_RE.is_match(&yaml_block) {
471            FM_TAGS_RE
472                .replace(&yaml_block, tags_yaml.as_str())
473                .into_owned()
474        } else if !tags_yaml.is_empty() {
475            let mut s = yaml_block;
476            if !s.ends_with('\n') {
477                s.push('\n');
478            }
479            s.push_str(&tags_yaml);
480            s
481        } else {
482            yaml_block
483        };
484
485        Ok(format!("---\n{}{}", yaml_block, after_yaml))
486    }
487
488    /// Surgically update metadata in a legacy (no-frontmatter) ADR file.
489    ///
490    /// Replaces the content between `## Status` and the next `## ` heading
491    /// with the new status and link lines. All other sections pass through untouched.
492    fn update_legacy_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
493        let lines: Vec<&str> = content.lines().collect();
494        let mut result = String::with_capacity(content.len());
495
496        // Find the ## Status section
497        let status_idx = lines.iter().position(|l| {
498            l.trim().eq_ignore_ascii_case("## Status") || l.trim().eq_ignore_ascii_case("## STATUS")
499        });
500
501        let Some(status_idx) = status_idx else {
502            // No status section found -- just return content unchanged
503            return Ok(content.to_string());
504        };
505
506        // Find the next ## heading after status
507        let next_heading_idx = lines[status_idx + 1..]
508            .iter()
509            .position(|l| l.starts_with("## "))
510            .map(|i| i + status_idx + 1);
511
512        // Write everything before the status section (including the ## Status line)
513        for line in &lines[..=status_idx] {
514            result.push_str(line);
515            result.push('\n');
516        }
517
518        // Write new status content
519        result.push('\n');
520        result.push_str(&adr.status.to_string());
521        result.push('\n');
522
523        // Write link lines with resolved titles
524        let link_titles = self.resolve_link_titles(adr);
525        for link in &adr.links {
526            result.push('\n');
527            if let Some((title, filename)) = link_titles.get(&link.target) {
528                result.push_str(&format!(
529                    "{} [{}. {}]({})",
530                    link.kind, link.target, title, filename
531                ));
532            } else {
533                result.push_str(&format!(
534                    "{} [{}. ...]({:04}-....md)",
535                    link.kind, link.target, link.target
536                ));
537            }
538            result.push('\n');
539        }
540
541        // Write everything from the next heading onward
542        if let Some(next_idx) = next_heading_idx {
543            result.push('\n');
544            for (i, line) in lines[next_idx..].iter().enumerate() {
545                result.push_str(line);
546                // Preserve trailing newline behavior
547                if next_idx + i < lines.len() - 1 || content.ends_with('\n') {
548                    result.push('\n');
549                }
550            }
551        } else if content.ends_with('\n') {
552            // No next heading, but original ended with newline
553        }
554
555        Ok(result)
556    }
557
558    /// Format links as YAML block for frontmatter insertion.
559    fn format_links_yaml(links: &[AdrLink]) -> String {
560        if links.is_empty() {
561            return String::new();
562        }
563        let mut s = String::from("links:\n");
564        for link in links {
565            let kind_str = match &link.kind {
566                LinkKind::Supersedes => "supersedes",
567                LinkKind::SupersededBy => "supersededby",
568                LinkKind::Amends => "amends",
569                LinkKind::AmendedBy => "amendedby",
570                LinkKind::RelatesTo => "relatesto",
571                LinkKind::Custom(c) => c.as_str(),
572            };
573            s.push_str(&format!(
574                "  - target: {}\n    kind: {}\n",
575                link.target, kind_str
576            ));
577        }
578        s
579    }
580
581    /// Format tags as YAML block for frontmatter insertion.
582    fn format_tags_yaml(tags: &[String]) -> String {
583        if tags.is_empty() {
584            return String::new();
585        }
586        let mut s = String::from("tags:\n");
587        for tag in tags {
588            s.push_str(&format!("  - {}\n", tag));
589        }
590        s
591    }
592}
593
594/// Count existing ADR files in a directory.
595fn count_existing_adrs(path: &Path) -> usize {
596    if !path.is_dir() {
597        return 0;
598    }
599
600    fs::read_dir(path)
601        .map(|entries| {
602            entries
603                .filter_map(|e| e.ok())
604                .filter(|e| {
605                    let path = e.path();
606                    path.is_file()
607                        && path.extension().is_some_and(|ext| ext == "md")
608                        && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
609                            // Match NNNN-*.md pattern (adr-tools style)
610                            n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
611                        })
612                })
613                .count()
614        })
615        .unwrap_or(0)
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use tempfile::TempDir;
622
623    // ========== Initialization Tests ==========
624
625    #[test]
626    fn test_init_repository() {
627        let temp = TempDir::new().unwrap();
628        let repo = Repository::init(temp.path(), None, false).unwrap();
629
630        assert!(repo.adr_path().exists());
631        assert!(temp.path().join(".adr-dir").exists());
632
633        let adrs = repo.list().unwrap();
634        assert_eq!(adrs.len(), 1);
635        assert_eq!(adrs[0].number, 1);
636        assert_eq!(adrs[0].title, "Record architecture decisions");
637    }
638
639    #[test]
640    fn test_init_repository_ng() {
641        let temp = TempDir::new().unwrap();
642        let repo = Repository::init(temp.path(), None, true).unwrap();
643
644        assert!(temp.path().join("adrs.toml").exists());
645        assert!(repo.config().is_next_gen());
646    }
647
648    #[test]
649    fn test_init_repository_custom_dir() {
650        let temp = TempDir::new().unwrap();
651        let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
652
653        assert!(temp.path().join("decisions").exists());
654        assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
655    }
656
657    #[test]
658    fn test_init_repository_nested_dir() {
659        let temp = TempDir::new().unwrap();
660        let _repo =
661            Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
662
663        assert!(temp.path().join("docs/architecture/adr").exists());
664    }
665
666    #[test]
667    fn test_init_repository_already_exists_skips_initial_adr() {
668        let temp = TempDir::new().unwrap();
669        Repository::init(temp.path(), None, false).unwrap();
670
671        // Re-init should succeed but not create another ADR
672        let repo = Repository::init(temp.path(), None, false).unwrap();
673        let adrs = repo.list().unwrap();
674        assert_eq!(adrs.len(), 1); // Still just the original initial ADR
675    }
676
677    #[test]
678    fn test_init_with_existing_adrs_skips_initial() {
679        let temp = TempDir::new().unwrap();
680        let adr_dir = temp.path().join("doc/adr");
681        fs::create_dir_all(&adr_dir).unwrap();
682
683        // Create some existing ADR files
684        fs::write(
685            adr_dir.join("0001-existing-decision.md"),
686            "# 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",
687        )
688        .unwrap();
689        fs::write(
690            adr_dir.join("0002-another-decision.md"),
691            "# 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",
692        )
693        .unwrap();
694
695        // Init should succeed and NOT create initial ADR
696        let repo = Repository::init(temp.path(), None, false).unwrap();
697        let adrs = repo.list().unwrap();
698        assert_eq!(adrs.len(), 2); // Only the existing ADRs, no "Record architecture decisions"
699        assert_eq!(adrs[0].title, "Existing Decision");
700        assert_eq!(adrs[1].title, "Another Decision");
701    }
702
703    #[test]
704    fn test_init_creates_first_adr() {
705        let temp = TempDir::new().unwrap();
706        let repo = Repository::init(temp.path(), None, false).unwrap();
707
708        let adr = repo.get(1).unwrap();
709        assert_eq!(adr.title, "Record architecture decisions");
710        assert_eq!(adr.status, AdrStatus::Accepted);
711        assert!(!adr.context.is_empty());
712        assert!(!adr.decision.is_empty());
713        assert!(!adr.consequences.is_empty());
714    }
715
716    // ========== Open Tests ==========
717
718    #[test]
719    fn test_open_repository() {
720        let temp = TempDir::new().unwrap();
721        Repository::init(temp.path(), None, false).unwrap();
722
723        let repo = Repository::open(temp.path()).unwrap();
724        assert_eq!(repo.list().unwrap().len(), 1);
725    }
726
727    #[test]
728    fn test_open_repository_not_found() {
729        let temp = TempDir::new().unwrap();
730        let result = Repository::open(temp.path());
731        assert!(result.is_err());
732    }
733
734    #[test]
735    fn test_open_or_default() {
736        let temp = TempDir::new().unwrap();
737        let repo = Repository::open_or_default(temp.path());
738        assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
739    }
740
741    #[test]
742    fn test_open_or_default_existing() {
743        let temp = TempDir::new().unwrap();
744        Repository::init(temp.path(), Some("custom".into()), false).unwrap();
745
746        let repo = Repository::open_or_default(temp.path());
747        assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
748    }
749
750    // ========== Create and List Tests ==========
751
752    #[test]
753    fn test_create_and_list() {
754        let temp = TempDir::new().unwrap();
755        let repo = Repository::init(temp.path(), None, false).unwrap();
756
757        let (adr, _) = repo.new_adr("Use Rust").unwrap();
758        assert_eq!(adr.number, 2);
759
760        let adrs = repo.list().unwrap();
761        assert_eq!(adrs.len(), 2);
762    }
763
764    #[test]
765    fn test_create_multiple() {
766        let temp = TempDir::new().unwrap();
767        let repo = Repository::init(temp.path(), None, false).unwrap();
768
769        repo.new_adr("Second").unwrap();
770        repo.new_adr("Third").unwrap();
771        repo.new_adr("Fourth").unwrap();
772
773        let adrs = repo.list().unwrap();
774        assert_eq!(adrs.len(), 4);
775        assert_eq!(adrs[0].number, 1);
776        assert_eq!(adrs[1].number, 2);
777        assert_eq!(adrs[2].number, 3);
778        assert_eq!(adrs[3].number, 4);
779    }
780
781    #[test]
782    fn test_list_sorted_by_number() {
783        let temp = TempDir::new().unwrap();
784        let repo = Repository::init(temp.path(), None, false).unwrap();
785
786        repo.new_adr("B").unwrap();
787        repo.new_adr("A").unwrap();
788        repo.new_adr("C").unwrap();
789
790        let adrs = repo.list().unwrap();
791        assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
792    }
793
794    #[test]
795    fn test_next_number() {
796        let temp = TempDir::new().unwrap();
797        let repo = Repository::init(temp.path(), None, false).unwrap();
798
799        assert_eq!(repo.next_number().unwrap(), 2);
800
801        repo.new_adr("Second").unwrap();
802        assert_eq!(repo.next_number().unwrap(), 3);
803    }
804
805    #[test]
806    fn test_create_file_exists() {
807        let temp = TempDir::new().unwrap();
808        let repo = Repository::init(temp.path(), None, false).unwrap();
809
810        let (_, path) = repo.new_adr("Test ADR").unwrap();
811        assert!(path.exists());
812        assert!(path.to_string_lossy().contains("0002-test-adr.md"));
813    }
814
815    // ========== Get and Find Tests ==========
816
817    #[test]
818    fn test_get_by_number() {
819        let temp = TempDir::new().unwrap();
820        let repo = Repository::init(temp.path(), None, false).unwrap();
821        repo.new_adr("Second").unwrap();
822
823        let adr = repo.get(2).unwrap();
824        assert_eq!(adr.title, "Second");
825    }
826
827    #[test]
828    fn test_get_not_found() {
829        let temp = TempDir::new().unwrap();
830        let repo = Repository::init(temp.path(), None, false).unwrap();
831
832        let result = repo.get(99);
833        assert!(result.is_err());
834    }
835
836    #[test]
837    fn test_find_by_number() {
838        let temp = TempDir::new().unwrap();
839        let repo = Repository::init(temp.path(), None, false).unwrap();
840
841        let adr = repo.find("1").unwrap();
842        assert_eq!(adr.number, 1);
843    }
844
845    #[test]
846    fn test_find_by_title() {
847        let temp = TempDir::new().unwrap();
848        let repo = Repository::init(temp.path(), None, false).unwrap();
849
850        let adr = repo.find("architecture").unwrap();
851        assert_eq!(adr.number, 1);
852    }
853
854    #[test]
855    fn test_find_fuzzy_match() {
856        let temp = TempDir::new().unwrap();
857        let repo = Repository::init(temp.path(), None, false).unwrap();
858        repo.new_adr("Use PostgreSQL for database").unwrap();
859        repo.new_adr("Use Redis for caching").unwrap();
860
861        let adr = repo.find("postgres").unwrap();
862        assert!(adr.title.contains("PostgreSQL"));
863    }
864
865    #[test]
866    fn test_find_not_found() {
867        let temp = TempDir::new().unwrap();
868        let repo = Repository::init(temp.path(), None, false).unwrap();
869
870        let result = repo.find("nonexistent");
871        assert!(result.is_err());
872    }
873
874    // ========== Supersede Tests ==========
875
876    #[test]
877    fn test_supersede() {
878        let temp = TempDir::new().unwrap();
879        let repo = Repository::init(temp.path(), None, false).unwrap();
880
881        let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
882        assert_eq!(new_adr.number, 2);
883        assert_eq!(new_adr.links.len(), 1);
884        assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
885
886        let old_adr = repo.get(1).unwrap();
887        assert_eq!(old_adr.status, AdrStatus::Superseded);
888    }
889
890    #[test]
891    fn test_supersede_creates_bidirectional_links() {
892        let temp = TempDir::new().unwrap();
893        let repo = Repository::init(temp.path(), None, false).unwrap();
894
895        repo.supersede("New approach", 1).unwrap();
896
897        let old_adr = repo.get(1).unwrap();
898        assert_eq!(old_adr.links.len(), 1);
899        assert_eq!(old_adr.links[0].target, 2);
900        assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
901
902        let new_adr = repo.get(2).unwrap();
903        assert_eq!(new_adr.links.len(), 1);
904        assert_eq!(new_adr.links[0].target, 1);
905        assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
906    }
907
908    #[test]
909    fn test_supersede_not_found() {
910        let temp = TempDir::new().unwrap();
911        let repo = Repository::init(temp.path(), None, false).unwrap();
912
913        let result = repo.supersede("New", 99);
914        assert!(result.is_err());
915    }
916
917    // ========== Link Resolution Tests (Issue #180) ==========
918
919    #[test]
920    fn test_supersede_generates_functional_links() {
921        let temp = TempDir::new().unwrap();
922        let repo = Repository::init(temp.path(), None, false).unwrap();
923
924        // Create ADR 2, then supersede it with ADR 3
925        repo.new_adr("Use MySQL for persistence").unwrap();
926        repo.supersede("Use PostgreSQL instead", 2).unwrap();
927
928        // Check the new ADR (3) has a functional "Supersedes" link to ADR 2
929        let new_content =
930            fs::read_to_string(repo.adr_path().join("0003-use-postgresql-instead.md")).unwrap();
931        assert!(
932            new_content.contains(
933                "Supersedes [2. Use MySQL for persistence](0002-use-mysql-for-persistence.md)"
934            ),
935            "New ADR should have functional Supersedes link. Got:\n{new_content}"
936        );
937
938        // Check the old ADR (2) has a functional "Superseded by" link to ADR 3
939        let old_content =
940            fs::read_to_string(repo.adr_path().join("0002-use-mysql-for-persistence.md")).unwrap();
941        assert!(
942            old_content.contains(
943                "Superseded by [3. Use PostgreSQL instead](0003-use-postgresql-instead.md)"
944            ),
945            "Old ADR should have functional Superseded by link. Got:\n{old_content}"
946        );
947    }
948
949    #[test]
950    fn test_link_generates_functional_links() {
951        let temp = TempDir::new().unwrap();
952        let repo = Repository::init(temp.path(), None, false).unwrap();
953
954        repo.new_adr("Use REST API").unwrap();
955        repo.new_adr("Use JSON for API responses").unwrap();
956
957        repo.link(3, 2, LinkKind::Amends, LinkKind::AmendedBy)
958            .unwrap();
959
960        // Check source ADR has functional link
961        let source_content =
962            fs::read_to_string(repo.adr_path().join("0003-use-json-for-api-responses.md")).unwrap();
963        assert!(
964            source_content.contains("Amends [2. Use REST API](0002-use-rest-api.md)"),
965            "Source ADR should have functional Amends link. Got:\n{source_content}"
966        );
967
968        // Check target ADR has functional reverse link
969        let target_content =
970            fs::read_to_string(repo.adr_path().join("0002-use-rest-api.md")).unwrap();
971        assert!(
972            target_content.contains(
973                "Amended by [3. Use JSON for API responses](0003-use-json-for-api-responses.md)"
974            ),
975            "Target ADR should have functional Amended by link. Got:\n{target_content}"
976        );
977    }
978
979    #[test]
980    fn test_set_status_superseded_generates_functional_link() {
981        let temp = TempDir::new().unwrap();
982        let repo = Repository::init(temp.path(), None, false).unwrap();
983
984        repo.new_adr("First Decision").unwrap();
985        repo.new_adr("Second Decision").unwrap();
986
987        repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
988
989        let content = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
990        assert!(
991            content.contains("Superseded by [3. Second Decision](0003-second-decision.md)"),
992            "ADR should have functional Superseded by link. Got:\n{content}"
993        );
994    }
995
996    #[test]
997    fn test_supersede_chain_generates_functional_links() {
998        let temp = TempDir::new().unwrap();
999        let repo = Repository::init(temp.path(), None, false).unwrap();
1000
1001        // ADR 1 is "Record architecture decisions" (from init)
1002        // Create ADR 2
1003        repo.new_adr("Use SQLite").unwrap();
1004        // ADR 3 supersedes ADR 2
1005        repo.supersede("Use PostgreSQL", 2).unwrap();
1006        // ADR 4 supersedes ADR 3
1007        repo.supersede("Use CockroachDB", 3).unwrap();
1008
1009        // Check ADR 3 has both directions
1010        let adr3_content =
1011            fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1012        assert!(
1013            adr3_content.contains("Supersedes [2. Use SQLite](0002-use-sqlite.md)"),
1014            "ADR 3 should supersede ADR 2. Got:\n{adr3_content}"
1015        );
1016        assert!(
1017            adr3_content.contains("Superseded by [4. Use CockroachDB](0004-use-cockroachdb.md)"),
1018            "ADR 3 should be superseded by ADR 4. Got:\n{adr3_content}"
1019        );
1020    }
1021
1022    #[test]
1023    fn test_ng_mode_supersede_generates_functional_links() {
1024        let temp = TempDir::new().unwrap();
1025        let repo = Repository::init(temp.path(), None, true).unwrap();
1026
1027        repo.new_adr("Use MySQL").unwrap();
1028        repo.supersede("Use PostgreSQL", 2).unwrap();
1029
1030        // Check the new ADR has functional links in both frontmatter and body
1031        let new_content =
1032            fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1033
1034        // Body should have functional markdown link
1035        assert!(
1036            new_content.contains("Supersedes [2. Use MySQL](0002-use-mysql.md)"),
1037            "NG mode should have functional link in body. Got:\n{new_content}"
1038        );
1039        // Frontmatter should have structured link
1040        assert!(new_content.contains("links:"));
1041        assert!(new_content.contains("target: 2"));
1042    }
1043
1044    // ========== Set Status Tests ==========
1045
1046    #[test]
1047    fn test_set_status_accepted() {
1048        let temp = TempDir::new().unwrap();
1049        let repo = Repository::init(temp.path(), None, false).unwrap();
1050        repo.new_adr("Test Decision").unwrap();
1051
1052        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1053
1054        let adr = repo.get(2).unwrap();
1055        assert_eq!(adr.status, AdrStatus::Accepted);
1056    }
1057
1058    #[test]
1059    fn test_set_status_deprecated() {
1060        let temp = TempDir::new().unwrap();
1061        let repo = Repository::init(temp.path(), None, false).unwrap();
1062        repo.new_adr("Old Decision").unwrap();
1063
1064        repo.set_status(2, AdrStatus::Deprecated, None).unwrap();
1065
1066        let adr = repo.get(2).unwrap();
1067        assert_eq!(adr.status, AdrStatus::Deprecated);
1068    }
1069
1070    #[test]
1071    fn test_set_status_superseded_with_link() {
1072        let temp = TempDir::new().unwrap();
1073        let repo = Repository::init(temp.path(), None, false).unwrap();
1074        repo.new_adr("First Decision").unwrap();
1075        repo.new_adr("Second Decision").unwrap();
1076
1077        repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
1078
1079        let adr = repo.get(2).unwrap();
1080        assert_eq!(adr.status, AdrStatus::Superseded);
1081        assert_eq!(adr.links.len(), 1);
1082        assert_eq!(adr.links[0].target, 3);
1083        assert_eq!(adr.links[0].kind, LinkKind::SupersededBy);
1084    }
1085
1086    #[test]
1087    fn test_set_status_superseded_without_link() {
1088        let temp = TempDir::new().unwrap();
1089        let repo = Repository::init(temp.path(), None, false).unwrap();
1090        repo.new_adr("Decision").unwrap();
1091
1092        repo.set_status(2, AdrStatus::Superseded, None).unwrap();
1093
1094        let adr = repo.get(2).unwrap();
1095        assert_eq!(adr.status, AdrStatus::Superseded);
1096        assert_eq!(adr.links.len(), 0);
1097    }
1098
1099    #[test]
1100    fn test_set_status_custom() {
1101        let temp = TempDir::new().unwrap();
1102        let repo = Repository::init(temp.path(), None, false).unwrap();
1103        repo.new_adr("Test Decision").unwrap();
1104
1105        repo.set_status(2, AdrStatus::Custom("Draft".into()), None)
1106            .unwrap();
1107
1108        let adr = repo.get(2).unwrap();
1109        assert_eq!(adr.status, AdrStatus::Custom("Draft".into()));
1110    }
1111
1112    #[test]
1113    fn test_set_status_adr_not_found() {
1114        let temp = TempDir::new().unwrap();
1115        let repo = Repository::init(temp.path(), None, false).unwrap();
1116
1117        let result = repo.set_status(99, AdrStatus::Accepted, None);
1118        assert!(result.is_err());
1119    }
1120
1121    #[test]
1122    fn test_set_status_superseded_by_not_found() {
1123        let temp = TempDir::new().unwrap();
1124        let repo = Repository::init(temp.path(), None, false).unwrap();
1125        repo.new_adr("Decision").unwrap();
1126
1127        let result = repo.set_status(2, AdrStatus::Superseded, Some(99));
1128        assert!(result.is_err());
1129    }
1130
1131    // ========== Link Tests ==========
1132
1133    #[test]
1134    fn test_link_adrs() {
1135        let temp = TempDir::new().unwrap();
1136        let repo = Repository::init(temp.path(), None, false).unwrap();
1137        repo.new_adr("Second").unwrap();
1138
1139        repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
1140            .unwrap();
1141
1142        let adr1 = repo.get(1).unwrap();
1143        assert_eq!(adr1.links.len(), 1);
1144        assert_eq!(adr1.links[0].target, 2);
1145        assert_eq!(adr1.links[0].kind, LinkKind::Amends);
1146
1147        let adr2 = repo.get(2).unwrap();
1148        assert_eq!(adr2.links.len(), 1);
1149        assert_eq!(adr2.links[0].target, 1);
1150        assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
1151    }
1152
1153    #[test]
1154    fn test_link_relates_to() {
1155        let temp = TempDir::new().unwrap();
1156        let repo = Repository::init(temp.path(), None, false).unwrap();
1157        repo.new_adr("Second").unwrap();
1158
1159        repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
1160            .unwrap();
1161
1162        let adr1 = repo.get(1).unwrap();
1163        assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
1164
1165        let adr2 = repo.get(2).unwrap();
1166        assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
1167    }
1168
1169    // ========== Update Tests ==========
1170
1171    #[test]
1172    fn test_update_adr() {
1173        let temp = TempDir::new().unwrap();
1174        let repo = Repository::init(temp.path(), None, false).unwrap();
1175
1176        let mut adr = repo.get(1).unwrap();
1177        adr.status = AdrStatus::Deprecated;
1178
1179        repo.update(&adr).unwrap();
1180
1181        let updated = repo.get(1).unwrap();
1182        assert_eq!(updated.status, AdrStatus::Deprecated);
1183    }
1184
1185    #[test]
1186    fn test_update_preserves_content() {
1187        let temp = TempDir::new().unwrap();
1188        let repo = Repository::init(temp.path(), None, false).unwrap();
1189
1190        let mut adr = repo.get(1).unwrap();
1191        let original_title = adr.title.clone();
1192        adr.status = AdrStatus::Deprecated;
1193
1194        repo.update(&adr).unwrap();
1195
1196        let updated = repo.get(1).unwrap();
1197        assert_eq!(updated.title, original_title);
1198    }
1199
1200    // ========== Read/Write Content Tests ==========
1201
1202    #[test]
1203    fn test_read_content() {
1204        let temp = TempDir::new().unwrap();
1205        let repo = Repository::init(temp.path(), None, false).unwrap();
1206
1207        let adr = repo.get(1).unwrap();
1208        let content = repo.read_content(&adr).unwrap();
1209
1210        assert!(content.contains("Record architecture decisions"));
1211        assert!(content.contains("## Status"));
1212    }
1213
1214    #[test]
1215    fn test_write_content() {
1216        let temp = TempDir::new().unwrap();
1217        let repo = Repository::init(temp.path(), None, false).unwrap();
1218
1219        let adr = repo.get(1).unwrap();
1220        let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
1221
1222        repo.write_content(&adr, new_content).unwrap();
1223
1224        let content = repo.read_content(&adr).unwrap();
1225        assert!(content.contains("Modified"));
1226    }
1227
1228    // ========== Template Configuration Tests ==========
1229
1230    #[test]
1231    fn test_with_template_format() {
1232        let temp = TempDir::new().unwrap();
1233        let repo = Repository::init(temp.path(), None, false)
1234            .unwrap()
1235            .with_template_format(TemplateFormat::Madr);
1236
1237        let (_, path) = repo.new_adr("MADR Test").unwrap();
1238        let content = fs::read_to_string(path).unwrap();
1239
1240        assert!(content.contains("Context and Problem Statement"));
1241    }
1242
1243    #[test]
1244    fn test_with_custom_template() {
1245        let temp = TempDir::new().unwrap();
1246        let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
1247        let repo = Repository::init(temp.path(), None, false)
1248            .unwrap()
1249            .with_custom_template(custom);
1250
1251        let (_, path) = repo.new_adr("Custom Test").unwrap();
1252        let content = fs::read_to_string(path).unwrap();
1253
1254        assert_eq!(content, "# ADR 2: Custom Test");
1255    }
1256
1257    // ========== Accessor Tests ==========
1258
1259    #[test]
1260    fn test_root() {
1261        let temp = TempDir::new().unwrap();
1262        let repo = Repository::init(temp.path(), None, false).unwrap();
1263
1264        assert_eq!(repo.root(), temp.path());
1265    }
1266
1267    #[test]
1268    fn test_config() {
1269        let temp = TempDir::new().unwrap();
1270        let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
1271
1272        assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
1273        assert!(repo.config().is_next_gen());
1274    }
1275
1276    #[test]
1277    fn test_adr_path() {
1278        let temp = TempDir::new().unwrap();
1279        let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
1280
1281        assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
1282    }
1283
1284    // ========== NextGen Mode Tests ==========
1285
1286    #[test]
1287    fn test_ng_mode_creates_frontmatter() {
1288        let temp = TempDir::new().unwrap();
1289        let repo = Repository::init(temp.path(), None, true).unwrap();
1290
1291        let (_, path) = repo.new_adr("NG Test").unwrap();
1292        let content = fs::read_to_string(path).unwrap();
1293
1294        assert!(content.starts_with("---"));
1295        assert!(content.contains("number: 2"));
1296        assert!(content.contains("title: NG Test"));
1297    }
1298
1299    #[test]
1300    fn test_ng_mode_parses_frontmatter() {
1301        let temp = TempDir::new().unwrap();
1302        let repo = Repository::init(temp.path(), None, true).unwrap();
1303
1304        repo.new_adr("NG ADR").unwrap();
1305
1306        let adr = repo.get(2).unwrap();
1307        assert_eq!(adr.title, "NG ADR");
1308        assert_eq!(adr.number, 2);
1309    }
1310
1311    // ========== Edge Cases ==========
1312
1313    #[test]
1314    fn test_list_empty_after_init_removal() {
1315        let temp = TempDir::new().unwrap();
1316        let repo = Repository::init(temp.path(), None, false).unwrap();
1317
1318        // Remove the initial ADR
1319        fs::remove_file(
1320            repo.adr_path()
1321                .join("0001-record-architecture-decisions.md"),
1322        )
1323        .unwrap();
1324
1325        let adrs = repo.list().unwrap();
1326        assert!(adrs.is_empty());
1327    }
1328
1329    #[test]
1330    fn test_list_ignores_non_adr_files() {
1331        let temp = TempDir::new().unwrap();
1332        let repo = Repository::init(temp.path(), None, false).unwrap();
1333
1334        // Create non-ADR files
1335        fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
1336        fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
1337
1338        let adrs = repo.list().unwrap();
1339        assert_eq!(adrs.len(), 1); // Only the initial ADR
1340    }
1341
1342    #[test]
1343    fn test_special_characters_in_title() {
1344        let temp = TempDir::new().unwrap();
1345        let repo = Repository::init(temp.path(), None, false).unwrap();
1346
1347        let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
1348        assert!(path.exists());
1349        assert_eq!(adr.title, "Use C++ & Rust!");
1350    }
1351
1352    // ========== Metadata Preservation Tests (issue #187) ==========
1353
1354    #[test]
1355    fn test_set_status_preserves_madr_body() {
1356        let temp = TempDir::new().unwrap();
1357        let repo = Repository::init(temp.path(), None, true).unwrap();
1358
1359        let madr_content = r#"---
1360number: 2
1361title: Use Redis for caching
1362date: 2026-01-15
1363status: proposed
1364---
1365
1366# Use Redis for caching
1367
1368## Context and Problem Statement
1369
1370We need a **fast** caching layer for our [API](https://api.example.com).
1371
1372## Considered Options
1373
1374* Redis
1375* Memcached
1376* In-memory cache
1377
1378## Decision Outcome
1379
1380Chosen option: "Redis", because it supports data structures beyond simple key-value.
1381
1382### Consequences
1383
1384* Good, because it provides pub/sub
1385* Bad, because it adds operational complexity
1386
1387## Pros and Cons of the Options
1388
1389### Redis
1390
1391* Good, because it supports complex data types
1392* Bad, because it requires a separate server
1393
1394### Memcached
1395
1396* Good, because it's simpler
1397* Bad, because it only supports strings
1398"#;
1399        let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
1400        fs::write(&adr_path, madr_content).unwrap();
1401
1402        // Change status
1403        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1404
1405        let result = fs::read_to_string(&adr_path).unwrap();
1406
1407        // Status should be updated
1408        assert!(result.contains("status: accepted"));
1409        assert!(!result.contains("status: proposed"));
1410
1411        // Body should be completely preserved
1412        let body_start = result.find("\n# Use Redis").unwrap();
1413        let original_body_start = madr_content.find("\n# Use Redis").unwrap();
1414        assert_eq!(
1415            &result[body_start..],
1416            &madr_content[original_body_start..],
1417            "Body content was modified"
1418        );
1419    }
1420
1421    #[test]
1422    fn test_set_status_preserves_yaml_comments() {
1423        let temp = TempDir::new().unwrap();
1424        let repo = Repository::init(temp.path(), None, true).unwrap();
1425
1426        let content_with_comments = r#"---
1427# SPDX-License-Identifier: MIT
1428# SPDX-FileCopyrightText: 2026 Example Corp
1429number: 2
1430title: Use MADR format
1431date: 2026-01-15
1432status: proposed
1433---
1434
1435## Context and Problem Statement
1436
1437We need a standard ADR format.
1438
1439## Decision Outcome
1440
1441Use MADR 4.0.0.
1442"#;
1443        let adr_path = repo.adr_path().join("0002-use-madr-format.md");
1444        fs::write(&adr_path, content_with_comments).unwrap();
1445
1446        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1447
1448        let result = fs::read_to_string(&adr_path).unwrap();
1449
1450        // YAML comments must be preserved
1451        assert!(
1452            result.contains("# SPDX-License-Identifier: MIT"),
1453            "SPDX comment was destroyed"
1454        );
1455        assert!(
1456            result.contains("# SPDX-FileCopyrightText: 2026 Example Corp"),
1457            "Copyright comment was destroyed"
1458        );
1459        assert!(result.contains("status: accepted"));
1460    }
1461
1462    #[test]
1463    fn test_set_status_preserves_markdown_links() {
1464        let temp = TempDir::new().unwrap();
1465        let repo = Repository::init(temp.path(), None, true).unwrap();
1466
1467        let content = r#"---
1468number: 2
1469title: Use PostgreSQL
1470date: 2026-01-15
1471status: proposed
1472---
1473
1474## Context
1475
1476See the [PostgreSQL docs](https://www.postgresql.org/docs/) for details.
1477
1478Also see [RFC 7159](https://tools.ietf.org/html/rfc7159) and `inline code`.
1479
1480## Decision
1481
1482We will use **PostgreSQL** version `16.x`.
1483
1484## Consequences
1485
1486- [Monitoring guide](https://example.com/monitoring)
1487- Performance benchmarks in [this report](./benchmarks.md)
1488"#;
1489        let adr_path = repo.adr_path().join("0002-use-postgresql.md");
1490        fs::write(&adr_path, content).unwrap();
1491
1492        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1493
1494        let result = fs::read_to_string(&adr_path).unwrap();
1495
1496        assert!(result.contains("[PostgreSQL docs](https://www.postgresql.org/docs/)"));
1497        assert!(result.contains("[RFC 7159](https://tools.ietf.org/html/rfc7159)"));
1498        assert!(result.contains("`inline code`"));
1499        assert!(result.contains("**PostgreSQL**"));
1500        assert!(result.contains("[Monitoring guide](https://example.com/monitoring)"));
1501        assert!(result.contains("[this report](./benchmarks.md)"));
1502    }
1503
1504    #[test]
1505    fn test_link_preserves_body_content() {
1506        let temp = TempDir::new().unwrap();
1507        let repo = Repository::init(temp.path(), None, true).unwrap();
1508
1509        let content_1 = r#"---
1510number: 2
1511title: First decision
1512date: 2026-01-15
1513status: accepted
1514---
1515
1516## Context
1517
1518Custom context with **bold** and [links](https://example.com).
1519
1520## Decision
1521
1522A detailed decision paragraph.
1523
1524## Consequences
1525
1526- Important consequence 1
1527- Important consequence 2
1528"#;
1529        let content_2 = r#"---
1530number: 3
1531title: Second decision
1532date: 2026-01-16
1533status: accepted
1534---
1535
1536## Context
1537
1538Different context entirely.
1539
1540## Decision
1541
1542Another decision.
1543
1544## Consequences
1545
1546None significant.
1547"#;
1548        fs::write(repo.adr_path().join("0002-first-decision.md"), content_1).unwrap();
1549        fs::write(repo.adr_path().join("0003-second-decision.md"), content_2).unwrap();
1550
1551        repo.link(2, 3, LinkKind::Amends, LinkKind::AmendedBy)
1552            .unwrap();
1553
1554        let result_1 = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
1555        let result_2 = fs::read_to_string(repo.adr_path().join("0003-second-decision.md")).unwrap();
1556
1557        // Bodies must be intact
1558        assert!(result_1.contains("Custom context with **bold** and [links](https://example.com)"));
1559        assert!(result_1.contains("A detailed decision paragraph."));
1560        assert!(result_2.contains("Different context entirely."));
1561        assert!(result_2.contains("None significant."));
1562
1563        // Links must be present in frontmatter
1564        assert!(result_1.contains("links:"));
1565        assert!(result_1.contains("target: 3"));
1566        assert!(result_2.contains("links:"));
1567        assert!(result_2.contains("target: 2"));
1568    }
1569
1570    #[test]
1571    fn test_supersede_preserves_old_adr_body() {
1572        let temp = TempDir::new().unwrap();
1573        let repo = Repository::init(temp.path(), None, true).unwrap();
1574
1575        let rich_content = r#"---
1576number: 2
1577title: Original approach
1578date: 2026-01-15
1579status: accepted
1580---
1581
1582## Context and Problem Statement
1583
1584This has **rich** markdown with [links](https://example.com).
1585
1586```rust
1587fn important_code() -> bool {
1588    true
1589}
1590```
1591
1592## Decision Outcome
1593
1594We chose the original approach.
1595
1596| Criteria | Score |
1597|----------|-------|
1598| Speed    | 9/10  |
1599| Safety   | 8/10  |
1600"#;
1601        fs::write(
1602            repo.adr_path().join("0002-original-approach.md"),
1603            rich_content,
1604        )
1605        .unwrap();
1606
1607        repo.supersede("Better approach", 2).unwrap();
1608
1609        let old_content =
1610            fs::read_to_string(repo.adr_path().join("0002-original-approach.md")).unwrap();
1611
1612        // Old ADR body must be preserved
1613        assert!(old_content.contains("```rust"));
1614        assert!(old_content.contains("fn important_code()"));
1615        assert!(old_content.contains("| Criteria | Score |"));
1616        assert!(old_content.contains("[links](https://example.com)"));
1617
1618        // Status and links must be updated
1619        assert!(old_content.contains("status: superseded"));
1620        assert!(old_content.contains("target: 3"));
1621    }
1622
1623    #[test]
1624    fn test_set_status_legacy_preserves_sections() {
1625        let temp = TempDir::new().unwrap();
1626        let repo = Repository::init(temp.path(), None, false).unwrap();
1627
1628        let legacy_content = r#"# 2. Use Rust for backend
1629
1630Date: 2026-01-15
1631
1632## Status
1633
1634Proposed
1635
1636## Context
1637
1638We need a fast, safe language for our backend services.
1639
1640See the [Rust book](https://doc.rust-lang.org/book/) for details.
1641
1642## Decision
1643
1644We will use **Rust** with the `tokio` runtime.
1645
1646```toml
1647[dependencies]
1648tokio = { version = "1", features = ["full"] }
1649```
1650
1651## Consequences
1652
1653- Type safety prevents many bugs at compile time
1654- Learning curve for team members
1655"#;
1656        let adr_path = repo.adr_path().join("0002-use-rust-for-backend.md");
1657        fs::write(&adr_path, legacy_content).unwrap();
1658
1659        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1660
1661        let result = fs::read_to_string(&adr_path).unwrap();
1662
1663        // Status should change
1664        assert!(result.contains("Accepted"));
1665
1666        // Other sections must be preserved exactly
1667        assert!(result.contains("[Rust book](https://doc.rust-lang.org/book/)"));
1668        assert!(result.contains("**Rust**"));
1669        assert!(result.contains("`tokio`"));
1670        assert!(result.contains("```toml"));
1671        assert!(result.contains("tokio = { version = \"1\", features = [\"full\"] }"));
1672        assert!(result.contains("Type safety prevents many bugs"));
1673    }
1674
1675    #[test]
1676    fn test_set_status_frontmatter_with_existing_links() {
1677        let temp = TempDir::new().unwrap();
1678        let repo = Repository::init(temp.path(), None, true).unwrap();
1679
1680        let content = r#"---
1681number: 2
1682title: Updated approach
1683date: 2026-01-15
1684status: proposed
1685links:
1686  - target: 1
1687    kind: amends
1688---
1689
1690## Context
1691
1692Context.
1693
1694## Decision
1695
1696Decision.
1697"#;
1698        let adr_path = repo.adr_path().join("0002-updated-approach.md");
1699        fs::write(&adr_path, content).unwrap();
1700
1701        // Just change status, links should be preserved
1702        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1703
1704        let result = fs::read_to_string(&adr_path).unwrap();
1705        assert!(result.contains("status: accepted"));
1706        assert!(result.contains("links:"));
1707        assert!(result.contains("target: 1"));
1708        assert!(result.contains("kind: amends"));
1709    }
1710
1711    #[test]
1712    fn test_update_metadata_adds_tags_to_frontmatter() {
1713        let temp = TempDir::new().unwrap();
1714        let repo = Repository::init(temp.path(), None, true).unwrap();
1715
1716        let content = r#"---
1717number: 2
1718title: Tagged ADR
1719date: 2026-01-15
1720status: proposed
1721---
1722
1723## Context
1724
1725Context.
1726"#;
1727        let adr_path = repo.adr_path().join("0002-tagged-adr.md");
1728        fs::write(&adr_path, content).unwrap();
1729
1730        let mut adr = repo.get(2).unwrap();
1731        adr.set_tags(vec!["security".into(), "api".into()]);
1732        repo.update_metadata(&adr).unwrap();
1733
1734        let result = fs::read_to_string(&adr_path).unwrap();
1735        assert!(result.contains("tags:"));
1736        assert!(result.contains("  - security"));
1737        assert!(result.contains("  - api"));
1738        // Body preserved
1739        assert!(result.contains("## Context\n\nContext."));
1740    }
1741}