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