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