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        let yaml_block = yaml_block.trim_end_matches('\n');
486        Ok(format!("---\n{}{}", yaml_block, after_yaml))
487    }
488
489    /// Surgically update metadata in a legacy (no-frontmatter) ADR file.
490    ///
491    /// Replaces the content between `## Status` and the next `## ` heading
492    /// with the new status and link lines. All other sections pass through untouched.
493    fn update_legacy_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
494        let lines: Vec<&str> = content.lines().collect();
495        let mut result = String::with_capacity(content.len());
496
497        // Find the ## Status section
498        let status_idx = lines.iter().position(|l| {
499            l.trim().eq_ignore_ascii_case("## Status") || l.trim().eq_ignore_ascii_case("## STATUS")
500        });
501
502        let Some(status_idx) = status_idx else {
503            // No status section found -- just return content unchanged
504            return Ok(content.to_string());
505        };
506
507        // Find the next ## heading after status
508        let next_heading_idx = lines[status_idx + 1..]
509            .iter()
510            .position(|l| l.starts_with("## "))
511            .map(|i| i + status_idx + 1);
512
513        // Write everything before the status section (including the ## Status line)
514        for line in &lines[..=status_idx] {
515            result.push_str(line);
516            result.push('\n');
517        }
518
519        // Write new status content
520        result.push('\n');
521        result.push_str(&adr.status.to_string());
522        result.push('\n');
523
524        // Write link lines with resolved titles
525        let link_titles = self.resolve_link_titles(adr);
526        for link in &adr.links {
527            result.push('\n');
528            if let Some((title, filename)) = link_titles.get(&link.target) {
529                result.push_str(&format!(
530                    "{} [{}. {}]({})",
531                    link.kind, link.target, title, filename
532                ));
533            } else {
534                result.push_str(&format!(
535                    "{} [{}. ...]({:04}-....md)",
536                    link.kind, link.target, link.target
537                ));
538            }
539            result.push('\n');
540        }
541
542        // Write everything from the next heading onward
543        if let Some(next_idx) = next_heading_idx {
544            result.push('\n');
545            for (i, line) in lines[next_idx..].iter().enumerate() {
546                result.push_str(line);
547                // Preserve trailing newline behavior
548                if next_idx + i < lines.len() - 1 || content.ends_with('\n') {
549                    result.push('\n');
550                }
551            }
552        } else if content.ends_with('\n') {
553            // No next heading, but original ended with newline
554        }
555
556        Ok(result)
557    }
558
559    /// Format links as YAML block for frontmatter insertion.
560    fn format_links_yaml(links: &[AdrLink]) -> String {
561        if links.is_empty() {
562            return String::new();
563        }
564        let mut s = String::from("links:\n");
565        for link in links {
566            let kind_str = match &link.kind {
567                LinkKind::Supersedes => "supersedes",
568                LinkKind::SupersededBy => "supersededby",
569                LinkKind::Amends => "amends",
570                LinkKind::AmendedBy => "amendedby",
571                LinkKind::RelatesTo => "relatesto",
572                LinkKind::Custom(c) => c.as_str(),
573            };
574            s.push_str(&format!(
575                "  - target: {}\n    kind: {}\n",
576                link.target, kind_str
577            ));
578        }
579        s
580    }
581
582    /// Format tags as YAML block for frontmatter insertion.
583    fn format_tags_yaml(tags: &[String]) -> String {
584        if tags.is_empty() {
585            return String::new();
586        }
587        let mut s = String::from("tags:\n");
588        for tag in tags {
589            s.push_str(&format!("  - {}\n", tag));
590        }
591        s
592    }
593}
594
595/// Count existing ADR files in a directory.
596fn count_existing_adrs(path: &Path) -> usize {
597    if !path.is_dir() {
598        return 0;
599    }
600
601    fs::read_dir(path)
602        .map(|entries| {
603            entries
604                .filter_map(|e| e.ok())
605                .filter(|e| {
606                    let path = e.path();
607                    path.is_file()
608                        && path.extension().is_some_and(|ext| ext == "md")
609                        && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
610                            // Match NNNN-*.md pattern (adr-tools style)
611                            n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
612                        })
613                })
614                .count()
615        })
616        .unwrap_or(0)
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use tempfile::TempDir;
623
624    // ========== Initialization Tests ==========
625
626    #[test]
627    fn test_init_repository() {
628        let temp = TempDir::new().unwrap();
629        let repo = Repository::init(temp.path(), None, false).unwrap();
630
631        assert!(repo.adr_path().exists());
632        assert!(temp.path().join(".adr-dir").exists());
633
634        let adrs = repo.list().unwrap();
635        assert_eq!(adrs.len(), 1);
636        assert_eq!(adrs[0].number, 1);
637        assert_eq!(adrs[0].title, "Record architecture decisions");
638    }
639
640    #[test]
641    fn test_init_repository_ng() {
642        let temp = TempDir::new().unwrap();
643        let repo = Repository::init(temp.path(), None, true).unwrap();
644
645        assert!(temp.path().join("adrs.toml").exists());
646        assert!(repo.config().is_next_gen());
647    }
648
649    #[test]
650    fn test_init_repository_custom_dir() {
651        let temp = TempDir::new().unwrap();
652        let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
653
654        assert!(temp.path().join("decisions").exists());
655        assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
656    }
657
658    #[test]
659    fn test_init_repository_nested_dir() {
660        let temp = TempDir::new().unwrap();
661        let _repo =
662            Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
663
664        assert!(temp.path().join("docs/architecture/adr").exists());
665    }
666
667    #[test]
668    fn test_init_repository_already_exists_skips_initial_adr() {
669        let temp = TempDir::new().unwrap();
670        Repository::init(temp.path(), None, false).unwrap();
671
672        // Re-init should succeed but not create another ADR
673        let repo = Repository::init(temp.path(), None, false).unwrap();
674        let adrs = repo.list().unwrap();
675        assert_eq!(adrs.len(), 1); // Still just the original initial ADR
676    }
677
678    #[test]
679    fn test_init_with_existing_adrs_skips_initial() {
680        let temp = TempDir::new().unwrap();
681        let adr_dir = temp.path().join("doc/adr");
682        fs::create_dir_all(&adr_dir).unwrap();
683
684        // Create some existing ADR files
685        fs::write(
686            adr_dir.join("0001-existing-decision.md"),
687            "# 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",
688        )
689        .unwrap();
690        fs::write(
691            adr_dir.join("0002-another-decision.md"),
692            "# 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",
693        )
694        .unwrap();
695
696        // Init should succeed and NOT create initial ADR
697        let repo = Repository::init(temp.path(), None, false).unwrap();
698        let adrs = repo.list().unwrap();
699        assert_eq!(adrs.len(), 2); // Only the existing ADRs, no "Record architecture decisions"
700        assert_eq!(adrs[0].title, "Existing Decision");
701        assert_eq!(adrs[1].title, "Another Decision");
702    }
703
704    #[test]
705    fn test_init_creates_first_adr() {
706        let temp = TempDir::new().unwrap();
707        let repo = Repository::init(temp.path(), None, false).unwrap();
708
709        let adr = repo.get(1).unwrap();
710        assert_eq!(adr.title, "Record architecture decisions");
711        assert_eq!(adr.status, AdrStatus::Accepted);
712        assert!(!adr.context.is_empty());
713        assert!(!adr.decision.is_empty());
714        assert!(!adr.consequences.is_empty());
715    }
716
717    // ========== Open Tests ==========
718
719    #[test]
720    fn test_open_repository() {
721        let temp = TempDir::new().unwrap();
722        Repository::init(temp.path(), None, false).unwrap();
723
724        let repo = Repository::open(temp.path()).unwrap();
725        assert_eq!(repo.list().unwrap().len(), 1);
726    }
727
728    #[test]
729    fn test_open_repository_not_found() {
730        let temp = TempDir::new().unwrap();
731        let result = Repository::open(temp.path());
732        assert!(result.is_err());
733    }
734
735    #[test]
736    fn test_open_or_default() {
737        let temp = TempDir::new().unwrap();
738        let repo = Repository::open_or_default(temp.path());
739        assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
740    }
741
742    #[test]
743    fn test_open_or_default_existing() {
744        let temp = TempDir::new().unwrap();
745        Repository::init(temp.path(), Some("custom".into()), false).unwrap();
746
747        let repo = Repository::open_or_default(temp.path());
748        assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
749    }
750
751    // ========== Create and List Tests ==========
752
753    #[test]
754    fn test_create_and_list() {
755        let temp = TempDir::new().unwrap();
756        let repo = Repository::init(temp.path(), None, false).unwrap();
757
758        let (adr, _) = repo.new_adr("Use Rust").unwrap();
759        assert_eq!(adr.number, 2);
760
761        let adrs = repo.list().unwrap();
762        assert_eq!(adrs.len(), 2);
763    }
764
765    #[test]
766    fn test_create_multiple() {
767        let temp = TempDir::new().unwrap();
768        let repo = Repository::init(temp.path(), None, false).unwrap();
769
770        repo.new_adr("Second").unwrap();
771        repo.new_adr("Third").unwrap();
772        repo.new_adr("Fourth").unwrap();
773
774        let adrs = repo.list().unwrap();
775        assert_eq!(adrs.len(), 4);
776        assert_eq!(adrs[0].number, 1);
777        assert_eq!(adrs[1].number, 2);
778        assert_eq!(adrs[2].number, 3);
779        assert_eq!(adrs[3].number, 4);
780    }
781
782    #[test]
783    fn test_list_sorted_by_number() {
784        let temp = TempDir::new().unwrap();
785        let repo = Repository::init(temp.path(), None, false).unwrap();
786
787        repo.new_adr("B").unwrap();
788        repo.new_adr("A").unwrap();
789        repo.new_adr("C").unwrap();
790
791        let adrs = repo.list().unwrap();
792        assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
793    }
794
795    #[test]
796    fn test_next_number() {
797        let temp = TempDir::new().unwrap();
798        let repo = Repository::init(temp.path(), None, false).unwrap();
799
800        assert_eq!(repo.next_number().unwrap(), 2);
801
802        repo.new_adr("Second").unwrap();
803        assert_eq!(repo.next_number().unwrap(), 3);
804    }
805
806    #[test]
807    fn test_create_file_exists() {
808        let temp = TempDir::new().unwrap();
809        let repo = Repository::init(temp.path(), None, false).unwrap();
810
811        let (_, path) = repo.new_adr("Test ADR").unwrap();
812        assert!(path.exists());
813        assert!(path.to_string_lossy().contains("0002-test-adr.md"));
814    }
815
816    // ========== Get and Find Tests ==========
817
818    #[test]
819    fn test_get_by_number() {
820        let temp = TempDir::new().unwrap();
821        let repo = Repository::init(temp.path(), None, false).unwrap();
822        repo.new_adr("Second").unwrap();
823
824        let adr = repo.get(2).unwrap();
825        assert_eq!(adr.title, "Second");
826    }
827
828    #[test]
829    fn test_get_not_found() {
830        let temp = TempDir::new().unwrap();
831        let repo = Repository::init(temp.path(), None, false).unwrap();
832
833        let result = repo.get(99);
834        assert!(result.is_err());
835    }
836
837    #[test]
838    fn test_find_by_number() {
839        let temp = TempDir::new().unwrap();
840        let repo = Repository::init(temp.path(), None, false).unwrap();
841
842        let adr = repo.find("1").unwrap();
843        assert_eq!(adr.number, 1);
844    }
845
846    #[test]
847    fn test_find_by_title() {
848        let temp = TempDir::new().unwrap();
849        let repo = Repository::init(temp.path(), None, false).unwrap();
850
851        let adr = repo.find("architecture").unwrap();
852        assert_eq!(adr.number, 1);
853    }
854
855    #[test]
856    fn test_find_fuzzy_match() {
857        let temp = TempDir::new().unwrap();
858        let repo = Repository::init(temp.path(), None, false).unwrap();
859        repo.new_adr("Use PostgreSQL for database").unwrap();
860        repo.new_adr("Use Redis for caching").unwrap();
861
862        let adr = repo.find("postgres").unwrap();
863        assert!(adr.title.contains("PostgreSQL"));
864    }
865
866    #[test]
867    fn test_find_not_found() {
868        let temp = TempDir::new().unwrap();
869        let repo = Repository::init(temp.path(), None, false).unwrap();
870
871        let result = repo.find("nonexistent");
872        assert!(result.is_err());
873    }
874
875    // ========== Supersede Tests ==========
876
877    #[test]
878    fn test_supersede() {
879        let temp = TempDir::new().unwrap();
880        let repo = Repository::init(temp.path(), None, false).unwrap();
881
882        let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
883        assert_eq!(new_adr.number, 2);
884        assert_eq!(new_adr.links.len(), 1);
885        assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
886
887        let old_adr = repo.get(1).unwrap();
888        assert_eq!(old_adr.status, AdrStatus::Superseded);
889    }
890
891    #[test]
892    fn test_supersede_creates_bidirectional_links() {
893        let temp = TempDir::new().unwrap();
894        let repo = Repository::init(temp.path(), None, false).unwrap();
895
896        repo.supersede("New approach", 1).unwrap();
897
898        let old_adr = repo.get(1).unwrap();
899        assert_eq!(old_adr.links.len(), 1);
900        assert_eq!(old_adr.links[0].target, 2);
901        assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
902
903        let new_adr = repo.get(2).unwrap();
904        assert_eq!(new_adr.links.len(), 1);
905        assert_eq!(new_adr.links[0].target, 1);
906        assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
907    }
908
909    #[test]
910    fn test_supersede_not_found() {
911        let temp = TempDir::new().unwrap();
912        let repo = Repository::init(temp.path(), None, false).unwrap();
913
914        let result = repo.supersede("New", 99);
915        assert!(result.is_err());
916    }
917
918    // ========== Link Resolution Tests (Issue #180) ==========
919
920    #[test]
921    fn test_supersede_generates_functional_links() {
922        let temp = TempDir::new().unwrap();
923        let repo = Repository::init(temp.path(), None, false).unwrap();
924
925        // Create ADR 2, then supersede it with ADR 3
926        repo.new_adr("Use MySQL for persistence").unwrap();
927        repo.supersede("Use PostgreSQL instead", 2).unwrap();
928
929        // Check the new ADR (3) has a functional "Supersedes" link to ADR 2
930        let new_content =
931            fs::read_to_string(repo.adr_path().join("0003-use-postgresql-instead.md")).unwrap();
932        assert!(
933            new_content.contains(
934                "Supersedes [2. Use MySQL for persistence](0002-use-mysql-for-persistence.md)"
935            ),
936            "New ADR should have functional Supersedes link. Got:\n{new_content}"
937        );
938
939        // Check the old ADR (2) has a functional "Superseded by" link to ADR 3
940        let old_content =
941            fs::read_to_string(repo.adr_path().join("0002-use-mysql-for-persistence.md")).unwrap();
942        assert!(
943            old_content.contains(
944                "Superseded by [3. Use PostgreSQL instead](0003-use-postgresql-instead.md)"
945            ),
946            "Old ADR should have functional Superseded by link. Got:\n{old_content}"
947        );
948    }
949
950    #[test]
951    fn test_link_generates_functional_links() {
952        let temp = TempDir::new().unwrap();
953        let repo = Repository::init(temp.path(), None, false).unwrap();
954
955        repo.new_adr("Use REST API").unwrap();
956        repo.new_adr("Use JSON for API responses").unwrap();
957
958        repo.link(3, 2, LinkKind::Amends, LinkKind::AmendedBy)
959            .unwrap();
960
961        // Check source ADR has functional link
962        let source_content =
963            fs::read_to_string(repo.adr_path().join("0003-use-json-for-api-responses.md")).unwrap();
964        assert!(
965            source_content.contains("Amends [2. Use REST API](0002-use-rest-api.md)"),
966            "Source ADR should have functional Amends link. Got:\n{source_content}"
967        );
968
969        // Check target ADR has functional reverse link
970        let target_content =
971            fs::read_to_string(repo.adr_path().join("0002-use-rest-api.md")).unwrap();
972        assert!(
973            target_content.contains(
974                "Amended by [3. Use JSON for API responses](0003-use-json-for-api-responses.md)"
975            ),
976            "Target ADR should have functional Amended by link. Got:\n{target_content}"
977        );
978    }
979
980    #[test]
981    fn test_set_status_superseded_generates_functional_link() {
982        let temp = TempDir::new().unwrap();
983        let repo = Repository::init(temp.path(), None, false).unwrap();
984
985        repo.new_adr("First Decision").unwrap();
986        repo.new_adr("Second Decision").unwrap();
987
988        repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
989
990        let content = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
991        assert!(
992            content.contains("Superseded by [3. Second Decision](0003-second-decision.md)"),
993            "ADR should have functional Superseded by link. Got:\n{content}"
994        );
995    }
996
997    #[test]
998    fn test_supersede_chain_generates_functional_links() {
999        let temp = TempDir::new().unwrap();
1000        let repo = Repository::init(temp.path(), None, false).unwrap();
1001
1002        // ADR 1 is "Record architecture decisions" (from init)
1003        // Create ADR 2
1004        repo.new_adr("Use SQLite").unwrap();
1005        // ADR 3 supersedes ADR 2
1006        repo.supersede("Use PostgreSQL", 2).unwrap();
1007        // ADR 4 supersedes ADR 3
1008        repo.supersede("Use CockroachDB", 3).unwrap();
1009
1010        // Check ADR 3 has both directions
1011        let adr3_content =
1012            fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1013        assert!(
1014            adr3_content.contains("Supersedes [2. Use SQLite](0002-use-sqlite.md)"),
1015            "ADR 3 should supersede ADR 2. Got:\n{adr3_content}"
1016        );
1017        assert!(
1018            adr3_content.contains("Superseded by [4. Use CockroachDB](0004-use-cockroachdb.md)"),
1019            "ADR 3 should be superseded by ADR 4. Got:\n{adr3_content}"
1020        );
1021    }
1022
1023    #[test]
1024    fn test_ng_mode_supersede_generates_functional_links() {
1025        let temp = TempDir::new().unwrap();
1026        let repo = Repository::init(temp.path(), None, true).unwrap();
1027
1028        repo.new_adr("Use MySQL").unwrap();
1029        repo.supersede("Use PostgreSQL", 2).unwrap();
1030
1031        // Check the new ADR has functional links in both frontmatter and body
1032        let new_content =
1033            fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1034
1035        // Body should have functional markdown link
1036        assert!(
1037            new_content.contains("Supersedes [2. Use MySQL](0002-use-mysql.md)"),
1038            "NG mode should have functional link in body. Got:\n{new_content}"
1039        );
1040        // Frontmatter should have structured link
1041        assert!(new_content.contains("links:"));
1042        assert!(new_content.contains("target: 2"));
1043    }
1044
1045    // ========== Set Status Tests ==========
1046
1047    #[test]
1048    fn test_set_status_accepted() {
1049        let temp = TempDir::new().unwrap();
1050        let repo = Repository::init(temp.path(), None, false).unwrap();
1051        repo.new_adr("Test Decision").unwrap();
1052
1053        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1054
1055        let adr = repo.get(2).unwrap();
1056        assert_eq!(adr.status, AdrStatus::Accepted);
1057    }
1058
1059    #[test]
1060    fn test_set_status_deprecated() {
1061        let temp = TempDir::new().unwrap();
1062        let repo = Repository::init(temp.path(), None, false).unwrap();
1063        repo.new_adr("Old Decision").unwrap();
1064
1065        repo.set_status(2, AdrStatus::Deprecated, None).unwrap();
1066
1067        let adr = repo.get(2).unwrap();
1068        assert_eq!(adr.status, AdrStatus::Deprecated);
1069    }
1070
1071    #[test]
1072    fn test_set_status_superseded_with_link() {
1073        let temp = TempDir::new().unwrap();
1074        let repo = Repository::init(temp.path(), None, false).unwrap();
1075        repo.new_adr("First Decision").unwrap();
1076        repo.new_adr("Second Decision").unwrap();
1077
1078        repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
1079
1080        let adr = repo.get(2).unwrap();
1081        assert_eq!(adr.status, AdrStatus::Superseded);
1082        assert_eq!(adr.links.len(), 1);
1083        assert_eq!(adr.links[0].target, 3);
1084        assert_eq!(adr.links[0].kind, LinkKind::SupersededBy);
1085    }
1086
1087    #[test]
1088    fn test_set_status_superseded_without_link() {
1089        let temp = TempDir::new().unwrap();
1090        let repo = Repository::init(temp.path(), None, false).unwrap();
1091        repo.new_adr("Decision").unwrap();
1092
1093        repo.set_status(2, AdrStatus::Superseded, None).unwrap();
1094
1095        let adr = repo.get(2).unwrap();
1096        assert_eq!(adr.status, AdrStatus::Superseded);
1097        assert_eq!(adr.links.len(), 0);
1098    }
1099
1100    #[test]
1101    fn test_set_status_custom() {
1102        let temp = TempDir::new().unwrap();
1103        let repo = Repository::init(temp.path(), None, false).unwrap();
1104        repo.new_adr("Test Decision").unwrap();
1105
1106        repo.set_status(2, AdrStatus::Custom("Draft".into()), None)
1107            .unwrap();
1108
1109        let adr = repo.get(2).unwrap();
1110        assert_eq!(adr.status, AdrStatus::Custom("Draft".into()));
1111    }
1112
1113    #[test]
1114    fn test_set_status_adr_not_found() {
1115        let temp = TempDir::new().unwrap();
1116        let repo = Repository::init(temp.path(), None, false).unwrap();
1117
1118        let result = repo.set_status(99, AdrStatus::Accepted, None);
1119        assert!(result.is_err());
1120    }
1121
1122    #[test]
1123    fn test_set_status_superseded_by_not_found() {
1124        let temp = TempDir::new().unwrap();
1125        let repo = Repository::init(temp.path(), None, false).unwrap();
1126        repo.new_adr("Decision").unwrap();
1127
1128        let result = repo.set_status(2, AdrStatus::Superseded, Some(99));
1129        assert!(result.is_err());
1130    }
1131
1132    // ========== Link Tests ==========
1133
1134    #[test]
1135    fn test_link_adrs() {
1136        let temp = TempDir::new().unwrap();
1137        let repo = Repository::init(temp.path(), None, false).unwrap();
1138        repo.new_adr("Second").unwrap();
1139
1140        repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
1141            .unwrap();
1142
1143        let adr1 = repo.get(1).unwrap();
1144        assert_eq!(adr1.links.len(), 1);
1145        assert_eq!(adr1.links[0].target, 2);
1146        assert_eq!(adr1.links[0].kind, LinkKind::Amends);
1147
1148        let adr2 = repo.get(2).unwrap();
1149        assert_eq!(adr2.links.len(), 1);
1150        assert_eq!(adr2.links[0].target, 1);
1151        assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
1152    }
1153
1154    #[test]
1155    fn test_link_relates_to() {
1156        let temp = TempDir::new().unwrap();
1157        let repo = Repository::init(temp.path(), None, false).unwrap();
1158        repo.new_adr("Second").unwrap();
1159
1160        repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
1161            .unwrap();
1162
1163        let adr1 = repo.get(1).unwrap();
1164        assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
1165
1166        let adr2 = repo.get(2).unwrap();
1167        assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
1168    }
1169
1170    // ========== Update Tests ==========
1171
1172    #[test]
1173    fn test_update_adr() {
1174        let temp = TempDir::new().unwrap();
1175        let repo = Repository::init(temp.path(), None, false).unwrap();
1176
1177        let mut adr = repo.get(1).unwrap();
1178        adr.status = AdrStatus::Deprecated;
1179
1180        repo.update(&adr).unwrap();
1181
1182        let updated = repo.get(1).unwrap();
1183        assert_eq!(updated.status, AdrStatus::Deprecated);
1184    }
1185
1186    #[test]
1187    fn test_update_preserves_content() {
1188        let temp = TempDir::new().unwrap();
1189        let repo = Repository::init(temp.path(), None, false).unwrap();
1190
1191        let mut adr = repo.get(1).unwrap();
1192        let original_title = adr.title.clone();
1193        adr.status = AdrStatus::Deprecated;
1194
1195        repo.update(&adr).unwrap();
1196
1197        let updated = repo.get(1).unwrap();
1198        assert_eq!(updated.title, original_title);
1199    }
1200
1201    // ========== Read/Write Content Tests ==========
1202
1203    #[test]
1204    fn test_read_content() {
1205        let temp = TempDir::new().unwrap();
1206        let repo = Repository::init(temp.path(), None, false).unwrap();
1207
1208        let adr = repo.get(1).unwrap();
1209        let content = repo.read_content(&adr).unwrap();
1210
1211        assert!(content.contains("Record architecture decisions"));
1212        assert!(content.contains("## Status"));
1213    }
1214
1215    #[test]
1216    fn test_write_content() {
1217        let temp = TempDir::new().unwrap();
1218        let repo = Repository::init(temp.path(), None, false).unwrap();
1219
1220        let adr = repo.get(1).unwrap();
1221        let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
1222
1223        repo.write_content(&adr, new_content).unwrap();
1224
1225        let content = repo.read_content(&adr).unwrap();
1226        assert!(content.contains("Modified"));
1227    }
1228
1229    // ========== Template Configuration Tests ==========
1230
1231    #[test]
1232    fn test_with_template_format() {
1233        let temp = TempDir::new().unwrap();
1234        let repo = Repository::init(temp.path(), None, false)
1235            .unwrap()
1236            .with_template_format(TemplateFormat::Madr);
1237
1238        let (_, path) = repo.new_adr("MADR Test").unwrap();
1239        let content = fs::read_to_string(path).unwrap();
1240
1241        assert!(content.contains("Context and Problem Statement"));
1242    }
1243
1244    #[test]
1245    fn test_with_custom_template() {
1246        let temp = TempDir::new().unwrap();
1247        let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
1248        let repo = Repository::init(temp.path(), None, false)
1249            .unwrap()
1250            .with_custom_template(custom);
1251
1252        let (_, path) = repo.new_adr("Custom Test").unwrap();
1253        let content = fs::read_to_string(path).unwrap();
1254
1255        assert_eq!(content, "# ADR 2: Custom Test");
1256    }
1257
1258    // ========== Accessor Tests ==========
1259
1260    #[test]
1261    fn test_root() {
1262        let temp = TempDir::new().unwrap();
1263        let repo = Repository::init(temp.path(), None, false).unwrap();
1264
1265        assert_eq!(repo.root(), temp.path());
1266    }
1267
1268    #[test]
1269    fn test_config() {
1270        let temp = TempDir::new().unwrap();
1271        let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
1272
1273        assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
1274        assert!(repo.config().is_next_gen());
1275    }
1276
1277    #[test]
1278    fn test_adr_path() {
1279        let temp = TempDir::new().unwrap();
1280        let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
1281
1282        assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
1283    }
1284
1285    // ========== NextGen Mode Tests ==========
1286
1287    #[test]
1288    fn test_ng_mode_creates_frontmatter() {
1289        let temp = TempDir::new().unwrap();
1290        let repo = Repository::init(temp.path(), None, true).unwrap();
1291
1292        let (_, path) = repo.new_adr("NG Test").unwrap();
1293        let content = fs::read_to_string(path).unwrap();
1294
1295        assert!(content.starts_with("---"));
1296        assert!(content.contains("number: 2"));
1297        assert!(content.contains("title: NG Test"));
1298    }
1299
1300    #[test]
1301    fn test_ng_mode_parses_frontmatter() {
1302        let temp = TempDir::new().unwrap();
1303        let repo = Repository::init(temp.path(), None, true).unwrap();
1304
1305        repo.new_adr("NG ADR").unwrap();
1306
1307        let adr = repo.get(2).unwrap();
1308        assert_eq!(adr.title, "NG ADR");
1309        assert_eq!(adr.number, 2);
1310    }
1311
1312    // ========== Edge Cases ==========
1313
1314    #[test]
1315    fn test_list_empty_after_init_removal() {
1316        let temp = TempDir::new().unwrap();
1317        let repo = Repository::init(temp.path(), None, false).unwrap();
1318
1319        // Remove the initial ADR
1320        fs::remove_file(
1321            repo.adr_path()
1322                .join("0001-record-architecture-decisions.md"),
1323        )
1324        .unwrap();
1325
1326        let adrs = repo.list().unwrap();
1327        assert!(adrs.is_empty());
1328    }
1329
1330    #[test]
1331    fn test_list_ignores_non_adr_files() {
1332        let temp = TempDir::new().unwrap();
1333        let repo = Repository::init(temp.path(), None, false).unwrap();
1334
1335        // Create non-ADR files
1336        fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
1337        fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
1338
1339        let adrs = repo.list().unwrap();
1340        assert_eq!(adrs.len(), 1); // Only the initial ADR
1341    }
1342
1343    #[test]
1344    fn test_special_characters_in_title() {
1345        let temp = TempDir::new().unwrap();
1346        let repo = Repository::init(temp.path(), None, false).unwrap();
1347
1348        let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
1349        assert!(path.exists());
1350        assert_eq!(adr.title, "Use C++ & Rust!");
1351    }
1352
1353    // ========== Metadata Preservation Tests (issue #187) ==========
1354
1355    #[test]
1356    fn test_set_status_preserves_madr_body() {
1357        let temp = TempDir::new().unwrap();
1358        let repo = Repository::init(temp.path(), None, true).unwrap();
1359
1360        let madr_content = r#"---
1361number: 2
1362title: Use Redis for caching
1363date: 2026-01-15
1364status: proposed
1365---
1366
1367# Use Redis for caching
1368
1369## Context and Problem Statement
1370
1371We need a **fast** caching layer for our [API](https://api.example.com).
1372
1373## Considered Options
1374
1375* Redis
1376* Memcached
1377* In-memory cache
1378
1379## Decision Outcome
1380
1381Chosen option: "Redis", because it supports data structures beyond simple key-value.
1382
1383### Consequences
1384
1385* Good, because it provides pub/sub
1386* Bad, because it adds operational complexity
1387
1388## Pros and Cons of the Options
1389
1390### Redis
1391
1392* Good, because it supports complex data types
1393* Bad, because it requires a separate server
1394
1395### Memcached
1396
1397* Good, because it's simpler
1398* Bad, because it only supports strings
1399"#;
1400        let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
1401        fs::write(&adr_path, madr_content).unwrap();
1402
1403        // Change status
1404        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1405
1406        let result = fs::read_to_string(&adr_path).unwrap();
1407
1408        // Status should be updated
1409        assert!(result.contains("status: accepted"));
1410        assert!(!result.contains("status: proposed"));
1411
1412        // Body should be completely preserved
1413        let body_start = result.find("\n# Use Redis").unwrap();
1414        let original_body_start = madr_content.find("\n# Use Redis").unwrap();
1415        assert_eq!(
1416            &result[body_start..],
1417            &madr_content[original_body_start..],
1418            "Body content was modified"
1419        );
1420    }
1421
1422    #[test]
1423    fn test_set_status_preserves_yaml_comments() {
1424        let temp = TempDir::new().unwrap();
1425        let repo = Repository::init(temp.path(), None, true).unwrap();
1426
1427        let content_with_comments = r#"---
1428# SPDX-License-Identifier: MIT
1429# SPDX-FileCopyrightText: 2026 Example Corp
1430number: 2
1431title: Use MADR format
1432date: 2026-01-15
1433status: proposed
1434---
1435
1436## Context and Problem Statement
1437
1438We need a standard ADR format.
1439
1440## Decision Outcome
1441
1442Use MADR 4.0.0.
1443"#;
1444        let adr_path = repo.adr_path().join("0002-use-madr-format.md");
1445        fs::write(&adr_path, content_with_comments).unwrap();
1446
1447        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1448
1449        let result = fs::read_to_string(&adr_path).unwrap();
1450
1451        // YAML comments must be preserved
1452        assert!(
1453            result.contains("# SPDX-License-Identifier: MIT"),
1454            "SPDX comment was destroyed"
1455        );
1456        assert!(
1457            result.contains("# SPDX-FileCopyrightText: 2026 Example Corp"),
1458            "Copyright comment was destroyed"
1459        );
1460        assert!(result.contains("status: accepted"));
1461    }
1462
1463    #[test]
1464    fn test_set_status_preserves_markdown_links() {
1465        let temp = TempDir::new().unwrap();
1466        let repo = Repository::init(temp.path(), None, true).unwrap();
1467
1468        let content = r#"---
1469number: 2
1470title: Use PostgreSQL
1471date: 2026-01-15
1472status: proposed
1473---
1474
1475## Context
1476
1477See the [PostgreSQL docs](https://www.postgresql.org/docs/) for details.
1478
1479Also see [RFC 7159](https://tools.ietf.org/html/rfc7159) and `inline code`.
1480
1481## Decision
1482
1483We will use **PostgreSQL** version `16.x`.
1484
1485## Consequences
1486
1487- [Monitoring guide](https://example.com/monitoring)
1488- Performance benchmarks in [this report](./benchmarks.md)
1489"#;
1490        let adr_path = repo.adr_path().join("0002-use-postgresql.md");
1491        fs::write(&adr_path, content).unwrap();
1492
1493        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1494
1495        let result = fs::read_to_string(&adr_path).unwrap();
1496
1497        assert!(result.contains("[PostgreSQL docs](https://www.postgresql.org/docs/)"));
1498        assert!(result.contains("[RFC 7159](https://tools.ietf.org/html/rfc7159)"));
1499        assert!(result.contains("`inline code`"));
1500        assert!(result.contains("**PostgreSQL**"));
1501        assert!(result.contains("[Monitoring guide](https://example.com/monitoring)"));
1502        assert!(result.contains("[this report](./benchmarks.md)"));
1503    }
1504
1505    #[test]
1506    fn test_link_preserves_body_content() {
1507        let temp = TempDir::new().unwrap();
1508        let repo = Repository::init(temp.path(), None, true).unwrap();
1509
1510        let content_1 = r#"---
1511number: 2
1512title: First decision
1513date: 2026-01-15
1514status: accepted
1515---
1516
1517## Context
1518
1519Custom context with **bold** and [links](https://example.com).
1520
1521## Decision
1522
1523A detailed decision paragraph.
1524
1525## Consequences
1526
1527- Important consequence 1
1528- Important consequence 2
1529"#;
1530        let content_2 = r#"---
1531number: 3
1532title: Second decision
1533date: 2026-01-16
1534status: accepted
1535---
1536
1537## Context
1538
1539Different context entirely.
1540
1541## Decision
1542
1543Another decision.
1544
1545## Consequences
1546
1547None significant.
1548"#;
1549        fs::write(repo.adr_path().join("0002-first-decision.md"), content_1).unwrap();
1550        fs::write(repo.adr_path().join("0003-second-decision.md"), content_2).unwrap();
1551
1552        repo.link(2, 3, LinkKind::Amends, LinkKind::AmendedBy)
1553            .unwrap();
1554
1555        let result_1 = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
1556        let result_2 = fs::read_to_string(repo.adr_path().join("0003-second-decision.md")).unwrap();
1557
1558        // Bodies must be intact
1559        assert!(result_1.contains("Custom context with **bold** and [links](https://example.com)"));
1560        assert!(result_1.contains("A detailed decision paragraph."));
1561        assert!(result_2.contains("Different context entirely."));
1562        assert!(result_2.contains("None significant."));
1563
1564        // Links must be present in frontmatter
1565        assert!(result_1.contains("links:"));
1566        assert!(result_1.contains("target: 3"));
1567        assert!(result_2.contains("links:"));
1568        assert!(result_2.contains("target: 2"));
1569    }
1570
1571    #[test]
1572    fn test_supersede_preserves_old_adr_body() {
1573        let temp = TempDir::new().unwrap();
1574        let repo = Repository::init(temp.path(), None, true).unwrap();
1575
1576        let rich_content = r#"---
1577number: 2
1578title: Original approach
1579date: 2026-01-15
1580status: accepted
1581---
1582
1583## Context and Problem Statement
1584
1585This has **rich** markdown with [links](https://example.com).
1586
1587```rust
1588fn important_code() -> bool {
1589    true
1590}
1591```
1592
1593## Decision Outcome
1594
1595We chose the original approach.
1596
1597| Criteria | Score |
1598|----------|-------|
1599| Speed    | 9/10  |
1600| Safety   | 8/10  |
1601"#;
1602        fs::write(
1603            repo.adr_path().join("0002-original-approach.md"),
1604            rich_content,
1605        )
1606        .unwrap();
1607
1608        repo.supersede("Better approach", 2).unwrap();
1609
1610        let old_content =
1611            fs::read_to_string(repo.adr_path().join("0002-original-approach.md")).unwrap();
1612
1613        // Old ADR body must be preserved
1614        assert!(old_content.contains("```rust"));
1615        assert!(old_content.contains("fn important_code()"));
1616        assert!(old_content.contains("| Criteria | Score |"));
1617        assert!(old_content.contains("[links](https://example.com)"));
1618
1619        // Status and links must be updated
1620        assert!(old_content.contains("status: superseded"));
1621        assert!(old_content.contains("target: 3"));
1622    }
1623
1624    #[test]
1625    fn test_set_status_legacy_preserves_sections() {
1626        let temp = TempDir::new().unwrap();
1627        let repo = Repository::init(temp.path(), None, false).unwrap();
1628
1629        let legacy_content = r#"# 2. Use Rust for backend
1630
1631Date: 2026-01-15
1632
1633## Status
1634
1635Proposed
1636
1637## Context
1638
1639We need a fast, safe language for our backend services.
1640
1641See the [Rust book](https://doc.rust-lang.org/book/) for details.
1642
1643## Decision
1644
1645We will use **Rust** with the `tokio` runtime.
1646
1647```toml
1648[dependencies]
1649tokio = { version = "1", features = ["full"] }
1650```
1651
1652## Consequences
1653
1654- Type safety prevents many bugs at compile time
1655- Learning curve for team members
1656"#;
1657        let adr_path = repo.adr_path().join("0002-use-rust-for-backend.md");
1658        fs::write(&adr_path, legacy_content).unwrap();
1659
1660        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1661
1662        let result = fs::read_to_string(&adr_path).unwrap();
1663
1664        // Status should change
1665        assert!(result.contains("Accepted"));
1666
1667        // Other sections must be preserved exactly
1668        assert!(result.contains("[Rust book](https://doc.rust-lang.org/book/)"));
1669        assert!(result.contains("**Rust**"));
1670        assert!(result.contains("`tokio`"));
1671        assert!(result.contains("```toml"));
1672        assert!(result.contains("tokio = { version = \"1\", features = [\"full\"] }"));
1673        assert!(result.contains("Type safety prevents many bugs"));
1674    }
1675
1676    #[test]
1677    fn test_set_status_frontmatter_with_existing_links() {
1678        let temp = TempDir::new().unwrap();
1679        let repo = Repository::init(temp.path(), None, true).unwrap();
1680
1681        let content = r#"---
1682number: 2
1683title: Updated approach
1684date: 2026-01-15
1685status: proposed
1686links:
1687  - target: 1
1688    kind: amends
1689---
1690
1691## Context
1692
1693Context.
1694
1695## Decision
1696
1697Decision.
1698"#;
1699        let adr_path = repo.adr_path().join("0002-updated-approach.md");
1700        fs::write(&adr_path, content).unwrap();
1701
1702        // Just change status, links should be preserved
1703        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1704
1705        let result = fs::read_to_string(&adr_path).unwrap();
1706        assert!(result.contains("status: accepted"));
1707        assert!(result.contains("links:"));
1708        assert!(result.contains("target: 1"));
1709        assert!(result.contains("kind: amends"));
1710        // No extra blank line before closing ---
1711        assert!(
1712            !result.contains("\n\n---"),
1713            "Should not have extra blank line before closing ---: {:?}",
1714            result
1715        );
1716    }
1717
1718    #[test]
1719    fn test_set_status_no_extra_newline_before_separator() {
1720        let temp = TempDir::new().unwrap();
1721        let repo = Repository::init(temp.path(), None, true).unwrap();
1722
1723        let content = "---\nnumber: 2\ntitle: Test\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
1724        let adr_path = repo.adr_path().join("0002-test.md");
1725        fs::write(&adr_path, content).unwrap();
1726
1727        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1728
1729        let result = fs::read_to_string(&adr_path).unwrap();
1730        assert!(result.contains("status: accepted"));
1731        // Frontmatter should close cleanly without extra blank line (#192)
1732        assert!(
1733            result.contains("\n---\n"),
1734            "Should have clean closing separator: {:?}",
1735            result
1736        );
1737        assert!(
1738            !result.contains("\n\n---"),
1739            "Should not have extra blank line before closing ---: {:?}",
1740            result
1741        );
1742    }
1743
1744    #[test]
1745    fn test_update_metadata_adds_tags_to_frontmatter() {
1746        let temp = TempDir::new().unwrap();
1747        let repo = Repository::init(temp.path(), None, true).unwrap();
1748
1749        let content = r#"---
1750number: 2
1751title: Tagged ADR
1752date: 2026-01-15
1753status: proposed
1754---
1755
1756## Context
1757
1758Context.
1759"#;
1760        let adr_path = repo.adr_path().join("0002-tagged-adr.md");
1761        fs::write(&adr_path, content).unwrap();
1762
1763        let mut adr = repo.get(2).unwrap();
1764        adr.set_tags(vec!["security".into(), "api".into()]);
1765        repo.update_metadata(&adr).unwrap();
1766
1767        let result = fs::read_to_string(&adr_path).unwrap();
1768        assert!(result.contains("tags:"));
1769        assert!(result.contains("  - security"));
1770        assert!(result.contains("  - api"));
1771        // Body preserved
1772        assert!(result.contains("## Context\n\nContext."));
1773    }
1774}