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 serde_yaml_neo::{Mapping, Value};
11use std::collections::HashMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14use walkdir::WalkDir;
15
16/// Selects which ADR body sections to patch on [`Repository::update`].
17///
18/// `None` for a field means leave that section's on-disk bytes untouched.
19/// `Some(text)` replaces the targeted portion (for MADR 4.0.0 `## Decision Outcome`,
20/// only the intro before `###` subsections unless `consequences` is also set).
21///
22/// # Migration from pre-`BodySectionPatch` `update(&adr)`
23///
24/// The previous `Repository::update(&adr)` re-rendered body sections from
25/// `adr.context` / `adr.decision` / `adr.consequences`. An empty
26/// [`BodySectionPatch::default()`] does **not** do that: it only updates
27/// metadata. To change body text, put the new content in the corresponding
28/// patch field; values on `adr` alone are ignored for body content.
29#[derive(Debug, Default, Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub struct BodySectionPatch {
32    /// Patch the context section (`## Context` / `## Context and Problem Statement`).
33    pub context: Option<String>,
34    /// Patch the decision intro (`## Decision` / `## Decision Outcome` body before H3 subsections).
35    pub decision: Option<String>,
36    /// Patch consequences (`## Consequences`, or MADR `### Consequences` under Decision Outcome).
37    pub consequences: Option<String>,
38}
39
40impl BodySectionPatch {
41    /// Create an empty patch (metadata-only when passed to [`Repository::update`]).
42    pub fn new() -> Self {
43        Self {
44            context: None,
45            decision: None,
46            consequences: None,
47        }
48    }
49
50    /// Returns true when no body sections should be modified.
51    pub fn is_empty(&self) -> bool {
52        self.context.is_none() && self.decision.is_none() && self.consequences.is_none()
53    }
54
55    /// Set the context section patch.
56    pub fn with_context(mut self, text: impl Into<String>) -> Self {
57        self.context = Some(text.into());
58        self
59    }
60
61    /// Set the decision section patch.
62    pub fn with_decision(mut self, text: impl Into<String>) -> Self {
63        self.decision = Some(text.into());
64        self
65    }
66
67    /// Set the consequences section patch.
68    pub fn with_consequences(mut self, text: impl Into<String>) -> Self {
69        self.consequences = Some(text.into());
70        self
71    }
72}
73
74/// Describes what [`Repository::renumber`] changed (or, with `dry_run: true`,
75/// would change) so the caller can print a plan or a report without needing
76/// to inspect the filesystem itself.
77#[derive(Debug, Default, Clone)]
78#[non_exhaustive]
79pub struct RenumberResult {
80    /// The source number.
81    pub from: u32,
82    /// The destination number.
83    pub to: u32,
84    /// True when `from == to`; every other field is left at its default.
85    pub no_op: bool,
86    /// The renumbered record's old and new file paths.
87    pub renamed_file: Option<(PathBuf, PathBuf)>,
88    /// True when the record's own frontmatter `number` field was rewritten
89    /// (nextgen mode only).
90    pub frontmatter_updated: bool,
91    /// True when the record's own H1 heading was rewritten. Nygard-style
92    /// templates number the H1 (`# 3. Title`); MADR's bare `# Title` has no
93    /// number and is never rewritten.
94    pub h1_updated: bool,
95    /// Paths of other records whose inbound references (frontmatter
96    /// `links[].target` and/or a rendered body markdown link) were rewritten
97    /// to point at `to` instead of `from`.
98    pub updated_references: Vec<PathBuf>,
99    /// Paths outside the ADR directory that still mention the old filename.
100    /// Informational only -- never rewritten.
101    pub prose_warnings: Vec<PathBuf>,
102    /// Paths of records holding a frontmatter `links[].target` equal to `from`
103    /// that were deliberately left alone because `from` was a duplicate, so
104    /// the reference could have meant either record. Informational only.
105    pub ambiguous_references: Vec<PathBuf>,
106}
107
108/// A repository of Architecture Decision Records.
109#[derive(Debug)]
110pub struct Repository {
111    /// The root directory of the project.
112    root: PathBuf,
113
114    /// Configuration for this repository.
115    config: Config,
116
117    /// Parser for reading ADRs.
118    parser: Parser,
119
120    /// Template engine for creating ADRs.
121    template_engine: TemplateEngine,
122}
123
124impl Repository {
125    /// Open an existing repository at the given root.
126    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
127        let root = root.into();
128        let config = Config::load(&root)?;
129        let template_engine = Self::engine_from_config(&config);
130
131        Ok(Self {
132            root,
133            config,
134            parser: Parser::new(),
135            template_engine,
136        })
137    }
138
139    /// Open a repository, or create default config if not found.
140    pub fn open_or_default(root: impl Into<PathBuf>) -> Self {
141        let root = root.into();
142        let config = Config::load_or_default(&root);
143        let template_engine = Self::engine_from_config(&config);
144
145        Self {
146            root,
147            config,
148            parser: Parser::new(),
149            template_engine,
150        }
151    }
152
153    /// Initialize a new repository at the given root.
154    ///
155    /// If a config file (`adrs.toml` or `.adr-dir`) already exists at `root`
156    /// and its configured `adr_dir` already matches the resolved directory,
157    /// the file is left untouched -- including settings this function does
158    /// not otherwise know about, such as `default_status`, `templates`,
159    /// `generate`, `export`, and `doctor`. Otherwise a fresh config is
160    /// written for the resolved directory and mode, as before.
161    pub fn init(root: impl Into<PathBuf>, adr_dir: Option<PathBuf>, ng: bool) -> Result<Self> {
162        let root = root.into();
163        let adr_dir = adr_dir.unwrap_or_else(|| PathBuf::from(crate::config::DEFAULT_ADR_DIR));
164
165        let legacy_path = root.join(crate::config::LEGACY_CONFIG_FILE);
166        let toml_path = root.join(crate::config::CONFIG_FILE);
167
168        // Reuse an existing config file if it already points at the resolved
169        // directory, so we don't silently discard settings it carries.
170        let existing_config = if legacy_path.exists() || toml_path.exists() {
171            Config::load(&root).ok().filter(|c| c.adr_dir == adr_dir)
172        } else {
173            None
174        };
175
176        let adr_path = root.join(&adr_dir);
177
178        // Check if directory exists and count existing ADRs
179        let existing_adrs = if adr_path.exists() {
180            count_existing_adrs(&adr_path)
181        } else {
182            // Create the directory
183            fs::create_dir_all(&adr_path)?;
184            0
185        };
186
187        let config = match existing_config {
188            Some(existing) => existing,
189            None => {
190                // Create config
191                let config = Config {
192                    adr_dir,
193                    mode: if ng {
194                        ConfigMode::NextGen
195                    } else {
196                        ConfigMode::Compatible
197                    },
198                    ..Default::default()
199                };
200                // Config::load() always prefers adrs.toml over .adr-dir, so
201                // a stale sibling in the other format could silently shadow
202                // the file we're about to write. Remove it so only one
203                // config file is authoritative after init.
204                let stale = if config.is_next_gen() {
205                    &legacy_path
206                } else {
207                    &toml_path
208                };
209                if stale.exists() {
210                    fs::remove_file(stale)?;
211                }
212                config.save(&root)?;
213                config
214            }
215        };
216
217        let template_engine = Self::engine_from_config(&config);
218
219        let repo = Self {
220            root,
221            config,
222            parser: Parser::new(),
223            template_engine,
224        };
225
226        // Only create initial ADR if no ADRs exist
227        if existing_adrs == 0 {
228            let mut adr = Adr::new(1, crate::init_adr::TITLE);
229            adr.status = AdrStatus::Accepted;
230            adr.context = crate::init_adr::CONTEXT.into();
231            adr.decision = crate::init_adr::DECISION.into();
232            adr.consequences = crate::init_adr::CONSEQUENCES.into();
233            repo.create(&adr)?;
234        }
235
236        Ok(repo)
237    }
238
239    /// Get the repository root path.
240    pub fn root(&self) -> &Path {
241        &self.root
242    }
243
244    /// Get the configuration.
245    pub fn config(&self) -> &Config {
246        &self.config
247    }
248
249    /// Get the full path to the ADR directory.
250    pub fn adr_path(&self) -> PathBuf {
251        self.config.adr_path(&self.root)
252    }
253
254    /// Build a template engine that respects the config's template format.
255    fn engine_from_config(config: &Config) -> TemplateEngine {
256        let mut engine = TemplateEngine::new();
257        if let Some(ref fmt) = config.templates.format
258            && let Ok(format) = fmt.parse::<TemplateFormat>()
259        {
260            engine = engine.with_format(format);
261        }
262        engine
263    }
264
265    /// Set the template format.
266    pub fn with_template_format(mut self, format: TemplateFormat) -> Self {
267        self.template_engine = self.template_engine.with_format(format);
268        self
269    }
270
271    /// Set the template variant.
272    pub fn with_template_variant(mut self, variant: TemplateVariant) -> Self {
273        self.template_engine = self.template_engine.with_variant(variant);
274        self
275    }
276
277    /// Override the configuration mode.
278    pub fn with_mode(mut self, mode: ConfigMode) -> Self {
279        self.config.mode = mode;
280        self
281    }
282
283    /// Set a custom template.
284    pub fn with_custom_template(mut self, template: Template) -> Self {
285        self.template_engine = self.template_engine.with_custom_template(template);
286        self
287    }
288
289    /// List all ADRs in the repository.
290    pub fn list(&self) -> Result<Vec<Adr>> {
291        let adr_path = self.adr_path();
292        if !adr_path.exists() {
293            return Err(Error::AdrDirNotFound);
294        }
295
296        let mut adrs: Vec<Adr> = WalkDir::new(&adr_path)
297            .max_depth(1)
298            .into_iter()
299            .filter_map(|e| e.ok())
300            .filter(|e| {
301                e.path().extension().is_some_and(|ext| ext == "md")
302                    && e.path()
303                        .file_name()
304                        .and_then(|n| n.to_str())
305                        .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
306            })
307            .filter_map(|e| self.parser.parse_file(e.path()).ok())
308            .collect();
309
310        adrs.sort_by_key(|a| a.number);
311        Ok(adrs)
312    }
313
314    /// List all ADRs, also returning parse errors for files that look like ADRs
315    /// but failed to parse.
316    ///
317    /// This is used by the `doctor` command to report files that could not be
318    /// parsed (e.g., invalid frontmatter).
319    #[allow(clippy::type_complexity)]
320    pub fn list_with_errors(&self) -> Result<(Vec<Adr>, Vec<(PathBuf, crate::Error)>)> {
321        let adr_path = self.adr_path();
322        if !adr_path.exists() {
323            return Err(Error::AdrDirNotFound);
324        }
325
326        let mut adrs = Vec::new();
327        let mut errors = Vec::new();
328
329        let candidates: Vec<_> = WalkDir::new(&adr_path)
330            .max_depth(1)
331            .into_iter()
332            .filter_map(|e| e.ok())
333            .filter(|e| {
334                e.path().extension().is_some_and(|ext| ext == "md")
335                    && e.path()
336                        .file_name()
337                        .and_then(|n| n.to_str())
338                        .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
339            })
340            .collect();
341
342        for entry in candidates {
343            match self.parser.parse_file(entry.path()) {
344                Ok(adr) => adrs.push(adr),
345                Err(e) => errors.push((entry.path().to_path_buf(), e)),
346            }
347        }
348
349        adrs.sort_by_key(|a| a.number);
350        Ok((adrs, errors))
351    }
352
353    /// Get the next available ADR number.
354    pub fn next_number(&self) -> Result<u32> {
355        let adrs = self.list()?;
356        Ok(adrs.last().map(|a| a.number + 1).unwrap_or(1))
357    }
358
359    /// Find an ADR by number.
360    pub fn get(&self, number: u32) -> Result<Adr> {
361        let adrs = self.list()?;
362        adrs.into_iter()
363            .find(|a| a.number == number)
364            .ok_or_else(|| Error::AdrNotFound(number.to_string()))
365    }
366
367    /// Find an ADR by query (number or fuzzy title match).
368    pub fn find(&self, query: &str) -> Result<Adr> {
369        // Try parsing as number first
370        if let Ok(number) = query.parse::<u32>() {
371            return self.get(number);
372        }
373
374        // Fuzzy match on title
375        let adrs = self.list()?;
376        let matcher = SkimMatcherV2::default();
377
378        let mut matches: Vec<_> = adrs
379            .into_iter()
380            .filter_map(|adr| {
381                let score = matcher.fuzzy_match(&adr.title, query)?;
382                Some((adr, score))
383            })
384            .collect();
385
386        matches.sort_by_key(|m| std::cmp::Reverse(m.1));
387
388        match matches.len() {
389            0 => Err(Error::AdrNotFound(query.to_string())),
390            1 => Ok(matches.remove(0).0),
391            _ => {
392                // If top match is significantly better, use it
393                if matches[0].1 > matches[1].1 * 2 {
394                    Ok(matches.remove(0).0)
395                } else {
396                    Err(Error::AmbiguousAdr {
397                        query: query.to_string(),
398                        matches: matches
399                            .iter()
400                            .take(5)
401                            .map(|(a, _)| a.title.clone())
402                            .collect(),
403                    })
404                }
405            }
406        }
407    }
408
409    /// Resolve link target titles and filenames for an ADR's links.
410    fn resolve_link_titles(&self, adr: &Adr) -> HashMap<u32, (String, String)> {
411        let mut map = HashMap::new();
412        for link in &adr.links {
413            if map.contains_key(&link.target) {
414                continue;
415            }
416            if let Ok(target_adr) = self.get(link.target) {
417                map.insert(
418                    link.target,
419                    (target_adr.title.clone(), Self::link_href(&target_adr)),
420                );
421            }
422        }
423        map
424    }
425
426    /// The href to use when rendering a link to `target_adr`.
427    ///
428    /// Prefers the target's actual on-disk filename over a filename
429    /// re-derived from its title. The two can diverge (hand-named files,
430    /// files renamed after a title edit), and re-deriving produces hrefs
431    /// that point at files that don't exist (#325). Falls back to the
432    /// title-derived filename only when the target has no resolvable path.
433    fn link_href(target_adr: &Adr) -> String {
434        target_adr
435            .path
436            .as_ref()
437            .and_then(|p| p.file_name())
438            .and_then(|f| f.to_str())
439            .map(str::to_string)
440            .unwrap_or_else(|| target_adr.filename())
441    }
442
443    /// Create a new ADR.
444    pub fn create(&self, adr: &Adr) -> Result<PathBuf> {
445        let path = self.adr_path().join(adr.filename());
446
447        let link_titles = self.resolve_link_titles(adr);
448        let content = self
449            .template_engine
450            .render(adr, &self.config, &link_titles)?;
451        fs::write(&path, content)?;
452
453        Ok(path)
454    }
455
456    /// Create a new ADR with the given title.
457    pub fn new_adr(&self, title: impl Into<String>) -> Result<(Adr, PathBuf)> {
458        let number = self.next_number()?;
459        let mut adr = Adr::new(number, title);
460        if let Some(default_status) = self.config.default_status.as_deref() {
461            adr.status = default_status.parse::<AdrStatus>().unwrap();
462        }
463        let path = self.create(&adr)?;
464        Ok((adr, path))
465    }
466
467    /// Create a new ADR that supersedes another.
468    pub fn supersede(&self, title: impl Into<String>, superseded: u32) -> Result<(Adr, PathBuf)> {
469        let number = self.next_number()?;
470        let mut adr = Adr::new(number, title);
471        adr.add_link(AdrLink::new(superseded, LinkKind::Supersedes));
472
473        // Create the new ADR first so its file exists on disk when
474        // the old ADR's "Superseded by" link is resolved.
475        let path = self.create(&adr)?;
476
477        // Now update the superseded ADR — the new ADR is on disk so
478        // its title and filename can be resolved for the link.
479        let mut old_adr = self.get(superseded)?;
480        old_adr.status = AdrStatus::Superseded;
481        old_adr.add_link(AdrLink::new(number, LinkKind::SupersededBy));
482        self.update_metadata(&old_adr)?;
483
484        Ok((adr, path))
485    }
486
487    /// Change the status of an ADR.
488    ///
489    /// If the new status is `Superseded` and `superseded_by` is provided,
490    /// a superseded-by link will be added automatically.
491    pub fn set_status(
492        &self,
493        number: u32,
494        status: AdrStatus,
495        superseded_by: Option<u32>,
496    ) -> Result<PathBuf> {
497        // Reject empty or whitespace-only custom statuses. Serializing one
498        // yields a YAML null that fails to deserialize, silently dropping the
499        // ADR from `list()` (see issue #305).
500        if let AdrStatus::Custom(s) = &status
501            && s.trim().is_empty()
502        {
503            return Err(Error::InvalidStatus(
504                "status cannot be empty or whitespace-only".to_string(),
505            ));
506        }
507
508        let mut adr = self.get(number)?;
509        adr.status = status.clone();
510
511        // If superseded by another ADR, add the link
512        if let (AdrStatus::Superseded, Some(by)) = (&status, superseded_by) {
513            // Check that the superseding ADR exists
514            let _ = self.get(by)?;
515
516            // Add superseded-by link if not already present
517            if !adr
518                .links
519                .iter()
520                .any(|l| matches!(l.kind, LinkKind::SupersededBy) && l.target == by)
521            {
522                adr.add_link(AdrLink::new(by, LinkKind::SupersededBy));
523            }
524        }
525
526        self.update_metadata(&adr)
527    }
528
529    /// Link two ADRs together.
530    pub fn link(
531        &self,
532        source: u32,
533        target: u32,
534        source_kind: LinkKind,
535        target_kind: LinkKind,
536    ) -> Result<()> {
537        let mut source_adr = self.get(source)?;
538        let mut target_adr = self.get(target)?;
539
540        source_adr.add_link(AdrLink::new(target, source_kind));
541        target_adr.add_link(AdrLink::new(source, target_kind));
542
543        self.update_metadata(&source_adr)?;
544        self.update_metadata(&target_adr)?;
545
546        Ok(())
547    }
548
549    /// Repair a duplicate or misassigned ADR number by moving `from` to `to`.
550    ///
551    /// Renumbering touches four things and gets all four right in a single
552    /// pass: the filename, the record's own frontmatter `number` (nextgen)
553    /// and H1 heading, and every *other* record's inbound reference to it
554    /// (frontmatter `links[].target` and rendered body markdown links).
555    /// Records are edited surgically in place -- never re-rendered from a
556    /// template -- so hand-written content that doesn't round-trip through
557    /// the template survives (see #310).
558    ///
559    /// # Preconditions (checked before any write)
560    ///
561    /// 1. `from` is resolved via [`Self::list`], not [`Self::get`], because
562    ///    `get` silently returns only the first match for a number and the
563    ///    motivating scenario is a duplicate number. Zero matches is an
564    ///    error. More than one match with no `file` given is an error
565    ///    listing every candidate path. If `file` is given, it must match
566    ///    one of the candidates.
567    /// 2. `from == to` is a no-op: returns immediately with
568    ///    [`RenumberResult::no_op`] set, before any occupancy check.
569    /// 3. `to` must be free. If occupied, the error names the occupying
570    ///    record and suggests the smallest free number.
571    ///
572    /// # Writes
573    ///
574    /// When `dry_run` is `false` and every precondition passes: the file is
575    /// renamed, the record's own frontmatter `number` and H1 are updated,
576    /// and every other record whose frontmatter links or body markdown links
577    /// reference the old number/filename are rewritten. A record with no
578    /// reference to `from` at all is left byte-for-byte untouched. CRLF line
579    /// endings are preserved throughout (see #339/#340).
580    ///
581    /// After a successful renumber (dry run or not), the rest of the
582    /// repository root is scanned for the old filename outside the ADR
583    /// directory (skipping `.git`, `target`, `node_modules`); matches are
584    /// reported in [`RenumberResult::prose_warnings`] but never rewritten.
585    ///
586    /// With `dry_run: true`, nothing on disk changes; the returned
587    /// [`RenumberResult`] describes exactly what would have happened.
588    pub fn renumber(
589        &self,
590        from: u32,
591        to: u32,
592        file: Option<&Path>,
593        dry_run: bool,
594    ) -> Result<RenumberResult> {
595        let all = self.list()?;
596
597        let candidates: Vec<&Adr> = all.iter().filter(|a| a.number == from).collect();
598        if candidates.is_empty() {
599            return Err(Error::AdrNotFound(from.to_string()));
600        }
601        // More than one record holds `from`, so inbound references that name it
602        // by number cannot be attributed to either one. Drives the decision to
603        // report such references rather than rewrite them, below.
604        let source_was_ambiguous = candidates.len() > 1;
605
606        let source_adr: Adr = if let Some(file) = file {
607            let file_canon = fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
608            let found = candidates.iter().find(|a| {
609                a.path
610                    .as_ref()
611                    .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
612                    == Some(file_canon.clone())
613            });
614            match found {
615                Some(adr) => (*adr).clone(),
616                None => {
617                    return Err(Error::RenumberFileMismatch {
618                        number: from,
619                        file: file.to_path_buf(),
620                        candidates: Self::renumber_candidate_paths(&candidates),
621                    });
622                }
623            }
624        } else if candidates.len() > 1 {
625            return Err(Error::AmbiguousRenumberSource {
626                number: from,
627                candidates: Self::renumber_candidate_paths(&candidates),
628            });
629        } else {
630            candidates[0].clone()
631        };
632
633        if from == to {
634            return Ok(RenumberResult {
635                from,
636                to,
637                no_op: true,
638                ..Default::default()
639            });
640        }
641
642        if let Some(occupant) = all.iter().find(|a| a.number == to) {
643            return Err(Error::RenumberTargetOccupied {
644                to,
645                occupant_title: occupant.title.clone(),
646                occupant_path: occupant.path.clone().unwrap_or_default(),
647                suggestion: Self::smallest_free_number(&all),
648            });
649        }
650
651        // -- Everything above validates; everything below only computes new
652        // -- content in memory. No file is written until every computation
653        // -- below has succeeded, so a failure here leaves the repository
654        // -- completely untouched.
655
656        let old_path = source_adr
657            .path
658            .clone()
659            .ok_or_else(|| Error::InvalidFormat {
660                path: PathBuf::new(),
661                reason: format!("ADR {from} has no on-disk path"),
662            })?;
663        let old_filename = old_path
664            .file_name()
665            .and_then(|f| f.to_str())
666            .ok_or_else(|| Error::InvalidFormat {
667                path: old_path.clone(),
668                reason: "filename is not valid UTF-8".into(),
669            })?
670            .to_string();
671        let prefix = format!("{from:04}-");
672        let slug_part =
673            old_filename
674                .strip_prefix(prefix.as_str())
675                .ok_or_else(|| Error::InvalidFormat {
676                    path: old_path.clone(),
677                    reason: format!("filename does not start with '{prefix}'"),
678                })?;
679        let new_filename = format!("{to:04}-{slug_part}");
680        let new_path = self.adr_path().join(&new_filename);
681
682        // The renumbered record's own file: rewrite frontmatter `number`
683        // (nextgen only) and the H1 heading.
684        let original_own_content = fs::read_to_string(&old_path)?;
685        let mut target_adr = source_adr.clone();
686        target_adr.number = to;
687
688        let after_frontmatter = if Self::has_frontmatter(&original_own_content) {
689            self.update_frontmatter_metadata(&target_adr, &original_own_content)?
690        } else {
691            original_own_content.clone()
692        };
693        let frontmatter_updated = after_frontmatter != original_own_content;
694
695        let after_h1 = Self::rewrite_h1_number(&after_frontmatter, from, to);
696        let h1_updated = after_h1.is_some();
697        let final_own_content = after_h1.unwrap_or(after_frontmatter);
698
699        // Every other record: rewrite frontmatter `links[].target == from`
700        // and any rendered body markdown link pointing at the old filename.
701        //
702        // The source is excluded by path, not by number. When `from` is a
703        // duplicate, the record left behind still carries that number, and
704        // any reference it holds to the file being renamed has to be
705        // rewritten like anyone else's. Excluding by number would skip it and
706        // leave the reference dangling.
707        let mut updated_references = Vec::new();
708        let mut ambiguous_references = Vec::new();
709        let mut pending_writes: Vec<(PathBuf, String)> = Vec::new();
710
711        for adr in all
712            .iter()
713            .filter(|a| a.path.as_deref() != Some(old_path.as_path()))
714        {
715            let Some(path) = adr.path.clone() else {
716                continue;
717            };
718            let original = fs::read_to_string(&path)?;
719            let mut working = original.clone();
720            let mut this_changed = false;
721
722            if Self::has_frontmatter(&working) && adr.links.iter().any(|l| l.target == from) {
723                if source_was_ambiguous {
724                    // `from` was held by more than one record, so a link
725                    // targeting it by number could have meant either. The
726                    // record left behind keeps the number, so leaving the
727                    // link alone preserves the reading that is still valid;
728                    // rewriting it would silently repoint a correct
729                    // relationship at the record that moved. Report instead.
730                    ambiguous_references.push(path.clone());
731                } else {
732                    let mut mutated_adr = adr.clone();
733                    for link in mutated_adr.links.iter_mut() {
734                        if link.target == from {
735                            link.target = to;
736                        }
737                    }
738                    let rewritten = self.update_frontmatter_metadata(&mutated_adr, &working)?;
739                    if rewritten != working {
740                        working = rewritten;
741                        this_changed = true;
742                    }
743                }
744            }
745
746            if let Some(rewritten) =
747                Self::rewrite_body_link_references(&working, to, &old_filename, &new_filename)
748            {
749                working = rewritten;
750                this_changed = true;
751            }
752
753            if this_changed {
754                updated_references.push(path.clone());
755                pending_writes.push((path, working));
756            }
757        }
758
759        if !dry_run {
760            fs::rename(&old_path, &new_path)?;
761            fs::write(&new_path, &final_own_content)?;
762            for (path, content) in &pending_writes {
763                fs::write(path, content)?;
764            }
765        }
766
767        let prose_warnings = self.scan_prose_references(&old_filename);
768
769        Ok(RenumberResult {
770            from,
771            to,
772            no_op: false,
773            renamed_file: Some((old_path, new_path)),
774            frontmatter_updated,
775            h1_updated,
776            updated_references,
777            prose_warnings,
778            ambiguous_references,
779        })
780    }
781
782    /// Format renumber candidates as `path (display)` strings for error messages.
783    fn renumber_candidate_paths(candidates: &[&Adr]) -> Vec<String> {
784        candidates
785            .iter()
786            .map(|a| {
787                a.path
788                    .as_ref()
789                    .map(|p| p.display().to_string())
790                    .unwrap_or_else(|| format!("<no path for ADR {}>", a.number))
791            })
792            .collect()
793    }
794
795    /// The smallest ADR number not currently in use (fills gaps, unlike
796    /// [`Self::next_number`] which always appends after the highest number).
797    fn smallest_free_number(adrs: &[Adr]) -> u32 {
798        let existing: std::collections::HashSet<u32> = adrs.iter().map(|a| a.number).collect();
799        let mut n = 1;
800        while existing.contains(&n) {
801            n += 1;
802        }
803        n
804    }
805
806    /// Rewrite the record's own H1 heading from `# {from}. Title` to `# {to}. Title`.
807    ///
808    /// Only the first H1 line is considered, and only when it carries an
809    /// explicit number prefix (Nygard-style templates render `# {{ number }}.
810    /// {{ title }}`). MADR's bare `# {{ title }}` H1 has no number and is
811    /// correctly left untouched. Returns `None` when nothing changed;
812    /// preserves the file's CRLF/LF line ending either way.
813    fn rewrite_h1_number(content: &str, from: u32, to: u32) -> Option<String> {
814        let crlf = content.contains("\r\n");
815        let normalized = if crlf {
816            std::borrow::Cow::Owned(content.replace("\r\n", "\n"))
817        } else {
818            std::borrow::Cow::Borrowed(content)
819        };
820
821        let old_prefix = format!("# {from}. ");
822        let new_prefix = format!("# {to}. ");
823
824        let mut found_h1 = false;
825        let mut changed = false;
826        let mut result = String::with_capacity(normalized.len() + 4);
827
828        for (i, line) in normalized.split('\n').enumerate() {
829            if i > 0 {
830                result.push('\n');
831            }
832            if !found_h1 && line.starts_with("# ") {
833                found_h1 = true;
834                if let Some(rest) = line.strip_prefix(old_prefix.as_str()) {
835                    result.push_str(&new_prefix);
836                    result.push_str(rest);
837                    changed = true;
838                    continue;
839                }
840            }
841            result.push_str(line);
842        }
843
844        if !changed {
845            return None;
846        }
847
848        Some(if crlf {
849            result.replace('\n', "\r\n")
850        } else {
851            result
852        })
853    }
854
855    /// Rewrite markdown links in `content` whose href is exactly
856    /// `old_filename`, pointing them at `new_filename` instead, and updating
857    /// the ADR number in the link text where present
858    /// (`[3. Title](0003-slug.md)` -> `[4. Title](0004-slug.md)`). Link text
859    /// with no leading number is left as-is aside from the href.
860    ///
861    /// Returns `None` when `content` has no reference to `old_filename` at
862    /// all, so a record unrelated to the renumbered one is never rewritten.
863    /// Preserves CRLF/LF.
864    fn rewrite_body_link_references(
865        content: &str,
866        to: u32,
867        old_filename: &str,
868        new_filename: &str,
869    ) -> Option<String> {
870        if !content.contains(old_filename) {
871            return None;
872        }
873
874        let crlf = content.contains("\r\n");
875        let normalized = if crlf {
876            std::borrow::Cow::Owned(content.replace("\r\n", "\n"))
877        } else {
878            std::borrow::Cow::Borrowed(content)
879        };
880
881        let link_pattern = format!(r"\[([^\]]*)\]\({}\)", regex::escape(old_filename));
882        let link_re = Regex::new(&link_pattern).expect("valid regex");
883        let number_prefix_re = Regex::new(r"^(\d+)(\..*)?$").expect("valid regex");
884
885        let mut changed = false;
886        let rewritten = link_re
887            .replace_all(&normalized, |caps: &regex::Captures| {
888                changed = true;
889                let text = caps.get(1).map(|m| m.as_str()).unwrap_or("");
890                let new_text = match number_prefix_re.captures(text) {
891                    Some(tc) => format!("{to}{}", tc.get(2).map(|m| m.as_str()).unwrap_or("")),
892                    None => text.to_string(),
893                };
894                format!("[{new_text}]({new_filename})")
895            })
896            .into_owned();
897
898        if !changed {
899            return None;
900        }
901
902        Some(if crlf {
903            rewritten.replace('\n', "\r\n")
904        } else {
905            rewritten
906        })
907    }
908
909    /// Scan the repository root (outside the ADR directory) for files that
910    /// still mention `old_filename`, informationally. Skips `.git`,
911    /// `target`, and `node_modules`. Never rewrites anything; the caller is
912    /// expected to surface the results as a warning.
913    fn scan_prose_references(&self, old_filename: &str) -> Vec<PathBuf> {
914        let adr_dir = self.adr_path();
915        let mut matches: Vec<PathBuf> = WalkDir::new(&self.root)
916            .into_iter()
917            .filter_entry(|e| {
918                if !e.file_type().is_dir() {
919                    return true;
920                }
921                !matches!(
922                    e.file_name().to_str(),
923                    Some(".git") | Some("target") | Some("node_modules")
924                )
925            })
926            .filter_map(|e| e.ok())
927            .filter(|e| e.file_type().is_file())
928            .map(|e| e.path().to_path_buf())
929            .filter(|p| !p.starts_with(&adr_dir))
930            .filter(|p| {
931                fs::read_to_string(p)
932                    .map(|content| content.contains(old_filename))
933                    .unwrap_or(false)
934            })
935            .collect();
936
937        matches.sort();
938        matches
939    }
940
941    /// Update an existing ADR.
942    ///
943    /// When `body` is non-empty, only the listed body sections are patched in place;
944    /// metadata bytes on disk are left unchanged. When `body` is empty, metadata
945    /// (status, links, tags, and MADR 4.0.0 frontmatter fields) is updated via the
946    /// same path as [`Self::update_metadata`].
947    ///
948    /// Empty `body` does **not** re-render context/decision/consequences from `adr`
949    /// (unlike the pre-`BodySectionPatch` API). Mutating those fields on `adr` and
950    /// calling `update(&adr, BodySectionPatch::default())` writes metadata only;
951    /// body text on disk is unchanged. Pass the new text in `body` to patch sections.
952    ///
953    /// `adr.title` and `adr.date` are not written by this method.
954    ///
955    /// Unlisted body sections are left byte-for-byte unchanged on disk, including MADR
956    /// `### Consequences` / `### Confirmation` subsections under `## Decision Outcome`.
957    pub fn update(&self, adr: &Adr, body: BodySectionPatch) -> Result<PathBuf> {
958        let path = adr
959            .path
960            .clone()
961            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
962
963        let content = fs::read_to_string(&path)?;
964
965        let content = if body.is_empty() {
966            if Self::has_frontmatter(&content) {
967                self.update_frontmatter_metadata(adr, &content)?
968            } else {
969                self.update_legacy_metadata(adr, &content)?
970            }
971        } else {
972            content
973        };
974
975        let updated = if body.is_empty() {
976            content
977        } else {
978            self.update_body_sections(&content, &body)?
979        };
980        fs::write(&path, updated)?;
981
982        Ok(path)
983    }
984
985    /// Read the content of an ADR file.
986    pub fn read_content(&self, adr: &Adr) -> Result<String> {
987        let path = adr
988            .path
989            .as_ref()
990            .cloned()
991            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
992
993        Ok(fs::read_to_string(path)?)
994    }
995
996    /// Write content to an ADR file.
997    pub fn write_content(&self, adr: &Adr, content: &str) -> Result<PathBuf> {
998        let path = adr
999            .path
1000            .as_ref()
1001            .cloned()
1002            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
1003
1004        fs::write(&path, content)?;
1005        Ok(path)
1006    }
1007
1008    /// Update only the metadata (status, links, tags) of an existing ADR file,
1009    /// preserving all other content byte-for-byte.
1010    pub fn update_metadata(&self, adr: &Adr) -> Result<PathBuf> {
1011        let path = adr
1012            .path
1013            .clone()
1014            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
1015
1016        let content = fs::read_to_string(&path)?;
1017
1018        let updated = if Self::has_frontmatter(&content) {
1019            self.update_frontmatter_metadata(adr, &content)?
1020        } else {
1021            self.update_legacy_metadata(adr, &content)?
1022        };
1023
1024        fs::write(&path, updated)?;
1025        Ok(path)
1026    }
1027
1028    /// Whether `content` opens with a YAML frontmatter delimiter, tolerating
1029    /// either LF or CRLF line endings so CRLF NextGen files are routed to
1030    /// [`Self::update_frontmatter_metadata`] instead of falling through to
1031    /// the legacy `## Status` splice (which would silently no-op on them).
1032    fn has_frontmatter(content: &str) -> bool {
1033        content.starts_with("---\n") || content.starts_with("---\r\n")
1034    }
1035
1036    /// Update managed metadata fields in a YAML frontmatter file.
1037    ///
1038    /// Parses the frontmatter into a YAML [`Mapping`], mutates managed keys
1039    /// (`number`, `status`, `links`, `tags`, and MADR people fields), and
1040    /// re-emits the mapping. Unknown keys are preserved; the markdown body is
1041    /// untouched. When no managed field changes, the original file bytes are
1042    /// returned completely untouched, regardless of line ending.
1043    ///
1044    /// `number` is managed so [`Self::renumber`] can rewrite it through this
1045    /// same surgical path rather than a dedicated one. Every other caller
1046    /// passes an `Adr` whose `number` was parsed from this same file (fetched
1047    /// via [`Self::get`] or [`Self::list`] and never mutated before the
1048    /// metadata write), so the comparison below is a no-op for them.
1049    ///
1050    /// Re-emitting via a standard YAML parser may drop YAML comments (e.g. SPDX
1051    /// headers) when any managed field changes — see ADR 0006.
1052    ///
1053    /// Line endings: when a change *is* written, the file's dominant ending
1054    /// (CRLF if `content` contains any `\r\n`, else LF; see
1055    /// [`Self::update_body_sections`] for the same rule on body patches) is
1056    /// detected up front. Parsing and re-emission both work against an
1057    /// LF-normalized copy, and the final result is converted back to the
1058    /// detected ending as a single pass, so a rewritten CRLF file stays CRLF
1059    /// throughout, including the frontmatter delimiters and the untouched
1060    /// markdown body. Mixed-ending input is normalized to the dominant ending.
1061    fn update_frontmatter_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
1062        let crlf = content.contains("\r\n");
1063        let normalized = if crlf {
1064            std::borrow::Cow::Owned(content.replace("\r\n", "\n"))
1065        } else {
1066            std::borrow::Cow::Borrowed(content)
1067        };
1068        let normalized: &str = &normalized;
1069
1070        // Split into frontmatter and body at the closing `---`
1071        let Some(rest) = normalized.strip_prefix("---\n") else {
1072            return Err(Error::InvalidFormat {
1073                path: Default::default(),
1074                reason: "Missing opening frontmatter delimiter".into(),
1075            });
1076        };
1077
1078        let Some(end_idx) = rest.find("\n---\n").or_else(|| {
1079            // Handle case where closing delimiter is at end of file with no trailing newline
1080            if rest.ends_with("\n---") {
1081                Some(rest.len() - 3)
1082            } else {
1083                None
1084            }
1085        }) else {
1086            return Err(Error::InvalidFormat {
1087                path: Default::default(),
1088                reason: "Missing closing frontmatter delimiter".into(),
1089            });
1090        };
1091
1092        let yaml_block = &rest[..end_idx + 1]; // include trailing \n
1093        let after_yaml = &rest[end_idx..]; // starts with \n---\n...
1094
1095        let parsed: Value = serde_yaml_neo::from_str(yaml_block)?;
1096        let Value::Mapping(mut map) = parsed else {
1097            return Err(Error::InvalidFormat {
1098                path: Default::default(),
1099                reason: "Frontmatter YAML must be a mapping".into(),
1100            });
1101        };
1102
1103        let mut dirty = false;
1104
1105        // 1. number (see #356 renumber support)
1106        let number_val = serde_yaml_neo::to_value(adr.number)?;
1107        if map.get(Self::yaml_str_key("number")) != Some(&number_val) {
1108            map.insert(Self::yaml_str_key("number"), number_val);
1109            dirty = true;
1110        }
1111
1112        // 2. status
1113        let status_val = Value::String(adr.status.to_string().to_lowercase());
1114        if map.get(Self::yaml_str_key("status")) != Some(&status_val) {
1115            map.insert(Self::yaml_str_key("status"), status_val);
1116            dirty = true;
1117        }
1118
1119        // 3. links
1120        if Self::set_yaml_sequence_field(&mut map, "links", &adr.links)? {
1121            dirty = true;
1122        }
1123
1124        // 4. tags (string-or-list on disk)
1125        if !Self::yaml_string_list_matches(&map, "tags", &adr.tags)
1126            && Self::set_yaml_string_list_field(&mut map, "tags", &adr.tags)?
1127        {
1128            dirty = true;
1129        }
1130
1131        // 5. MADR people fields (string-or-list on disk; leave Value untouched when
1132        // semantically equal so block scalars survive no-op metadata writes).
1133        if !Self::yaml_string_list_matches(&map, "decision-makers", &adr.decision_makers)
1134            && Self::set_yaml_string_list_field(&mut map, "decision-makers", &adr.decision_makers)?
1135        {
1136            dirty = true;
1137        }
1138        if !Self::yaml_string_list_matches(&map, "consulted", &adr.consulted)
1139            && Self::set_yaml_string_list_field(&mut map, "consulted", &adr.consulted)?
1140        {
1141            dirty = true;
1142        }
1143        if !Self::yaml_string_list_matches(&map, "informed", &adr.informed)
1144            && Self::set_yaml_string_list_field(&mut map, "informed", &adr.informed)?
1145        {
1146            dirty = true;
1147        }
1148
1149        if !dirty {
1150            return Ok(content.to_string());
1151        }
1152
1153        let new_yaml = serde_yaml_neo::to_string(&Value::Mapping(map))?;
1154        let new_yaml = new_yaml.trim_end_matches('\n');
1155        let result = format!("---\n{new_yaml}{after_yaml}");
1156        if crlf {
1157            Ok(result.replace('\n', "\r\n"))
1158        } else {
1159            Ok(result)
1160        }
1161    }
1162
1163    /// Surgically update metadata in a legacy (no-frontmatter) ADR file.
1164    ///
1165    /// Replaces the content between `## Status` and the next `## ` heading
1166    /// with the new status and link lines. All other sections pass through untouched.
1167    fn update_legacy_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
1168        // Preserve the file's dominant line ending. `content.lines()` strips both
1169        // `\n` and `\r\n`, so re-emitting with a fixed `\n` would silently convert
1170        // a CRLF legacy file to LF on every metadata write (`adrs status`,
1171        // `adrs link`). Detect the ending once (CRLF if any `\r\n` is present, else
1172        // LF) and re-emit every line with it; a CRLF file whose metadata is
1173        // unchanged round-trips byte-for-byte. Matches `update_body_sections`.
1174        let ending = if content.contains("\r\n") {
1175            "\r\n"
1176        } else {
1177            "\n"
1178        };
1179        let lines: Vec<&str> = content.lines().collect();
1180        let mut result = String::with_capacity(content.len());
1181
1182        // Find the ## Status section
1183        let status_idx = lines.iter().position(|l| {
1184            l.trim().eq_ignore_ascii_case("## Status") || l.trim().eq_ignore_ascii_case("## STATUS")
1185        });
1186
1187        let Some(status_idx) = status_idx else {
1188            // No status section found -- just return content unchanged
1189            return Ok(content.to_string());
1190        };
1191
1192        // Find the next ## heading after status
1193        let next_heading_idx = lines[status_idx + 1..]
1194            .iter()
1195            .position(|l| l.starts_with("## "))
1196            .map(|i| i + status_idx + 1);
1197
1198        // Write everything before the status section (including the ## Status line)
1199        for line in &lines[..=status_idx] {
1200            result.push_str(line);
1201            result.push_str(ending);
1202        }
1203
1204        // Write new status content
1205        result.push_str(ending);
1206        result.push_str(&adr.status.to_string());
1207        result.push_str(ending);
1208
1209        // Write link lines with resolved titles
1210        let link_titles = self.resolve_link_titles(adr);
1211        for link in &adr.links {
1212            result.push_str(ending);
1213            if let Some((title, filename)) = link_titles.get(&link.target) {
1214                result.push_str(&format!(
1215                    "{} [{}. {}]({})",
1216                    link.kind, link.target, title, filename
1217                ));
1218            } else {
1219                result.push_str(&format!(
1220                    "{} [{}. ...]({:04}-....md)",
1221                    link.kind, link.target, link.target
1222                ));
1223            }
1224            result.push_str(ending);
1225        }
1226
1227        // Write everything from the next heading onward
1228        if let Some(next_idx) = next_heading_idx {
1229            result.push_str(ending);
1230            for (i, line) in lines[next_idx..].iter().enumerate() {
1231                result.push_str(line);
1232                // Preserve trailing newline behavior
1233                if next_idx + i < lines.len() - 1 || content.ends_with('\n') {
1234                    result.push_str(ending);
1235                }
1236            }
1237        } else if content.ends_with('\n') {
1238            // No next heading, but original ended with newline
1239        }
1240
1241        Ok(result)
1242    }
1243
1244    /// Patch only the body sections listed in `patch`, preserving everything else.
1245    ///
1246    /// Line endings: the file's dominant ending is detected once up front (CRLF
1247    /// if `content` contains any `\r\n`, else LF) and every line this method
1248    /// emits — copied-through lines and patched section bodies alike — is
1249    /// re-emitted with that ending, so a CRLF file stays CRLF end to end
1250    /// (frontmatter, untouched sections, and patched text). `patch` text
1251    /// itself always arrives with `\n` line endings and is converted on
1252    /// emit. Mixed-ending input is normalized to the dominant ending.
1253    fn update_body_sections(&self, content: &str, patch: &BodySectionPatch) -> Result<String> {
1254        let ending = if content.contains("\r\n") {
1255            "\r\n"
1256        } else {
1257            "\n"
1258        };
1259        let lines: Vec<&str> = content.lines().collect();
1260        let mut result = String::with_capacity(content.len());
1261        let mut i = 0;
1262        let mut found_context = patch.context.is_none();
1263        let mut found_decision = patch.decision.is_none();
1264        let mut found_consequences = patch.consequences.is_none();
1265
1266        while i < lines.len() {
1267            let line = lines[i];
1268            if Self::is_h2_outside_fence(&lines, i)
1269                && let Some(heading_text) = line.strip_prefix("## ")
1270                && let Some(field) = crate::parse::canonical_section_field(heading_text.trim())
1271            {
1272                result.push_str(line);
1273                result.push_str(ending);
1274                i += 1;
1275                let body_end = Self::next_h2_index(&lines, i);
1276                // A heading immediately follows the patched/copied body unless
1277                // this section runs to EOF; used to add exactly one blank line
1278                // separator after replacement text (see `write_section_body`).
1279                let next_is_heading = body_end < lines.len();
1280
1281                match field {
1282                    "context" => {
1283                        if patch.context.is_some() {
1284                            found_context = true;
1285                        }
1286                        if let Some(ref text) = patch.context {
1287                            Self::write_section_body(&mut result, text, ending, next_is_heading);
1288                        } else {
1289                            Self::append_lines(&mut result, &lines, i, body_end, content, ending);
1290                        }
1291                    }
1292                    "decision" => {
1293                        let madr_decision_outcome =
1294                            heading_text.trim().eq_ignore_ascii_case("Decision Outcome");
1295                        let (decision_found, consequences_applied) = Self::patch_decision_section(
1296                            &mut result,
1297                            &lines,
1298                            content,
1299                            i,
1300                            body_end,
1301                            patch,
1302                            madr_decision_outcome,
1303                            ending,
1304                        );
1305                        if decision_found {
1306                            found_decision = true;
1307                        }
1308                        if consequences_applied {
1309                            found_consequences = true;
1310                        }
1311                    }
1312                    "consequences" => {
1313                        if patch.consequences.is_some() {
1314                            found_consequences = true;
1315                        }
1316                        if let Some(ref text) = patch.consequences {
1317                            Self::write_section_body(&mut result, text, ending, next_is_heading);
1318                        } else {
1319                            Self::append_lines(&mut result, &lines, i, body_end, content, ending);
1320                        }
1321                    }
1322                    _ => {
1323                        Self::append_lines(&mut result, &lines, i, body_end, content, ending);
1324                    }
1325                }
1326
1327                i = body_end;
1328                continue;
1329            }
1330
1331            result.push_str(line);
1332            if i < lines.len() - 1 || content.ends_with('\n') {
1333                result.push_str(ending);
1334            }
1335            i += 1;
1336        }
1337
1338        if !found_context {
1339            return Err(Error::InvalidFormat {
1340                path: PathBuf::new(),
1341                reason: "context patch requested but no matching section heading found".into(),
1342            });
1343        }
1344        if !found_decision {
1345            return Err(Error::InvalidFormat {
1346                path: PathBuf::new(),
1347                reason: "decision patch requested but no matching section heading found".into(),
1348            });
1349        }
1350        if !found_consequences {
1351            return Err(Error::InvalidFormat {
1352                path: PathBuf::new(),
1353                reason: "consequences patch requested but no matching section heading found".into(),
1354            });
1355        }
1356
1357        if content.ends_with('\n') && !result.ends_with('\n') {
1358            result.push_str(ending);
1359        }
1360
1361        Ok(result)
1362    }
1363
1364    /// CommonMark fence opener/closer: 0–3 leading spaces, then a run of ≥3
1365    /// `` ` `` or `~`. Lines indented ≥4 spaces are indented code, not fences.
1366    fn fence_run(line: &str) -> Option<(char, usize)> {
1367        let indent = line.chars().take_while(|c| *c == ' ').count();
1368        if indent >= 4 {
1369            return None;
1370        }
1371        let rest = &line[indent..];
1372        let ch = rest.chars().next()?;
1373        if ch != '`' && ch != '~' {
1374            return None;
1375        }
1376        let run = rest.chars().take_while(|c| *c == ch).count();
1377        if run < 3 {
1378            return None;
1379        }
1380        Some((ch, run))
1381    }
1382
1383    /// Whether `line` can close an open fence of `(ch, open_len)` (same character,
1384    /// run length ≥ opener, only whitespace after the run).
1385    fn is_fence_close(line: &str, ch: char, open_len: usize) -> bool {
1386        let Some((close_ch, run)) = Self::fence_run(line) else {
1387            return false;
1388        };
1389        if close_ch != ch || run < open_len {
1390            return false;
1391        }
1392        let indent = line.chars().take_while(|c| *c == ' ').count();
1393        let after_run = &line[indent + run..];
1394        after_run.chars().all(|c| c == ' ' || c == '\t')
1395    }
1396
1397    fn in_fence_at_line(lines: &[&str], index: usize) -> bool {
1398        let mut open: Option<(char, usize)> = None;
1399        for line in &lines[..index] {
1400            match open {
1401                None => {
1402                    if let Some((ch, run)) = Self::fence_run(line) {
1403                        open = Some((ch, run));
1404                    }
1405                }
1406                Some((ch, open_len)) => {
1407                    if Self::is_fence_close(line, ch, open_len) {
1408                        open = None;
1409                    }
1410                }
1411            }
1412        }
1413        open.is_some()
1414    }
1415
1416    fn is_h2_outside_fence(lines: &[&str], index: usize) -> bool {
1417        lines[index].starts_with("## ") && !Self::in_fence_at_line(lines, index)
1418    }
1419
1420    fn is_h3_outside_fence(lines: &[&str], index: usize) -> bool {
1421        lines[index].starts_with("### ") && !Self::in_fence_at_line(lines, index)
1422    }
1423
1424    fn next_h2_index(lines: &[&str], start: usize) -> usize {
1425        lines[start..]
1426            .iter()
1427            .enumerate()
1428            .find(|(offset, _)| Self::is_h2_outside_fence(lines, start + offset))
1429            .map(|(offset, _)| start + offset)
1430            .unwrap_or(lines.len())
1431    }
1432
1433    fn append_lines(
1434        result: &mut String,
1435        lines: &[&str],
1436        start: usize,
1437        end: usize,
1438        content: &str,
1439        ending: &str,
1440    ) {
1441        for (offset, line) in lines[start..end].iter().enumerate() {
1442            result.push_str(line);
1443            if start + offset < end - 1 || end < lines.len() || content.ends_with('\n') {
1444                result.push_str(ending);
1445            }
1446        }
1447    }
1448
1449    /// Write a patched section body right after its just-emitted heading line.
1450    ///
1451    /// `text` always arrives with `\n` line endings regardless of the on-disk
1452    /// convention; it is normalized and re-emitted with `ending` (see
1453    /// [`Self::update_body_sections`]). Exactly one blank line separates the
1454    /// heading from the body, matching conventional ADR formatting. When
1455    /// `blank_line_after` is true — the patched section is immediately
1456    /// followed by another heading, either a copied-through one or one this
1457    /// function's caller is about to emit — a second blank line follows the
1458    /// body so the heading isn't butted up against the replacement text
1459    /// ("text\n## Next" becoming "text\n\n## Next"). No such line is added
1460    /// when the section runs to EOF, so patching the last section — or
1461    /// re-patching the same text twice — never accumulates a trailing blank.
1462    fn write_section_body(result: &mut String, text: &str, ending: &str, blank_line_after: bool) {
1463        result.push_str(ending);
1464        if ending == "\n" {
1465            result.push_str(text);
1466        } else {
1467            result.push_str(&text.replace("\r\n", "\n").replace('\n', ending));
1468        }
1469        if !text.ends_with('\n') {
1470            result.push_str(ending);
1471        }
1472        if blank_line_after {
1473            result.push_str(ending);
1474        }
1475    }
1476
1477    fn is_consequences_h3(line: &str) -> bool {
1478        line.strip_prefix("### ")
1479            .is_some_and(|title| title.trim().eq_ignore_ascii_case("consequences"))
1480    }
1481
1482    fn is_consequences_h2(line: &str) -> bool {
1483        line.strip_prefix("## ")
1484            .is_some_and(|title| title.trim().eq_ignore_ascii_case("consequences"))
1485    }
1486
1487    /// Patch `## Decision` / `## Decision Outcome`, preserving MADR H3 subsections
1488    /// unless `patch.decision` or `patch.consequences` targets them.
1489    ///
1490    /// Returns `(found_decision, consequences_applied)`.
1491    #[allow(clippy::too_many_arguments)]
1492    fn patch_decision_section(
1493        result: &mut String,
1494        lines: &[&str],
1495        content: &str,
1496        body_start: usize,
1497        body_end: usize,
1498        patch: &BodySectionPatch,
1499        madr_decision_outcome: bool,
1500        ending: &str,
1501    ) -> (bool, bool) {
1502        if patch.decision.is_none() && patch.consequences.is_none() {
1503            Self::append_lines(result, lines, body_start, body_end, content, ending);
1504            return (false, false);
1505        }
1506
1507        let mut consequences_applied = false;
1508
1509        // Top-level `## Consequences` H2 (anywhere outside fences) is patched
1510        // separately. Leave Decision bytes untouched when only consequences is
1511        // set and such an H2 exists — including before Decision Outcome.
1512        if patch.decision.is_none()
1513            && patch.consequences.is_some()
1514            && Self::has_consequences_h2(lines)
1515        {
1516            Self::append_lines(result, lines, body_start, body_end, content, ending);
1517            return (true, false);
1518        }
1519
1520        let body = &lines[body_start..body_end];
1521        let first_h3 = body
1522            .iter()
1523            .enumerate()
1524            .find(|(offset, _)| Self::is_h3_outside_fence(lines, body_start + offset))
1525            .map(|(offset, _)| body_start + offset);
1526        let intro_end = first_h3.unwrap_or(body_end);
1527
1528        // Whether the (about to be replaced) intro is immediately followed by
1529        // a synthesized `### Consequences` heading — only possible when there
1530        // are no H3 subsections at all.
1531        let will_append_madr_consequences = intro_end >= body_end
1532            && patch.consequences.is_some()
1533            && !Self::has_consequences_h2(lines)
1534            && madr_decision_outcome;
1535
1536        if let Some(ref text) = patch.decision {
1537            let blank_line_after = if intro_end < body_end {
1538                // An H3 subsection heading follows immediately.
1539                true
1540            } else {
1541                will_append_madr_consequences || body_end < lines.len()
1542            };
1543            Self::write_section_body(result, text, ending, blank_line_after);
1544        } else {
1545            Self::append_lines(result, lines, body_start, intro_end, content, ending);
1546        }
1547
1548        if intro_end >= body_end {
1549            if let Some(ref text) = patch.consequences
1550                && !Self::has_consequences_h2(lines)
1551                && madr_decision_outcome
1552            {
1553                Self::write_madr_consequences_subsection(
1554                    result,
1555                    text,
1556                    ending,
1557                    body_end < lines.len(),
1558                );
1559                consequences_applied = true;
1560            }
1561            return (true, consequences_applied);
1562        }
1563
1564        let mut j = intro_end;
1565
1566        while j < body_end {
1567            if !Self::is_h3_outside_fence(lines, j) {
1568                j += 1;
1569                continue;
1570            }
1571
1572            let sub_start = j;
1573            let sub_end = lines[sub_start + 1..body_end]
1574                .iter()
1575                .enumerate()
1576                .find(|(offset, _)| {
1577                    let idx = sub_start + 1 + offset;
1578                    Self::is_h2_outside_fence(lines, idx) || Self::is_h3_outside_fence(lines, idx)
1579                })
1580                .map(|(offset, _)| sub_start + 1 + offset)
1581                .unwrap_or(body_end);
1582
1583            if Self::is_consequences_h3(lines[sub_start])
1584                && let Some(ref text) = patch.consequences
1585            {
1586                consequences_applied = true;
1587                result.push_str(lines[sub_start]);
1588                result.push_str(ending);
1589                // Another H3 follows immediately, or the wider Decision
1590                // Outcome section is itself followed by another heading.
1591                let blank_line_after = sub_end < body_end || body_end < lines.len();
1592                Self::write_section_body(result, text, ending, blank_line_after);
1593                j = sub_end;
1594                continue;
1595            }
1596
1597            Self::append_lines(result, lines, sub_start, sub_end, content, ending);
1598            j = sub_end;
1599        }
1600
1601        if let Some(ref text) = patch.consequences
1602            && !consequences_applied
1603            && !Self::has_consequences_h2(lines)
1604            && madr_decision_outcome
1605        {
1606            Self::write_madr_consequences_subsection(result, text, ending, body_end < lines.len());
1607            consequences_applied = true;
1608        }
1609
1610        (true, consequences_applied)
1611    }
1612
1613    /// True when a fence-aware `## Consequences` H2 exists anywhere in the file.
1614    fn has_consequences_h2(lines: &[&str]) -> bool {
1615        lines.iter().enumerate().any(|(idx, line)| {
1616            Self::is_h2_outside_fence(lines, idx) && Self::is_consequences_h2(line)
1617        })
1618    }
1619
1620    fn write_madr_consequences_subsection(
1621        result: &mut String,
1622        text: &str,
1623        ending: &str,
1624        blank_line_after: bool,
1625    ) {
1626        // append_lines may omit a final newline when the source file has none
1627        // and the append ends at EOF; re-establish a separator before the H3.
1628        if !result.is_empty() && !result.ends_with('\n') {
1629            result.push_str(ending);
1630        }
1631        result.push_str("### Consequences");
1632        result.push_str(ending);
1633        Self::write_section_body(result, text, ending, blank_line_after);
1634    }
1635
1636    fn yaml_str_key(key: &str) -> Value {
1637        Value::String(key.to_string())
1638    }
1639
1640    /// Set or remove a sequence-valued frontmatter field. Returns true if `map` changed.
1641    fn set_yaml_sequence_field<T: serde::Serialize>(
1642        map: &mut Mapping,
1643        key: &str,
1644        values: &[T],
1645    ) -> Result<bool> {
1646        let key = Self::yaml_str_key(key);
1647        if values.is_empty() {
1648            return Ok(map.remove(&key).is_some());
1649        }
1650        let desired = serde_yaml_neo::to_value(values)?;
1651        if map.get(&key) == Some(&desired) {
1652            return Ok(false);
1653        }
1654        map.insert(key, desired);
1655        Ok(true)
1656    }
1657
1658    /// Set or remove a string-list frontmatter field. Returns true if `map` changed.
1659    fn set_yaml_string_list_field(map: &mut Mapping, key: &str, values: &[String]) -> Result<bool> {
1660        Self::set_yaml_sequence_field(map, key, values)
1661    }
1662
1663    /// Whether a frontmatter string-or-list field already matches `desired`
1664    /// (same rules as `Adr`'s `string_or_vec` deserializer).
1665    fn yaml_string_list_matches(map: &Mapping, key: &str, desired: &[String]) -> bool {
1666        match map.get(Self::yaml_str_key(key)) {
1667            None | Some(Value::Null) => desired.is_empty(),
1668            Some(Value::String(s)) => desired.len() == 1 && desired[0] == *s,
1669            Some(Value::Sequence(seq)) => {
1670                let got: Option<Vec<&str>> = seq.iter().map(|v| v.as_str()).collect();
1671                match got {
1672                    Some(got) => got == desired.iter().map(String::as_str).collect::<Vec<_>>(),
1673                    None => false,
1674                }
1675            }
1676            Some(_) => false,
1677        }
1678    }
1679}
1680
1681/// Count existing ADR files in a directory.
1682fn count_existing_adrs(path: &Path) -> usize {
1683    if !path.is_dir() {
1684        return 0;
1685    }
1686
1687    fs::read_dir(path)
1688        .map(|entries| {
1689            entries
1690                .filter_map(|e| e.ok())
1691                .filter(|e| {
1692                    let path = e.path();
1693                    path.is_file()
1694                        && path.extension().is_some_and(|ext| ext == "md")
1695                        && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
1696                            // Match NNNN-*.md pattern (adr-tools style)
1697                            n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
1698                        })
1699                })
1700                .count()
1701        })
1702        .unwrap_or(0)
1703}
1704
1705#[cfg(test)]
1706mod tests {
1707    use super::*;
1708    use tempfile::TempDir;
1709
1710    // ========== Initialization Tests ==========
1711
1712    #[test]
1713    fn test_init_repository() {
1714        let temp = TempDir::new().unwrap();
1715        let repo = Repository::init(temp.path(), None, false).unwrap();
1716
1717        assert!(repo.adr_path().exists());
1718        assert!(temp.path().join(".adr-dir").exists());
1719
1720        let adrs = repo.list().unwrap();
1721        assert_eq!(adrs.len(), 1);
1722        assert_eq!(adrs[0].number, 1);
1723        assert_eq!(adrs[0].title, "Record architecture decisions");
1724    }
1725
1726    #[test]
1727    fn test_init_repository_ng() {
1728        let temp = TempDir::new().unwrap();
1729        let repo = Repository::init(temp.path(), None, true).unwrap();
1730
1731        assert!(temp.path().join("adrs.toml").exists());
1732        assert!(repo.config().is_next_gen());
1733    }
1734
1735    #[test]
1736    fn test_init_repository_custom_dir() {
1737        let temp = TempDir::new().unwrap();
1738        let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
1739
1740        assert!(temp.path().join("decisions").exists());
1741        assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
1742    }
1743
1744    #[test]
1745    fn test_init_repository_nested_dir() {
1746        let temp = TempDir::new().unwrap();
1747        let _repo =
1748            Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
1749
1750        assert!(temp.path().join("docs/architecture/adr").exists());
1751    }
1752
1753    #[test]
1754    fn test_init_repository_already_exists_skips_initial_adr() {
1755        let temp = TempDir::new().unwrap();
1756        Repository::init(temp.path(), None, false).unwrap();
1757
1758        // Re-init should succeed but not create another ADR
1759        let repo = Repository::init(temp.path(), None, false).unwrap();
1760        let adrs = repo.list().unwrap();
1761        assert_eq!(adrs.len(), 1); // Still just the original initial ADR
1762    }
1763
1764    #[test]
1765    fn test_init_with_existing_adrs_skips_initial() {
1766        let temp = TempDir::new().unwrap();
1767        let adr_dir = temp.path().join("doc/adr");
1768        fs::create_dir_all(&adr_dir).unwrap();
1769
1770        // Create some existing ADR files
1771        fs::write(
1772            adr_dir.join("0001-existing-decision.md"),
1773            "# 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",
1774        )
1775        .unwrap();
1776        fs::write(
1777            adr_dir.join("0002-another-decision.md"),
1778            "# 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",
1779        )
1780        .unwrap();
1781
1782        // Init should succeed and NOT create initial ADR
1783        let repo = Repository::init(temp.path(), None, false).unwrap();
1784        let adrs = repo.list().unwrap();
1785        assert_eq!(adrs.len(), 2); // Only the existing ADRs, no "Record architecture decisions"
1786        assert_eq!(adrs[0].title, "Existing Decision");
1787        assert_eq!(adrs[1].title, "Another Decision");
1788    }
1789
1790    #[test]
1791    fn test_init_creates_first_adr() {
1792        let temp = TempDir::new().unwrap();
1793        let repo = Repository::init(temp.path(), None, false).unwrap();
1794
1795        let adr = repo.get(1).unwrap();
1796        assert_eq!(adr.title, crate::init_adr::TITLE);
1797        assert_eq!(adr.status, AdrStatus::Accepted);
1798        assert_eq!(adr.context, crate::init_adr::CONTEXT);
1799        assert_eq!(adr.decision, crate::init_adr::DECISION);
1800        assert_eq!(adr.consequences, crate::init_adr::CONSEQUENCES);
1801    }
1802
1803    #[test]
1804    fn test_init_first_adr_has_markdown_links_in_both_modes() {
1805        for ng in [false, true] {
1806            let temp = TempDir::new().unwrap();
1807            let repo = Repository::init(temp.path(), None, ng).unwrap();
1808            let path = repo
1809                .adr_path()
1810                .join("0001-record-architecture-decisions.md");
1811            let content = fs::read_to_string(path).unwrap();
1812
1813            assert!(
1814                content.contains(
1815                    "[Documenting Architecture Decisions](https://www.cognitect.com/blog/2011/11/15/documenting-architecture-decisions)"
1816                ),
1817                "expected Nygard article link (ng={ng})"
1818            );
1819            assert!(
1820                content.contains("[adrs](https://github.com/joshrotenberg/adrs)"),
1821                "expected adrs link (ng={ng})"
1822            );
1823            assert!(
1824                content.contains("[adr-tools](https://github.com/npryce/adr-tools)"),
1825                "expected adr-tools link (ng={ng})"
1826            );
1827            assert!(
1828                content.ends_with('\n'),
1829                "init ADR should end with a newline (ng={ng})"
1830            );
1831        }
1832    }
1833
1834    // ========== Open Tests ==========
1835
1836    #[test]
1837    fn test_open_repository() {
1838        let temp = TempDir::new().unwrap();
1839        Repository::init(temp.path(), None, false).unwrap();
1840
1841        let repo = Repository::open(temp.path()).unwrap();
1842        assert_eq!(repo.list().unwrap().len(), 1);
1843    }
1844
1845    #[test]
1846    fn test_open_repository_not_found() {
1847        let temp = TempDir::new().unwrap();
1848        let result = Repository::open(temp.path());
1849        assert!(result.is_err());
1850    }
1851
1852    #[test]
1853    fn test_open_or_default() {
1854        let temp = TempDir::new().unwrap();
1855        let repo = Repository::open_or_default(temp.path());
1856        assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
1857    }
1858
1859    #[test]
1860    fn test_open_or_default_existing() {
1861        let temp = TempDir::new().unwrap();
1862        Repository::init(temp.path(), Some("custom".into()), false).unwrap();
1863
1864        let repo = Repository::open_or_default(temp.path());
1865        assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
1866    }
1867
1868    // ========== Create and List Tests ==========
1869
1870    #[test]
1871    fn test_create_and_list() {
1872        let temp = TempDir::new().unwrap();
1873        let repo = Repository::init(temp.path(), None, false).unwrap();
1874
1875        let (adr, _) = repo.new_adr("Use Rust").unwrap();
1876        assert_eq!(adr.number, 2);
1877
1878        let adrs = repo.list().unwrap();
1879        assert_eq!(adrs.len(), 2);
1880    }
1881
1882    #[test]
1883    fn test_create_multiple() {
1884        let temp = TempDir::new().unwrap();
1885        let repo = Repository::init(temp.path(), None, false).unwrap();
1886
1887        repo.new_adr("Second").unwrap();
1888        repo.new_adr("Third").unwrap();
1889        repo.new_adr("Fourth").unwrap();
1890
1891        let adrs = repo.list().unwrap();
1892        assert_eq!(adrs.len(), 4);
1893        assert_eq!(adrs[0].number, 1);
1894        assert_eq!(adrs[1].number, 2);
1895        assert_eq!(adrs[2].number, 3);
1896        assert_eq!(adrs[3].number, 4);
1897    }
1898
1899    #[test]
1900    fn test_list_sorted_by_number() {
1901        let temp = TempDir::new().unwrap();
1902        let repo = Repository::init(temp.path(), None, false).unwrap();
1903
1904        repo.new_adr("B").unwrap();
1905        repo.new_adr("A").unwrap();
1906        repo.new_adr("C").unwrap();
1907
1908        let adrs = repo.list().unwrap();
1909        assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
1910    }
1911
1912    #[test]
1913    fn test_next_number() {
1914        let temp = TempDir::new().unwrap();
1915        let repo = Repository::init(temp.path(), None, false).unwrap();
1916
1917        assert_eq!(repo.next_number().unwrap(), 2);
1918
1919        repo.new_adr("Second").unwrap();
1920        assert_eq!(repo.next_number().unwrap(), 3);
1921    }
1922
1923    #[test]
1924    fn test_create_file_exists() {
1925        let temp = TempDir::new().unwrap();
1926        let repo = Repository::init(temp.path(), None, false).unwrap();
1927
1928        let (_, path) = repo.new_adr("Test ADR").unwrap();
1929        assert!(path.exists());
1930        assert!(path.to_string_lossy().contains("0002-test-adr.md"));
1931    }
1932
1933    #[test]
1934    fn test_new_adr_uses_custom_default_status_from_config() {
1935        let temp = TempDir::new().unwrap();
1936        Repository::init(temp.path(), None, false).unwrap();
1937
1938        std::fs::write(
1939            temp.path().join("adrs.toml"),
1940            r#"
1941adr_dir = "doc/adr"
1942mode = "compatible"
1943default_status = "draft"
1944"#,
1945        )
1946        .unwrap();
1947
1948        let repo = Repository::open(temp.path()).unwrap();
1949        let (adr, _) = repo.new_adr("Custom status ADR").unwrap();
1950
1951        assert_eq!(adr.status, AdrStatus::Custom("draft".into()));
1952    }
1953
1954    // ========== Get and Find Tests ==========
1955
1956    #[test]
1957    fn test_get_by_number() {
1958        let temp = TempDir::new().unwrap();
1959        let repo = Repository::init(temp.path(), None, false).unwrap();
1960        repo.new_adr("Second").unwrap();
1961
1962        let adr = repo.get(2).unwrap();
1963        assert_eq!(adr.title, "Second");
1964    }
1965
1966    #[test]
1967    fn test_get_not_found() {
1968        let temp = TempDir::new().unwrap();
1969        let repo = Repository::init(temp.path(), None, false).unwrap();
1970
1971        let result = repo.get(99);
1972        assert!(result.is_err());
1973    }
1974
1975    #[test]
1976    fn test_find_by_number() {
1977        let temp = TempDir::new().unwrap();
1978        let repo = Repository::init(temp.path(), None, false).unwrap();
1979
1980        let adr = repo.find("1").unwrap();
1981        assert_eq!(adr.number, 1);
1982    }
1983
1984    #[test]
1985    fn test_find_by_title() {
1986        let temp = TempDir::new().unwrap();
1987        let repo = Repository::init(temp.path(), None, false).unwrap();
1988
1989        let adr = repo.find("architecture").unwrap();
1990        assert_eq!(adr.number, 1);
1991    }
1992
1993    #[test]
1994    fn test_find_fuzzy_match() {
1995        let temp = TempDir::new().unwrap();
1996        let repo = Repository::init(temp.path(), None, false).unwrap();
1997        repo.new_adr("Use PostgreSQL for database").unwrap();
1998        repo.new_adr("Use Redis for caching").unwrap();
1999
2000        let adr = repo.find("postgres").unwrap();
2001        assert!(adr.title.contains("PostgreSQL"));
2002    }
2003
2004    #[test]
2005    fn test_find_not_found() {
2006        let temp = TempDir::new().unwrap();
2007        let repo = Repository::init(temp.path(), None, false).unwrap();
2008
2009        let result = repo.find("nonexistent");
2010        assert!(result.is_err());
2011    }
2012
2013    // ========== Supersede Tests ==========
2014
2015    #[test]
2016    fn test_supersede() {
2017        let temp = TempDir::new().unwrap();
2018        let repo = Repository::init(temp.path(), None, false).unwrap();
2019
2020        let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
2021        assert_eq!(new_adr.number, 2);
2022        assert_eq!(new_adr.links.len(), 1);
2023        assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
2024
2025        let old_adr = repo.get(1).unwrap();
2026        assert_eq!(old_adr.status, AdrStatus::Superseded);
2027    }
2028
2029    #[test]
2030    fn test_supersede_creates_bidirectional_links() {
2031        let temp = TempDir::new().unwrap();
2032        let repo = Repository::init(temp.path(), None, false).unwrap();
2033
2034        repo.supersede("New approach", 1).unwrap();
2035
2036        let old_adr = repo.get(1).unwrap();
2037        assert_eq!(old_adr.links.len(), 1);
2038        assert_eq!(old_adr.links[0].target, 2);
2039        assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
2040
2041        let new_adr = repo.get(2).unwrap();
2042        assert_eq!(new_adr.links.len(), 1);
2043        assert_eq!(new_adr.links[0].target, 1);
2044        assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
2045    }
2046
2047    #[test]
2048    fn test_supersede_not_found() {
2049        let temp = TempDir::new().unwrap();
2050        let repo = Repository::init(temp.path(), None, false).unwrap();
2051
2052        let result = repo.supersede("New", 99);
2053        assert!(result.is_err());
2054    }
2055
2056    // ========== Link Resolution Tests (Issue #180) ==========
2057
2058    #[test]
2059    fn test_supersede_generates_functional_links() {
2060        let temp = TempDir::new().unwrap();
2061        let repo = Repository::init(temp.path(), None, false).unwrap();
2062
2063        // Create ADR 2, then supersede it with ADR 3
2064        repo.new_adr("Use MySQL for persistence").unwrap();
2065        repo.supersede("Use PostgreSQL instead", 2).unwrap();
2066
2067        // Check the new ADR (3) has a functional "Supersedes" link to ADR 2
2068        let new_content =
2069            fs::read_to_string(repo.adr_path().join("0003-use-postgresql-instead.md")).unwrap();
2070        assert!(
2071            new_content.contains(
2072                "Supersedes [2. Use MySQL for persistence](0002-use-mysql-for-persistence.md)"
2073            ),
2074            "New ADR should have functional Supersedes link. Got:\n{new_content}"
2075        );
2076
2077        // Check the old ADR (2) has a functional "Superseded by" link to ADR 3
2078        let old_content =
2079            fs::read_to_string(repo.adr_path().join("0002-use-mysql-for-persistence.md")).unwrap();
2080        assert!(
2081            old_content.contains(
2082                "Superseded by [3. Use PostgreSQL instead](0003-use-postgresql-instead.md)"
2083            ),
2084            "Old ADR should have functional Superseded by link. Got:\n{old_content}"
2085        );
2086    }
2087
2088    #[test]
2089    fn test_link_generates_functional_links() {
2090        let temp = TempDir::new().unwrap();
2091        let repo = Repository::init(temp.path(), None, false).unwrap();
2092
2093        repo.new_adr("Use REST API").unwrap();
2094        repo.new_adr("Use JSON for API responses").unwrap();
2095
2096        repo.link(3, 2, LinkKind::Amends, LinkKind::AmendedBy)
2097            .unwrap();
2098
2099        // Check source ADR has functional link
2100        let source_content =
2101            fs::read_to_string(repo.adr_path().join("0003-use-json-for-api-responses.md")).unwrap();
2102        assert!(
2103            source_content.contains("Amends [2. Use REST API](0002-use-rest-api.md)"),
2104            "Source ADR should have functional Amends link. Got:\n{source_content}"
2105        );
2106
2107        // Check target ADR has functional reverse link
2108        let target_content =
2109            fs::read_to_string(repo.adr_path().join("0002-use-rest-api.md")).unwrap();
2110        assert!(
2111            target_content.contains(
2112                "Amended by [3. Use JSON for API responses](0003-use-json-for-api-responses.md)"
2113            ),
2114            "Target ADR should have functional Amended by link. Got:\n{target_content}"
2115        );
2116    }
2117
2118    #[test]
2119    fn test_set_status_superseded_generates_functional_link() {
2120        let temp = TempDir::new().unwrap();
2121        let repo = Repository::init(temp.path(), None, false).unwrap();
2122
2123        repo.new_adr("First Decision").unwrap();
2124        repo.new_adr("Second Decision").unwrap();
2125
2126        repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
2127
2128        let content = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
2129        assert!(
2130            content.contains("Superseded by [3. Second Decision](0003-second-decision.md)"),
2131            "ADR should have functional Superseded by link. Got:\n{content}"
2132        );
2133    }
2134
2135    #[test]
2136    fn test_supersede_chain_generates_functional_links() {
2137        let temp = TempDir::new().unwrap();
2138        let repo = Repository::init(temp.path(), None, false).unwrap();
2139
2140        // ADR 1 is "Record architecture decisions" (from init)
2141        // Create ADR 2
2142        repo.new_adr("Use SQLite").unwrap();
2143        // ADR 3 supersedes ADR 2
2144        repo.supersede("Use PostgreSQL", 2).unwrap();
2145        // ADR 4 supersedes ADR 3
2146        repo.supersede("Use CockroachDB", 3).unwrap();
2147
2148        // Check ADR 3 has both directions
2149        let adr3_content =
2150            fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
2151        assert!(
2152            adr3_content.contains("Supersedes [2. Use SQLite](0002-use-sqlite.md)"),
2153            "ADR 3 should supersede ADR 2. Got:\n{adr3_content}"
2154        );
2155        assert!(
2156            adr3_content.contains("Superseded by [4. Use CockroachDB](0004-use-cockroachdb.md)"),
2157            "ADR 3 should be superseded by ADR 4. Got:\n{adr3_content}"
2158        );
2159    }
2160
2161    #[test]
2162    fn test_ng_mode_supersede_generates_functional_links() {
2163        let temp = TempDir::new().unwrap();
2164        let repo = Repository::init(temp.path(), None, true).unwrap();
2165
2166        repo.new_adr("Use MySQL").unwrap();
2167        repo.supersede("Use PostgreSQL", 2).unwrap();
2168
2169        // Check the new ADR has functional links in both frontmatter and body
2170        let new_content =
2171            fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
2172
2173        // Body should have functional markdown link
2174        assert!(
2175            new_content.contains("Supersedes [2. Use MySQL](0002-use-mysql.md)"),
2176            "NG mode should have functional link in body. Got:\n{new_content}"
2177        );
2178        // Frontmatter should have structured link
2179        assert!(new_content.contains("links:"));
2180        assert!(new_content.contains("target: 2"));
2181    }
2182
2183    // ========== Set Status Tests ==========
2184
2185    #[test]
2186    fn test_set_status_accepted() {
2187        let temp = TempDir::new().unwrap();
2188        let repo = Repository::init(temp.path(), None, false).unwrap();
2189        repo.new_adr("Test Decision").unwrap();
2190
2191        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2192
2193        let adr = repo.get(2).unwrap();
2194        assert_eq!(adr.status, AdrStatus::Accepted);
2195    }
2196
2197    #[test]
2198    fn test_set_status_deprecated() {
2199        let temp = TempDir::new().unwrap();
2200        let repo = Repository::init(temp.path(), None, false).unwrap();
2201        repo.new_adr("Old Decision").unwrap();
2202
2203        repo.set_status(2, AdrStatus::Deprecated, None).unwrap();
2204
2205        let adr = repo.get(2).unwrap();
2206        assert_eq!(adr.status, AdrStatus::Deprecated);
2207    }
2208
2209    #[test]
2210    fn test_set_status_superseded_with_link() {
2211        let temp = TempDir::new().unwrap();
2212        let repo = Repository::init(temp.path(), None, false).unwrap();
2213        repo.new_adr("First Decision").unwrap();
2214        repo.new_adr("Second Decision").unwrap();
2215
2216        repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
2217
2218        let adr = repo.get(2).unwrap();
2219        assert_eq!(adr.status, AdrStatus::Superseded);
2220        assert_eq!(adr.links.len(), 1);
2221        assert_eq!(adr.links[0].target, 3);
2222        assert_eq!(adr.links[0].kind, LinkKind::SupersededBy);
2223    }
2224
2225    #[test]
2226    fn test_set_status_superseded_without_link() {
2227        let temp = TempDir::new().unwrap();
2228        let repo = Repository::init(temp.path(), None, false).unwrap();
2229        repo.new_adr("Decision").unwrap();
2230
2231        repo.set_status(2, AdrStatus::Superseded, None).unwrap();
2232
2233        let adr = repo.get(2).unwrap();
2234        assert_eq!(adr.status, AdrStatus::Superseded);
2235        assert_eq!(adr.links.len(), 0);
2236    }
2237
2238    #[test]
2239    fn test_set_status_custom() {
2240        let temp = TempDir::new().unwrap();
2241        let repo = Repository::init(temp.path(), None, false).unwrap();
2242        repo.new_adr("Test Decision").unwrap();
2243
2244        repo.set_status(2, AdrStatus::Custom("Draft".into()), None)
2245            .unwrap();
2246
2247        let adr = repo.get(2).unwrap();
2248        assert_eq!(adr.status, AdrStatus::Custom("Draft".into()));
2249    }
2250
2251    #[test]
2252    fn test_set_status_adr_not_found() {
2253        let temp = TempDir::new().unwrap();
2254        let repo = Repository::init(temp.path(), None, false).unwrap();
2255
2256        let result = repo.set_status(99, AdrStatus::Accepted, None);
2257        assert!(result.is_err());
2258    }
2259
2260    #[test]
2261    fn test_set_status_superseded_by_not_found() {
2262        let temp = TempDir::new().unwrap();
2263        let repo = Repository::init(temp.path(), None, false).unwrap();
2264        repo.new_adr("Decision").unwrap();
2265
2266        let result = repo.set_status(2, AdrStatus::Superseded, Some(99));
2267        assert!(result.is_err());
2268    }
2269
2270    // ========== Link Tests ==========
2271
2272    #[test]
2273    fn test_link_adrs() {
2274        let temp = TempDir::new().unwrap();
2275        let repo = Repository::init(temp.path(), None, false).unwrap();
2276        repo.new_adr("Second").unwrap();
2277
2278        repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
2279            .unwrap();
2280
2281        let adr1 = repo.get(1).unwrap();
2282        assert_eq!(adr1.links.len(), 1);
2283        assert_eq!(adr1.links[0].target, 2);
2284        assert_eq!(adr1.links[0].kind, LinkKind::Amends);
2285
2286        let adr2 = repo.get(2).unwrap();
2287        assert_eq!(adr2.links.len(), 1);
2288        assert_eq!(adr2.links[0].target, 1);
2289        assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
2290    }
2291
2292    #[test]
2293    fn test_link_relates_to() {
2294        let temp = TempDir::new().unwrap();
2295        let repo = Repository::init(temp.path(), None, false).unwrap();
2296        repo.new_adr("Second").unwrap();
2297
2298        repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
2299            .unwrap();
2300
2301        let adr1 = repo.get(1).unwrap();
2302        assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
2303
2304        let adr2 = repo.get(2).unwrap();
2305        assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
2306    }
2307
2308    // ========== Update Tests ==========
2309
2310    #[test]
2311    fn test_update_adr() {
2312        let temp = TempDir::new().unwrap();
2313        let repo = Repository::init(temp.path(), None, false).unwrap();
2314
2315        let mut adr = repo.get(1).unwrap();
2316        adr.status = AdrStatus::Deprecated;
2317
2318        repo.update(&adr, BodySectionPatch::default()).unwrap();
2319
2320        let updated = repo.get(1).unwrap();
2321        assert_eq!(updated.status, AdrStatus::Deprecated);
2322    }
2323
2324    #[test]
2325    fn test_update_preserves_content() {
2326        let temp = TempDir::new().unwrap();
2327        let repo = Repository::init(temp.path(), None, false).unwrap();
2328
2329        let mut adr = repo.get(1).unwrap();
2330        let original_title = adr.title.clone();
2331        adr.status = AdrStatus::Deprecated;
2332
2333        repo.update(&adr, BodySectionPatch::default()).unwrap();
2334
2335        let updated = repo.get(1).unwrap();
2336        assert_eq!(updated.title, original_title);
2337    }
2338
2339    // ========== Read/Write Content Tests ==========
2340
2341    #[test]
2342    fn test_read_content() {
2343        let temp = TempDir::new().unwrap();
2344        let repo = Repository::init(temp.path(), None, false).unwrap();
2345
2346        let adr = repo.get(1).unwrap();
2347        let content = repo.read_content(&adr).unwrap();
2348
2349        assert!(content.contains("Record architecture decisions"));
2350        assert!(content.contains("## Status"));
2351    }
2352
2353    #[test]
2354    fn test_write_content() {
2355        let temp = TempDir::new().unwrap();
2356        let repo = Repository::init(temp.path(), None, false).unwrap();
2357
2358        let adr = repo.get(1).unwrap();
2359        let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
2360
2361        repo.write_content(&adr, new_content).unwrap();
2362
2363        let content = repo.read_content(&adr).unwrap();
2364        assert!(content.contains("Modified"));
2365    }
2366
2367    // ========== Mode Override Tests ==========
2368
2369    #[test]
2370    fn test_with_mode_overrides_compatible_to_ng() {
2371        let temp = TempDir::new().unwrap();
2372        // Init in compatible mode
2373        let repo = Repository::init(temp.path(), None, false)
2374            .unwrap()
2375            .with_mode(ConfigMode::NextGen);
2376
2377        let (_, path) = repo.new_adr("Mode Override Test").unwrap();
2378        let content = fs::read_to_string(path).unwrap();
2379
2380        assert!(
2381            content.starts_with("---\n"),
2382            "with_mode(NextGen) on compatible repo should produce YAML frontmatter. Got:\n{content}"
2383        );
2384        assert!(content.contains("status: proposed"));
2385    }
2386
2387    #[test]
2388    fn test_with_mode_ng_to_compatible() {
2389        let temp = TempDir::new().unwrap();
2390        // Init in ng mode, then override to compatible
2391        let repo = Repository::init(temp.path(), None, true)
2392            .unwrap()
2393            .with_mode(ConfigMode::Compatible);
2394
2395        let (_, path) = repo.new_adr("Downgrade Mode Test").unwrap();
2396        let content = fs::read_to_string(path).unwrap();
2397
2398        assert!(
2399            !content.starts_with("---\n"),
2400            "with_mode(Compatible) on ng repo should NOT produce YAML frontmatter. Got:\n{content}"
2401        );
2402    }
2403
2404    // ========== Template Configuration Tests ==========
2405
2406    #[test]
2407    fn test_with_template_format() {
2408        let temp = TempDir::new().unwrap();
2409        let repo = Repository::init(temp.path(), None, false)
2410            .unwrap()
2411            .with_template_format(TemplateFormat::Madr);
2412
2413        let (_, path) = repo.new_adr("MADR Test").unwrap();
2414        let content = fs::read_to_string(path).unwrap();
2415
2416        assert!(content.contains("Context and Problem Statement"));
2417    }
2418
2419    #[test]
2420    fn test_with_custom_template() {
2421        let temp = TempDir::new().unwrap();
2422        let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
2423        let repo = Repository::init(temp.path(), None, false)
2424            .unwrap()
2425            .with_custom_template(custom);
2426
2427        let (_, path) = repo.new_adr("Custom Test").unwrap();
2428        let content = fs::read_to_string(path).unwrap();
2429
2430        assert_eq!(content, "# ADR 2: Custom Test\n");
2431    }
2432
2433    // ========== Accessor Tests ==========
2434
2435    #[test]
2436    fn test_root() {
2437        let temp = TempDir::new().unwrap();
2438        let repo = Repository::init(temp.path(), None, false).unwrap();
2439
2440        assert_eq!(repo.root(), temp.path());
2441    }
2442
2443    #[test]
2444    fn test_config() {
2445        let temp = TempDir::new().unwrap();
2446        let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
2447
2448        assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
2449        assert!(repo.config().is_next_gen());
2450    }
2451
2452    #[test]
2453    fn test_adr_path() {
2454        let temp = TempDir::new().unwrap();
2455        let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
2456
2457        assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
2458    }
2459
2460    // ========== NextGen Mode Tests ==========
2461
2462    #[test]
2463    fn test_ng_mode_creates_frontmatter() {
2464        let temp = TempDir::new().unwrap();
2465        let repo = Repository::init(temp.path(), None, true).unwrap();
2466
2467        let (_, path) = repo.new_adr("NG Test").unwrap();
2468        let content = fs::read_to_string(path).unwrap();
2469
2470        assert!(content.starts_with("---"));
2471        assert!(content.contains("number: 2"));
2472        assert!(content.contains("title: NG Test"));
2473    }
2474
2475    #[test]
2476    fn test_ng_mode_parses_frontmatter() {
2477        let temp = TempDir::new().unwrap();
2478        let repo = Repository::init(temp.path(), None, true).unwrap();
2479
2480        repo.new_adr("NG ADR").unwrap();
2481
2482        let adr = repo.get(2).unwrap();
2483        assert_eq!(adr.title, "NG ADR");
2484        assert_eq!(adr.number, 2);
2485    }
2486
2487    // ========== Edge Cases ==========
2488
2489    #[test]
2490    fn test_list_empty_after_init_removal() {
2491        let temp = TempDir::new().unwrap();
2492        let repo = Repository::init(temp.path(), None, false).unwrap();
2493
2494        // Remove the initial ADR
2495        fs::remove_file(
2496            repo.adr_path()
2497                .join("0001-record-architecture-decisions.md"),
2498        )
2499        .unwrap();
2500
2501        let adrs = repo.list().unwrap();
2502        assert!(adrs.is_empty());
2503    }
2504
2505    #[test]
2506    fn test_list_ignores_non_adr_files() {
2507        let temp = TempDir::new().unwrap();
2508        let repo = Repository::init(temp.path(), None, false).unwrap();
2509
2510        // Create non-ADR files
2511        fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
2512        fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
2513
2514        let adrs = repo.list().unwrap();
2515        assert_eq!(adrs.len(), 1); // Only the initial ADR
2516    }
2517
2518    #[test]
2519    fn test_special_characters_in_title() {
2520        let temp = TempDir::new().unwrap();
2521        let repo = Repository::init(temp.path(), None, false).unwrap();
2522
2523        let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
2524        assert!(path.exists());
2525        assert_eq!(adr.title, "Use C++ & Rust!");
2526    }
2527
2528    // ========== Metadata Preservation Tests (issue #187) ==========
2529
2530    #[test]
2531    fn test_set_status_preserves_madr_body() {
2532        let temp = TempDir::new().unwrap();
2533        let repo = Repository::init(temp.path(), None, true).unwrap();
2534
2535        let madr_content = r#"---
2536number: 2
2537title: Use Redis for caching
2538date: 2026-01-15
2539status: proposed
2540---
2541
2542# Use Redis for caching
2543
2544## Context and Problem Statement
2545
2546We need a **fast** caching layer for our [API](https://api.example.com).
2547
2548## Considered Options
2549
2550* Redis
2551* Memcached
2552* In-memory cache
2553
2554## Decision Outcome
2555
2556Chosen option: "Redis", because it supports data structures beyond simple key-value.
2557
2558### Consequences
2559
2560* Good, because it provides pub/sub
2561* Bad, because it adds operational complexity
2562
2563## Pros and Cons of the Options
2564
2565### Redis
2566
2567* Good, because it supports complex data types
2568* Bad, because it requires a separate server
2569
2570### Memcached
2571
2572* Good, because it's simpler
2573* Bad, because it only supports strings
2574"#;
2575        let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
2576        fs::write(&adr_path, madr_content).unwrap();
2577
2578        // Change status
2579        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2580
2581        let result = fs::read_to_string(&adr_path).unwrap();
2582
2583        // Status should be updated
2584        assert!(result.contains("status: accepted"));
2585        assert!(!result.contains("status: proposed"));
2586
2587        // Body should be completely preserved
2588        let body_start = result.find("\n# Use Redis").unwrap();
2589        let original_body_start = madr_content.find("\n# Use Redis").unwrap();
2590        assert_eq!(
2591            &result[body_start..],
2592            &madr_content[original_body_start..],
2593            "Body content was modified"
2594        );
2595    }
2596
2597    #[test]
2598    fn test_set_status_via_mapping_preserves_unknown_keys_and_body() {
2599        // Frontmatter is re-emitted via a YAML Mapping (ADR 0006) and does not
2600        // round-trip comments. Unknown keys and the markdown body must survive.
2601        let temp = TempDir::new().unwrap();
2602        let repo = Repository::init(temp.path(), None, true).unwrap();
2603
2604        let content_with_comments = r#"---
2605# SPDX-License-Identifier: MIT
2606number: 2
2607title: Use MADR format
2608date: 2026-01-15
2609status: proposed
2610custom-meta: keep-me
2611---
2612
2613## Context and Problem Statement
2614
2615We need a standard ADR format.
2616
2617## Decision Outcome
2618
2619Use MADR 4.0.0.
2620"#;
2621        let adr_path = repo.adr_path().join("0002-use-madr-format.md");
2622        fs::write(&adr_path, content_with_comments).unwrap();
2623
2624        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2625
2626        let result = fs::read_to_string(&adr_path).unwrap();
2627
2628        assert!(result.contains("status: accepted"));
2629        assert!(
2630            result.contains("custom-meta: keep-me"),
2631            "unknown frontmatter key was dropped\n{result}"
2632        );
2633        assert!(
2634            result.contains("## Decision Outcome") && result.contains("Use MADR 4.0.0."),
2635            "markdown body must survive\n{result}"
2636        );
2637    }
2638
2639    #[test]
2640    fn test_set_status_preserves_markdown_links() {
2641        let temp = TempDir::new().unwrap();
2642        let repo = Repository::init(temp.path(), None, true).unwrap();
2643
2644        let content = r#"---
2645number: 2
2646title: Use PostgreSQL
2647date: 2026-01-15
2648status: proposed
2649---
2650
2651## Context
2652
2653See the [PostgreSQL docs](https://www.postgresql.org/docs/) for details.
2654
2655Also see [RFC 7159](https://tools.ietf.org/html/rfc7159) and `inline code`.
2656
2657## Decision
2658
2659We will use **PostgreSQL** version `16.x`.
2660
2661## Consequences
2662
2663- [Monitoring guide](https://example.com/monitoring)
2664- Performance benchmarks in [this report](./benchmarks.md)
2665"#;
2666        let adr_path = repo.adr_path().join("0002-use-postgresql.md");
2667        fs::write(&adr_path, content).unwrap();
2668
2669        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2670
2671        let result = fs::read_to_string(&adr_path).unwrap();
2672
2673        assert!(result.contains("[PostgreSQL docs](https://www.postgresql.org/docs/)"));
2674        assert!(result.contains("[RFC 7159](https://tools.ietf.org/html/rfc7159)"));
2675        assert!(result.contains("`inline code`"));
2676        assert!(result.contains("**PostgreSQL**"));
2677        assert!(result.contains("[Monitoring guide](https://example.com/monitoring)"));
2678        assert!(result.contains("[this report](./benchmarks.md)"));
2679    }
2680
2681    #[test]
2682    fn test_link_preserves_body_content() {
2683        let temp = TempDir::new().unwrap();
2684        let repo = Repository::init(temp.path(), None, true).unwrap();
2685
2686        let content_1 = r#"---
2687number: 2
2688title: First decision
2689date: 2026-01-15
2690status: accepted
2691---
2692
2693## Context
2694
2695Custom context with **bold** and [links](https://example.com).
2696
2697## Decision
2698
2699A detailed decision paragraph.
2700
2701## Consequences
2702
2703- Important consequence 1
2704- Important consequence 2
2705"#;
2706        let content_2 = r#"---
2707number: 3
2708title: Second decision
2709date: 2026-01-16
2710status: accepted
2711---
2712
2713## Context
2714
2715Different context entirely.
2716
2717## Decision
2718
2719Another decision.
2720
2721## Consequences
2722
2723None significant.
2724"#;
2725        fs::write(repo.adr_path().join("0002-first-decision.md"), content_1).unwrap();
2726        fs::write(repo.adr_path().join("0003-second-decision.md"), content_2).unwrap();
2727
2728        repo.link(2, 3, LinkKind::Amends, LinkKind::AmendedBy)
2729            .unwrap();
2730
2731        let result_1 = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
2732        let result_2 = fs::read_to_string(repo.adr_path().join("0003-second-decision.md")).unwrap();
2733
2734        // Bodies must be intact
2735        assert!(result_1.contains("Custom context with **bold** and [links](https://example.com)"));
2736        assert!(result_1.contains("A detailed decision paragraph."));
2737        assert!(result_2.contains("Different context entirely."));
2738        assert!(result_2.contains("None significant."));
2739
2740        // Links must be present in frontmatter
2741        assert!(result_1.contains("links:"));
2742        assert!(result_1.contains("target: 3"));
2743        assert!(result_2.contains("links:"));
2744        assert!(result_2.contains("target: 2"));
2745    }
2746
2747    #[test]
2748    fn test_supersede_preserves_old_adr_body() {
2749        let temp = TempDir::new().unwrap();
2750        let repo = Repository::init(temp.path(), None, true).unwrap();
2751
2752        let rich_content = r#"---
2753number: 2
2754title: Original approach
2755date: 2026-01-15
2756status: accepted
2757---
2758
2759## Context and Problem Statement
2760
2761This has **rich** markdown with [links](https://example.com).
2762
2763```rust
2764fn important_code() -> bool {
2765    true
2766}
2767```
2768
2769## Decision Outcome
2770
2771We chose the original approach.
2772
2773| Criteria | Score |
2774|----------|-------|
2775| Speed    | 9/10  |
2776| Safety   | 8/10  |
2777"#;
2778        fs::write(
2779            repo.adr_path().join("0002-original-approach.md"),
2780            rich_content,
2781        )
2782        .unwrap();
2783
2784        repo.supersede("Better approach", 2).unwrap();
2785
2786        let old_content =
2787            fs::read_to_string(repo.adr_path().join("0002-original-approach.md")).unwrap();
2788
2789        // Old ADR body must be preserved
2790        assert!(old_content.contains("```rust"));
2791        assert!(old_content.contains("fn important_code()"));
2792        assert!(old_content.contains("| Criteria | Score |"));
2793        assert!(old_content.contains("[links](https://example.com)"));
2794
2795        // Status and links must be updated
2796        assert!(old_content.contains("status: superseded"));
2797        assert!(old_content.contains("target: 3"));
2798    }
2799
2800    #[test]
2801    fn test_set_status_legacy_preserves_sections() {
2802        let temp = TempDir::new().unwrap();
2803        let repo = Repository::init(temp.path(), None, false).unwrap();
2804
2805        let legacy_content = r#"# 2. Use Rust for backend
2806
2807Date: 2026-01-15
2808
2809## Status
2810
2811Proposed
2812
2813## Context
2814
2815We need a fast, safe language for our backend services.
2816
2817See the [Rust book](https://doc.rust-lang.org/book/) for details.
2818
2819## Decision
2820
2821We will use **Rust** with the `tokio` runtime.
2822
2823```toml
2824[dependencies]
2825tokio = { version = "1", features = ["full"] }
2826```
2827
2828## Consequences
2829
2830- Type safety prevents many bugs at compile time
2831- Learning curve for team members
2832"#;
2833        let adr_path = repo.adr_path().join("0002-use-rust-for-backend.md");
2834        fs::write(&adr_path, legacy_content).unwrap();
2835
2836        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2837
2838        let result = fs::read_to_string(&adr_path).unwrap();
2839
2840        // Status should change
2841        assert!(result.contains("Accepted"));
2842
2843        // Other sections must be preserved exactly
2844        assert!(result.contains("[Rust book](https://doc.rust-lang.org/book/)"));
2845        assert!(result.contains("**Rust**"));
2846        assert!(result.contains("`tokio`"));
2847        assert!(result.contains("```toml"));
2848        assert!(result.contains("tokio = { version = \"1\", features = [\"full\"] }"));
2849        assert!(result.contains("Type safety prevents many bugs"));
2850    }
2851
2852    #[test]
2853    fn test_set_status_frontmatter_with_existing_links() {
2854        let temp = TempDir::new().unwrap();
2855        let repo = Repository::init(temp.path(), None, true).unwrap();
2856
2857        let content = r#"---
2858number: 2
2859title: Updated approach
2860date: 2026-01-15
2861status: proposed
2862links:
2863  - target: 1
2864    kind: amends
2865---
2866
2867## Context
2868
2869Context.
2870
2871## Decision
2872
2873Decision.
2874"#;
2875        let adr_path = repo.adr_path().join("0002-updated-approach.md");
2876        fs::write(&adr_path, content).unwrap();
2877
2878        // Just change status, links should be preserved
2879        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2880
2881        let result = fs::read_to_string(&adr_path).unwrap();
2882        assert!(result.contains("status: accepted"));
2883        assert!(result.contains("links:"));
2884        assert!(result.contains("target: 1"));
2885        assert!(result.contains("kind: amends"));
2886        // No extra blank line before closing ---
2887        assert!(
2888            !result.contains("\n\n---"),
2889            "Should not have extra blank line before closing ---: {:?}",
2890            result
2891        );
2892    }
2893
2894    #[test]
2895    fn test_set_status_no_extra_newline_before_separator() {
2896        let temp = TempDir::new().unwrap();
2897        let repo = Repository::init(temp.path(), None, true).unwrap();
2898
2899        let content = "---\nnumber: 2\ntitle: Test\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
2900        let adr_path = repo.adr_path().join("0002-test.md");
2901        fs::write(&adr_path, content).unwrap();
2902
2903        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2904
2905        let result = fs::read_to_string(&adr_path).unwrap();
2906        assert!(result.contains("status: accepted"));
2907        // Frontmatter should close cleanly without extra blank line (#192)
2908        assert!(
2909            result.contains("\n---\n"),
2910            "Should have clean closing separator: {:?}",
2911            result
2912        );
2913        assert!(
2914            !result.contains("\n\n---"),
2915            "Should not have extra blank line before closing ---: {:?}",
2916            result
2917        );
2918    }
2919
2920    #[test]
2921    fn test_set_status_rejects_empty_custom() {
2922        let temp = TempDir::new().unwrap();
2923        let repo = Repository::init(temp.path(), None, true).unwrap();
2924
2925        let content = "---\nnumber: 2\ntitle: Test\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
2926        let adr_path = repo.adr_path().join("0002-test.md");
2927        fs::write(&adr_path, content).unwrap();
2928
2929        // Whitespace-only and empty custom statuses must be rejected (#305).
2930        for bad in ["", " ", "   ", "\t"] {
2931            let err = repo
2932                .set_status(2, AdrStatus::Custom(bad.to_string()), None)
2933                .unwrap_err();
2934            assert!(
2935                matches!(err, Error::InvalidStatus(_)),
2936                "expected InvalidStatus for {:?}, got {:?}",
2937                bad,
2938                err
2939            );
2940        }
2941
2942        // The file must be untouched and still parse.
2943        let result = fs::read_to_string(&adr_path).unwrap();
2944        assert!(result.contains("status: proposed"));
2945        assert!(repo.get(2).is_ok());
2946    }
2947
2948    /// `update_content` on a MADR 4.0.0 ADR must preserve unmodified sections and
2949    /// headings (not re-render as Nygard/adr-tools).
2950    #[test]
2951    fn test_update_madr_content_preserves_unmodified_sections() {
2952        let temp = TempDir::new().unwrap();
2953        let repo = Repository::init(temp.path(), None, true).unwrap();
2954
2955        let madr_content = r#"---
2956number: 2
2957title: Use Redis for caching
2958date: 2026-01-15
2959status: proposed
2960---
2961
2962# Use Redis for caching
2963
2964## Context and Problem Statement
2965
2966Original context about caching needs.
2967
2968## Considered Options
2969
2970* Redis
2971* Memcached
2972
2973## Decision Outcome
2974
2975Chosen option: "Redis", because it supports data structures beyond simple key-value.
2976
2977### Consequences
2978
2979* Good, because it provides pub/sub
2980"#;
2981        let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
2982        fs::write(&adr_path, madr_content).unwrap();
2983
2984        let mut adr = repo.get(2).unwrap();
2985        adr.context = "Updated context text.".into();
2986        repo.update(
2987            &adr,
2988            BodySectionPatch {
2989                context: Some("Updated context text.".into()),
2990                ..Default::default()
2991            },
2992        )
2993        .unwrap();
2994
2995        let result = fs::read_to_string(&adr_path).unwrap();
2996
2997        assert!(result.contains("## Context and Problem Statement"));
2998        assert!(result.contains("Updated context text."));
2999        assert!(result.contains("## Considered Options"));
3000        assert!(result.contains("* Memcached"));
3001        assert!(result.contains("## Decision Outcome"));
3002        assert!(result.contains("Chosen option: \"Redis\""));
3003        assert!(result.contains("### Consequences"));
3004        assert!(result.contains("* Good, because it provides pub/sub"));
3005        assert!(!result.contains("What is the change that we're proposing"));
3006        assert!(!result.contains("## Context\n"));
3007        assert!(!result.contains("## Decision\n"));
3008    }
3009
3010    #[test]
3011    fn test_update_madr_context_preserves_decision_h3_subsections() {
3012        let temp = TempDir::new().unwrap();
3013        let repo = Repository::init(temp.path(), None, true).unwrap();
3014
3015        let madr_content = r#"---
3016number: 2
3017title: Use Redis
3018date: 2026-01-15
3019status: proposed
3020---
3021
3022## Context and Problem Statement
3023
3024Original context.
3025
3026## Decision Outcome
3027
3028Chosen option: "Redis", because it is fast.
3029
3030### Consequences
3031
3032* Good, because it provides pub/sub
3033* Bad, because it needs memory
3034
3035### Confirmation
3036
3037We will confirm via load tests.
3038"#;
3039        let adr_path = repo.adr_path().join("0002-use-redis.md");
3040        fs::write(&adr_path, madr_content).unwrap();
3041
3042        let mut adr = repo.get(2).unwrap();
3043        adr.context = "Updated context only.".into();
3044        repo.update(
3045            &adr,
3046            BodySectionPatch {
3047                context: Some("Updated context only.".into()),
3048                ..Default::default()
3049            },
3050        )
3051        .unwrap();
3052
3053        let result = fs::read_to_string(&adr_path).unwrap();
3054        assert!(result.contains("Updated context only."));
3055        assert!(result.contains("Chosen option: \"Redis\", because it is fast."));
3056        assert!(result.contains("### Consequences"));
3057        assert!(result.contains("* Good, because it provides pub/sub"));
3058        assert!(result.contains("* Bad, because it needs memory"));
3059        assert!(result.contains("### Confirmation"));
3060        assert!(result.contains("We will confirm via load tests."));
3061    }
3062
3063    #[test]
3064    fn test_update_madr_consequences_patches_h3_subsection() {
3065        let temp = TempDir::new().unwrap();
3066        let repo = Repository::init(temp.path(), None, true).unwrap();
3067
3068        let madr_content = r#"---
3069number: 2
3070title: Use Redis
3071date: 2026-01-15
3072status: proposed
3073---
3074
3075## Context and Problem Statement
3076
3077Context.
3078
3079## Decision Outcome
3080
3081Chosen option: "Redis", because it is fast.
3082
3083### Consequences
3084
3085* Old consequence
3086"#;
3087        let adr_path = repo.adr_path().join("0002-use-redis.md");
3088        fs::write(&adr_path, madr_content).unwrap();
3089
3090        let mut adr = repo.get(2).unwrap();
3091        adr.consequences = "* New consequence".into();
3092        repo.update(
3093            &adr,
3094            BodySectionPatch {
3095                consequences: Some("* New consequence".into()),
3096                ..Default::default()
3097            },
3098        )
3099        .unwrap();
3100
3101        let result = fs::read_to_string(&adr_path).unwrap();
3102        assert!(result.contains("Chosen option: \"Redis\", because it is fast."));
3103        assert!(result.contains("### Consequences"));
3104        assert!(result.contains("* New consequence"));
3105        assert!(!result.contains("* Old consequence"));
3106    }
3107
3108    /// MADR 4.0.0 section bodies must round-trip through parse → update → parse.
3109    #[test]
3110    fn test_update_madr_content_round_trip_via_get() {
3111        let temp = TempDir::new().unwrap();
3112        let repo = Repository::init(temp.path(), None, true).unwrap();
3113
3114        let madr_content = r#"---
3115number: 2
3116title: Use PostgreSQL
3117date: 2026-01-15
3118status: proposed
3119---
3120
3121## Context and Problem Statement
3122
3123We need a relational database.
3124
3125## Decision Outcome
3126
3127We will use PostgreSQL 16.
3128"#;
3129        let adr_path = repo.adr_path().join("0002-use-postgresql.md");
3130        fs::write(&adr_path, madr_content).unwrap();
3131
3132        let mut adr = repo.get(2).unwrap();
3133        adr.context = "Updated context only.".into();
3134        repo.update(
3135            &adr,
3136            BodySectionPatch {
3137                context: Some("Updated context only.".into()),
3138                ..Default::default()
3139            },
3140        )
3141        .unwrap();
3142
3143        let reloaded = repo.get(2).unwrap();
3144        assert_eq!(reloaded.context, "Updated context only.");
3145        assert_eq!(reloaded.decision, "We will use PostgreSQL 16.");
3146    }
3147
3148    /// Issue #338: the read path mapped MADR `### Consequences` (under
3149    /// `## Decision Outcome`) to `decision` instead of `consequences`, so a
3150    /// consequences-only patch followed by `get()` showed the new text
3151    /// folded into `decision` with `consequences` still empty. This is the
3152    /// read-side counterpart to `test_update_madr_consequences_patches_h3_subsection`,
3153    /// which only checks the raw file bytes.
3154    #[test]
3155    fn test_update_madr_consequences_only_round_trip_via_get() {
3156        let temp = TempDir::new().unwrap();
3157        let repo = Repository::init(temp.path(), None, true).unwrap();
3158
3159        let madr_content = r#"---
3160number: 2
3161title: Use Redis
3162date: 2026-01-15
3163status: proposed
3164---
3165
3166## Context and Problem Statement
3167
3168Context.
3169
3170## Decision Outcome
3171
3172Chosen option: "Redis", because it is fast.
3173
3174### Consequences
3175
3176Old consequence text.
3177"#;
3178        let adr_path = repo.adr_path().join("0002-use-redis.md");
3179        fs::write(&adr_path, madr_content).unwrap();
3180
3181        let mut adr = repo.get(2).unwrap();
3182        adr.consequences = "New consequence text.".into();
3183        repo.update(
3184            &adr,
3185            BodySectionPatch::new().with_consequences("New consequence text."),
3186        )
3187        .unwrap();
3188
3189        let reloaded = repo.get(2).unwrap();
3190        assert_eq!(reloaded.consequences, "New consequence text.");
3191        assert_eq!(
3192            reloaded.decision,
3193            "Chosen option: \"Redis\", because it is fast."
3194        );
3195        assert!(
3196            !reloaded.decision.contains("New consequence text."),
3197            "consequences text leaked into decision on read:\n{}",
3198            reloaded.decision
3199        );
3200    }
3201
3202    #[test]
3203    fn test_update_metadata_adds_tags_to_frontmatter() {
3204        let temp = TempDir::new().unwrap();
3205        let repo = Repository::init(temp.path(), None, true).unwrap();
3206
3207        let content = r#"---
3208number: 2
3209title: Tagged ADR
3210date: 2026-01-15
3211status: proposed
3212---
3213
3214## Context
3215
3216Context.
3217"#;
3218        let adr_path = repo.adr_path().join("0002-tagged-adr.md");
3219        fs::write(&adr_path, content).unwrap();
3220
3221        let mut adr = repo.get(2).unwrap();
3222        adr.set_tags(vec!["security".into(), "api".into()]);
3223        repo.update_metadata(&adr).unwrap();
3224
3225        let result = fs::read_to_string(&adr_path).unwrap();
3226        assert!(result.contains("tags:"));
3227        // serde_yaml emits block sequences at column 0; both indent styles are valid.
3228        assert!(
3229            result.contains("- security") && result.contains("- api"),
3230            "tags missing from frontmatter\n{result}"
3231        );
3232        // Body preserved
3233        assert!(result.contains("## Context\n\nContext."));
3234    }
3235
3236    // ========== list_with_errors Tests ==========
3237
3238    #[test]
3239    fn test_list_with_errors_all_valid() {
3240        let temp = TempDir::new().unwrap();
3241        let repo = Repository::init(temp.path(), None, true).unwrap();
3242        repo.new_adr("Valid ADR").unwrap();
3243
3244        let (adrs, errors) = repo.list_with_errors().unwrap();
3245        assert_eq!(adrs.len(), 2); // init ADR + new one
3246        assert!(errors.is_empty());
3247    }
3248
3249    #[test]
3250    fn test_list_with_errors_captures_invalid_frontmatter() {
3251        let temp = TempDir::new().unwrap();
3252        let repo = Repository::init(temp.path(), None, true).unwrap();
3253
3254        // Write a file with invalid YAML frontmatter (bad date format)
3255        let bad_content =
3256            "---\nnumber: 2\nstatus: accepted\ndate: not-a-date\n---\n\n# 2. Bad ADR\n";
3257        fs::write(repo.adr_path().join("0002-bad-adr.md"), bad_content).unwrap();
3258
3259        let (adrs, errors) = repo.list_with_errors().unwrap();
3260        assert_eq!(adrs.len(), 1); // Only the init ADR
3261        assert_eq!(errors.len(), 1);
3262        assert!(errors[0].0.to_string_lossy().contains("0002-bad-adr.md"));
3263    }
3264
3265    #[test]
3266    fn test_list_with_errors_mixed_valid_and_invalid() {
3267        let temp = TempDir::new().unwrap();
3268        let repo = Repository::init(temp.path(), None, true).unwrap();
3269
3270        // Valid ADR
3271        repo.new_adr("Good ADR").unwrap();
3272
3273        // Invalid ADR (completely broken YAML)
3274        let bad_content = "---\n: :\n---\n\n# 3. Broken\n";
3275        fs::write(repo.adr_path().join("0003-broken.md"), bad_content).unwrap();
3276
3277        let (adrs, errors) = repo.list_with_errors().unwrap();
3278        assert_eq!(adrs.len(), 2); // init + good
3279        assert_eq!(errors.len(), 1); // broken
3280    }
3281
3282    #[test]
3283    fn test_list_with_errors_string_decision_makers_is_valid() {
3284        let temp = TempDir::new().unwrap();
3285        let repo = Repository::init(temp.path(), None, true).unwrap();
3286
3287        // This is the exact case from issue #216
3288        let content = r#"---
3289number: 2
3290status: accepted
3291date: 2026-03-18
3292decision-makers: mschoettle
3293---
3294
3295# 2. Use Markdown Architectural Decision Records
3296"#;
3297        fs::write(repo.adr_path().join("0002-use-markdown-adrs.md"), content).unwrap();
3298
3299        let (adrs, errors) = repo.list_with_errors().unwrap();
3300        assert!(errors.is_empty(), "string decision-makers should parse");
3301        assert_eq!(adrs.len(), 2);
3302
3303        let adr = adrs.iter().find(|a| a.number == 2).unwrap();
3304        assert_eq!(adr.decision_makers, vec!["mschoettle"]);
3305    }
3306    // ========== Link metadata round-trip Tests (#323, #325) ==========
3307
3308    #[test]
3309    fn test_update_metadata_preserves_link_descriptions() {
3310        let temp = TempDir::new().unwrap();
3311        let repo = Repository::init(temp.path(), None, true).unwrap();
3312
3313        let content = r#"---
3314number: 2
3315title: Linked ADR
3316date: 2026-01-15
3317status: proposed
3318links:
3319  - target: 1
3320    kind: relatesto
3321    description: Explains the connection
3322---
3323
3324## Context
3325
3326Context.
3327"#;
3328        let adr_path = repo.adr_path().join("0002-linked-adr.md");
3329        fs::write(&adr_path, content).unwrap();
3330
3331        let adr = repo.get(2).unwrap();
3332        assert_eq!(
3333            adr.links[0].description.as_deref(),
3334            Some("Explains the connection")
3335        );
3336
3337        // No-op update_metadata must not drop the description.
3338        repo.update_metadata(&adr).unwrap();
3339        let result = fs::read_to_string(&adr_path).unwrap();
3340        assert!(result.contains("kind: relatesto"));
3341        assert!(result.contains("description: Explains the connection"));
3342    }
3343
3344    #[test]
3345    fn test_update_metadata_kebab_case_kind_round_trips_verbatim() {
3346        // KNOWN-QUIRK (#323, deferred): kebab-case `kind: relates-to` deserializes
3347        // as LinkKind::Custom("relates-to") rather than LinkKind::RelatesTo (see
3348        // the note on LinkKind). update_metadata must at least not silently
3349        // rewrite it to the canonical "relatesto" spelling on write-back.
3350        let temp = TempDir::new().unwrap();
3351        let repo = Repository::init(temp.path(), None, true).unwrap();
3352
3353        let content = r#"---
3354number: 2
3355title: Linked ADR
3356date: 2026-01-15
3357status: proposed
3358links:
3359  - target: 1
3360    kind: relates-to
3361---
3362
3363## Context
3364
3365Context.
3366"#;
3367        let adr_path = repo.adr_path().join("0002-linked-adr.md");
3368        fs::write(&adr_path, content).unwrap();
3369
3370        let adr = repo.get(2).unwrap();
3371        assert_eq!(adr.links[0].kind, LinkKind::Custom("relates-to".into()));
3372
3373        repo.update_metadata(&adr).unwrap();
3374        let result = fs::read_to_string(&adr_path).unwrap();
3375        assert!(result.contains("kind: relates-to"));
3376    }
3377
3378    #[test]
3379    fn test_resolve_link_titles_uses_actual_filename_for_hand_named_target() {
3380        let temp = TempDir::new().unwrap();
3381        let repo = Repository::init(temp.path(), None, false).unwrap();
3382
3383        // Target's on-disk filename deliberately differs from a slug of its title.
3384        let target_content = "# 2. Use Rust for backend services\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nContext.\n";
3385        fs::write(
3386            repo.adr_path().join("0002-use-rust-for-backend.md"),
3387            target_content,
3388        )
3389        .unwrap();
3390
3391        let mut source = repo.get(1).unwrap();
3392        source.add_link(AdrLink::new(2, LinkKind::Amends));
3393
3394        let titles = repo.resolve_link_titles(&source);
3395        let (title, filename) = titles.get(&2).unwrap();
3396        assert_eq!(title, "Use Rust for backend services");
3397        assert_eq!(filename, "0002-use-rust-for-backend.md");
3398    }
3399
3400    #[test]
3401    fn test_link_href_prefers_actual_path_over_slugified_title() {
3402        let target = Adr {
3403            path: Some(PathBuf::from("/repo/doc/adr/0003-use-rust-for-backend.md")),
3404            ..Adr::new(3, "Use Rust for backend services")
3405        };
3406        assert_eq!(
3407            Repository::link_href(&target),
3408            "0003-use-rust-for-backend.md"
3409        );
3410    }
3411
3412    #[test]
3413    fn test_link_href_falls_back_to_slugified_title_when_path_missing() {
3414        let target = Adr {
3415            path: None,
3416            ..Adr::new(3, "Use Rust for backend services")
3417        };
3418        assert_eq!(
3419            Repository::link_href(&target),
3420            "0003-use-rust-for-backend-services.md"
3421        );
3422    }
3423
3424    // ========== BodySectionPatch preservation tests (issue #310) ==========
3425
3426    /// Extract from `## {heading}` through the line before the next H2 (inclusive).
3427    fn extract_h2_block(content: &str, heading: &str) -> Option<String> {
3428        let lines: Vec<&str> = content.lines().collect();
3429        let marker = format!("## {heading}");
3430        let start = lines.iter().position(|l| l.trim() == marker)?;
3431        let end = lines[(start + 1)..]
3432            .iter()
3433            .position(|l| l.starts_with("## "))
3434            .map(|p| start + 1 + p)
3435            .unwrap_or(lines.len());
3436        Some(lines[start..end].join("\n"))
3437    }
3438
3439    fn assert_h2_block_unchanged(before: &str, after: &str, heading: &str) {
3440        assert_eq!(
3441            extract_h2_block(before, heading),
3442            extract_h2_block(after, heading),
3443            "section `{heading}` should be byte-identical"
3444        );
3445    }
3446
3447    #[test]
3448    fn test_update_ng_nygard_rich_context_preserved_on_decision_patch() {
3449        let temp = TempDir::new().unwrap();
3450        let repo = Repository::init(temp.path(), None, true).unwrap();
3451
3452        let content = r#"---
3453number: 2
3454title: Rich context ADR
3455date: 2026-01-15
3456status: proposed
3457---
3458
3459## Context
3460
3461We need caching. See the [Redis docs](https://redis.io).
3462
3463* Requirement one
3464* Requirement two
3465
3466Use `redis-cli` for debugging.
3467
3468## Decision
3469
3470Old decision.
3471
3472## Consequences
3473
3474Old consequences.
3475"#;
3476        let adr_path = repo.adr_path().join("0002-rich-context-adr.md");
3477        fs::write(&adr_path, content).unwrap();
3478        let before = content.to_string();
3479
3480        let mut adr = repo.get(2).unwrap();
3481        adr.decision = "New decision.".into();
3482        repo.update(
3483            &adr,
3484            BodySectionPatch {
3485                decision: Some("New decision.".into()),
3486                ..Default::default()
3487            },
3488        )
3489        .unwrap();
3490
3491        let after = fs::read_to_string(&adr_path).unwrap();
3492        assert_h2_block_unchanged(&before, &after, "Context");
3493        assert_h2_block_unchanged(&before, &after, "Consequences");
3494        assert!(after.contains("New decision."));
3495    }
3496
3497    #[test]
3498    fn test_update_madr_rich_context_preserved_on_consequences_patch() {
3499        let temp = TempDir::new().unwrap();
3500        let repo = Repository::init(temp.path(), None, true).unwrap();
3501
3502        let content = r#"---
3503number: 2
3504title: Rich MADR context
3505date: 2026-01-15
3506status: proposed
3507---
3508
3509## Context and Problem Statement
3510
3511We need caching. See the [Redis docs](https://redis.io).
3512
3513* Requirement one
3514* Requirement two
3515
3516Use `redis-cli` for debugging.
3517
3518## Decision Outcome
3519
3520Chosen option: "Redis", because it is fast.
3521
3522### Consequences
3523
3524* Old consequence
3525"#;
3526        let adr_path = repo.adr_path().join("0002-rich-madr-context.md");
3527        fs::write(&adr_path, content).unwrap();
3528        let before = content.to_string();
3529
3530        let mut adr = repo.get(2).unwrap();
3531        adr.consequences = "* New consequence".into();
3532        repo.update(
3533            &adr,
3534            BodySectionPatch {
3535                consequences: Some("* New consequence".into()),
3536                ..Default::default()
3537            },
3538        )
3539        .unwrap();
3540
3541        let after = fs::read_to_string(&adr_path).unwrap();
3542        assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
3543        assert!(after.contains("Chosen option: \"Redis\", because it is fast."));
3544        assert!(after.contains("* New consequence"));
3545        assert!(!after.contains("* Old consequence"));
3546    }
3547
3548    #[test]
3549    fn test_update_legacy_rich_context_preserved_on_decision_patch() {
3550        let temp = TempDir::new().unwrap();
3551        let repo = Repository::init(temp.path(), None, false).unwrap();
3552
3553        let content = r#"# 2. Legacy rich context
3554
3555Date: 2026-01-15
3556
3557## Status
3558
3559Proposed
3560
3561## Context
3562
3563We need caching. See the [Redis docs](https://redis.io).
3564
3565* Requirement one
3566* Requirement two
3567
3568Use `redis-cli` for debugging.
3569
3570## Decision
3571
3572Old decision.
3573
3574## Consequences
3575
3576Old consequences.
3577"#;
3578        let adr_path = repo.adr_path().join("0002-legacy-rich-context.md");
3579        fs::write(&adr_path, content).unwrap();
3580        let before = content.to_string();
3581
3582        let mut adr = repo.get(2).unwrap();
3583        adr.decision = "New decision.".into();
3584        repo.update(
3585            &adr,
3586            BodySectionPatch {
3587                decision: Some("New decision.".into()),
3588                ..Default::default()
3589            },
3590        )
3591        .unwrap();
3592
3593        let after = fs::read_to_string(&adr_path).unwrap();
3594        assert_h2_block_unchanged(&before, &after, "Context");
3595        assert_h2_block_unchanged(&before, &after, "Consequences");
3596        assert!(after.contains("New decision."));
3597    }
3598
3599    #[test]
3600    fn test_update_madr_decision_only_preserves_h3_subsections() {
3601        let temp = TempDir::new().unwrap();
3602        let repo = Repository::init(temp.path(), None, true).unwrap();
3603
3604        let content = r#"---
3605number: 2
3606title: Decision patch MADR
3607date: 2026-01-15
3608status: proposed
3609---
3610
3611## Context and Problem Statement
3612
3613Context.
3614
3615## Decision Outcome
3616
3617Old intro text.
3618
3619### Consequences
3620
3621* Good, because it is fast
3622* Bad, because it uses memory
3623
3624### Confirmation
3625
3626Confirm via load tests.
3627"#;
3628        let adr_path = repo.adr_path().join("0002-decision-patch-madr.md");
3629        fs::write(&adr_path, content).unwrap();
3630        let before = content.to_string();
3631
3632        let mut adr = repo.get(2).unwrap();
3633        adr.decision = "New intro text.".into();
3634        repo.update(
3635            &adr,
3636            BodySectionPatch {
3637                decision: Some("New intro text.".into()),
3638                ..Default::default()
3639            },
3640        )
3641        .unwrap();
3642
3643        let after = fs::read_to_string(&adr_path).unwrap();
3644        assert!(after.contains("New intro text."));
3645        assert!(!after.contains("Old intro text."));
3646        assert!(after.contains("### Consequences"));
3647        assert!(after.contains("* Good, because it is fast"));
3648        assert!(after.contains("* Bad, because it uses memory"));
3649        assert!(after.contains("### Confirmation"));
3650        assert!(after.contains("Confirm via load tests."));
3651        assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
3652    }
3653
3654    #[test]
3655    fn test_update_madr_decision_and_consequences_together_preserves_other_h3() {
3656        let temp = TempDir::new().unwrap();
3657        let repo = Repository::init(temp.path(), None, true).unwrap();
3658
3659        let content = r#"---
3660number: 2
3661title: Combined patch MADR
3662date: 2026-01-15
3663status: proposed
3664---
3665
3666## Context and Problem Statement
3667
3668Context.
3669
3670## Decision Outcome
3671
3672Old intro.
3673
3674### Consequences
3675
3676* Old consequence
3677
3678### Confirmation
3679
3680Confirm via load tests.
3681"#;
3682        let adr_path = repo.adr_path().join("0002-combined-patch-madr.md");
3683        fs::write(&adr_path, content).unwrap();
3684        let before = content.to_string();
3685
3686        let mut adr = repo.get(2).unwrap();
3687        adr.decision = "New intro.".into();
3688        adr.consequences = "* New consequence".into();
3689        repo.update(
3690            &adr,
3691            BodySectionPatch {
3692                decision: Some("New intro.".into()),
3693                consequences: Some("* New consequence".into()),
3694                ..Default::default()
3695            },
3696        )
3697        .unwrap();
3698
3699        let after = fs::read_to_string(&adr_path).unwrap();
3700        assert!(after.contains("New intro."));
3701        assert!(after.contains("* New consequence"));
3702        assert!(!after.contains("Old intro."));
3703        assert!(!after.contains("* Old consequence"));
3704        assert!(after.contains("### Confirmation"));
3705        assert!(after.contains("Confirm via load tests."));
3706        assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
3707    }
3708
3709    #[test]
3710    fn test_update_ng_nygard_consequences_only_patch() {
3711        let temp = TempDir::new().unwrap();
3712        let repo = Repository::init(temp.path(), None, true).unwrap();
3713
3714        let content = r#"---
3715number: 2
3716title: Nygard consequences patch
3717date: 2026-01-15
3718status: proposed
3719---
3720
3721## Context
3722
3723Original context.
3724
3725## Decision
3726
3727Original decision.
3728
3729## Consequences
3730
3731* Old item
3732"#;
3733        let adr_path = repo.adr_path().join("0002-nygard-consequences-patch.md");
3734        fs::write(&adr_path, content).unwrap();
3735        let before = content.to_string();
3736
3737        let mut adr = repo.get(2).unwrap();
3738        adr.consequences = "* New item".into();
3739        repo.update(
3740            &adr,
3741            BodySectionPatch {
3742                consequences: Some("* New item".into()),
3743                ..Default::default()
3744            },
3745        )
3746        .unwrap();
3747
3748        let after = fs::read_to_string(&adr_path).unwrap();
3749        assert_h2_block_unchanged(&before, &after, "Context");
3750        assert_h2_block_unchanged(&before, &after, "Decision");
3751        assert!(after.contains("* New item"));
3752        assert!(!after.contains("* Old item"));
3753    }
3754
3755    #[test]
3756    fn test_update_ng_nygard_context_only_preserves_consequences_section() {
3757        let temp = TempDir::new().unwrap();
3758        let repo = Repository::init(temp.path(), None, true).unwrap();
3759
3760        let content = r#"---
3761number: 2
3762title: Nygard context patch
3763date: 2026-01-15
3764status: proposed
3765---
3766
3767## Context
3768
3769Old context.
3770
3771## Decision
3772
3773Original decision.
3774
3775## Consequences
3776
3777* Good, because it is fast
3778* Bad, because it uses memory
3779"#;
3780        let adr_path = repo.adr_path().join("0002-nygard-context-patch.md");
3781        fs::write(&adr_path, content).unwrap();
3782        let before = content.to_string();
3783
3784        let mut adr = repo.get(2).unwrap();
3785        adr.context = "New context.".into();
3786        repo.update(
3787            &adr,
3788            BodySectionPatch {
3789                context: Some("New context.".into()),
3790                ..Default::default()
3791            },
3792        )
3793        .unwrap();
3794
3795        let after = fs::read_to_string(&adr_path).unwrap();
3796        assert_h2_block_unchanged(&before, &after, "Decision");
3797        assert_h2_block_unchanged(&before, &after, "Consequences");
3798        assert!(after.contains("New context."));
3799    }
3800
3801    #[test]
3802    fn test_update_metadata_only_preserves_madr_body_byte_identical() {
3803        let temp = TempDir::new().unwrap();
3804        let repo = Repository::init(temp.path(), None, true).unwrap();
3805
3806        let content = r#"---
3807number: 2
3808title: Metadata only MADR
3809date: 2026-01-15
3810status: proposed
3811---
3812
3813## Context and Problem Statement
3814
3815Context.
3816
3817## Decision Outcome
3818
3819Intro.
3820
3821### Consequences
3822
3823* Good item
3824
3825### Confirmation
3826
3827Confirm via tests.
3828"#;
3829        let adr_path = repo.adr_path().join("0002-metadata-only-madr.md");
3830        fs::write(&adr_path, content).unwrap();
3831        let before = fs::read_to_string(&adr_path).unwrap();
3832        let body_start = before.find("\n\n## Context").unwrap();
3833
3834        let mut adr = repo.get(2).unwrap();
3835        adr.status = AdrStatus::Accepted;
3836        repo.update(&adr, BodySectionPatch::default()).unwrap();
3837
3838        let after = fs::read_to_string(&adr_path).unwrap();
3839        assert_eq!(
3840            &before[body_start..],
3841            &after[after.find("\n\n## Context").unwrap()..]
3842        );
3843        assert!(after.contains("status: accepted"));
3844    }
3845
3846    #[test]
3847    fn test_update_madr_consequences_appends_h3_when_missing() {
3848        let temp = TempDir::new().unwrap();
3849        let repo = Repository::init(temp.path(), None, true).unwrap();
3850
3851        let content = r#"---
3852number: 2
3853title: Append consequences MADR
3854date: 2026-01-15
3855status: proposed
3856---
3857
3858## Context and Problem Statement
3859
3860Context.
3861
3862## Decision Outcome
3863
3864Intro only, no consequences subsection yet.
3865"#;
3866        let adr_path = repo.adr_path().join("0002-append-consequences-madr.md");
3867        fs::write(&adr_path, content).unwrap();
3868
3869        let mut adr = repo.get(2).unwrap();
3870        adr.consequences = "* Appended consequence".into();
3871        repo.update(
3872            &adr,
3873            BodySectionPatch {
3874                consequences: Some("* Appended consequence".into()),
3875                ..Default::default()
3876            },
3877        )
3878        .unwrap();
3879
3880        let after = fs::read_to_string(&adr_path).unwrap();
3881        assert!(after.contains("Intro only, no consequences subsection yet."));
3882        assert!(after.contains("### Consequences"));
3883        assert!(after.contains("* Appended consequence"));
3884    }
3885
3886    #[test]
3887    fn test_update_madr_in_compatible_mode_repo() {
3888        let temp = TempDir::new().unwrap();
3889        let repo = Repository::init(temp.path(), None, false).unwrap();
3890
3891        let content = r#"---
3892number: 2
3893title: Compatible repo MADR file
3894date: 2026-01-15
3895status: proposed
3896---
3897
3898## Context and Problem Statement
3899
3900Original context.
3901
3902## Decision Outcome
3903
3904Chosen option: "Redis", because it is fast.
3905
3906### Consequences
3907
3908* Good item
3909
3910### Confirmation
3911
3912Confirm via tests.
3913"#;
3914        let adr_path = repo.adr_path().join("0002-compatible-repo-madr-file.md");
3915        fs::write(&adr_path, content).unwrap();
3916        let before = content.to_string();
3917
3918        let mut adr = repo.get(2).unwrap();
3919        adr.context = "Updated context.".into();
3920        repo.update(
3921            &adr,
3922            BodySectionPatch {
3923                context: Some("Updated context.".into()),
3924                ..Default::default()
3925            },
3926        )
3927        .unwrap();
3928
3929        let after = fs::read_to_string(&adr_path).unwrap();
3930        assert!(after.contains("Updated context."));
3931        assert_h2_block_unchanged(&before, &after, "Decision Outcome");
3932    }
3933
3934    #[test]
3935    fn test_update_madr_decision_only_without_h3_subsections() {
3936        let temp = TempDir::new().unwrap();
3937        let repo = Repository::init(temp.path(), None, true).unwrap();
3938
3939        let content = r#"---
3940number: 2
3941title: Simple decision MADR
3942date: 2026-01-15
3943status: proposed
3944---
3945
3946## Context and Problem Statement
3947
3948Context.
3949
3950## Decision Outcome
3951
3952Old single-paragraph decision.
3953"#;
3954        let adr_path = repo.adr_path().join("0002-simple-decision-madr.md");
3955        fs::write(&adr_path, content).unwrap();
3956
3957        let mut adr = repo.get(2).unwrap();
3958        adr.decision = "New single-paragraph decision.".into();
3959        repo.update(
3960            &adr,
3961            BodySectionPatch {
3962                decision: Some("New single-paragraph decision.".into()),
3963                ..Default::default()
3964            },
3965        )
3966        .unwrap();
3967
3968        let after = fs::read_to_string(&adr_path).unwrap();
3969        assert!(after.contains("New single-paragraph decision."));
3970        assert!(!after.contains("Old single-paragraph decision."));
3971    }
3972
3973    #[test]
3974    fn test_update_madr_optional_sections_preserved_byte_identical() {
3975        let temp = TempDir::new().unwrap();
3976        let repo = Repository::init(temp.path(), None, true).unwrap();
3977
3978        let content = r#"---
3979number: 2
3980title: MADR optional sections
3981date: 2026-01-15
3982status: proposed
3983---
3984
3985## Context and Problem Statement
3986
3987Original context.
3988
3989## Considered Options
3990
3991* Redis
3992* Memcached
3993
3994## Decision Outcome
3995
3996Chosen option: "Redis", because it is fast.
3997
3998### Consequences
3999
4000* Good item
4001
4002## Pros and Cons of the Options
4003
4004### Redis
4005
4006* Good, because fast
4007* Bad, because memory
4008
4009### Memcached
4010
4011* Good, because simple
4012* Bad, because strings only
4013
4014## More Information
4015
4016See [MADR](https://adr.github.io/madr/) for details.
4017"#;
4018        let adr_path = repo.adr_path().join("0002-madr-optional-sections.md");
4019        fs::write(&adr_path, content).unwrap();
4020        let before = content.to_string();
4021
4022        let mut adr = repo.get(2).unwrap();
4023        adr.context = "Updated context.".into();
4024        repo.update(
4025            &adr,
4026            BodySectionPatch {
4027                context: Some("Updated context.".into()),
4028                ..Default::default()
4029            },
4030        )
4031        .unwrap();
4032
4033        let after = fs::read_to_string(&adr_path).unwrap();
4034        assert!(after.contains("Updated context."));
4035        assert_h2_block_unchanged(&before, &after, "Considered Options");
4036        assert_h2_block_unchanged(&before, &after, "Decision Outcome");
4037        assert_h2_block_unchanged(&before, &after, "Pros and Cons of the Options");
4038        assert_h2_block_unchanged(&before, &after, "More Information");
4039    }
4040
4041    #[test]
4042    fn test_update_unchanged_sections_byte_identical_after_context_patch() {
4043        let temp = TempDir::new().unwrap();
4044        let repo = Repository::init(temp.path(), None, true).unwrap();
4045
4046        let content = r#"---
4047number: 2
4048title: Byte identity check
4049date: 2026-01-15
4050status: proposed
4051---
4052
4053## Context and Problem Statement
4054
4055Original context.
4056
4057## Decision Outcome
4058
4059Intro.
4060
4061### Consequences
4062
4063* Good item
4064
4065### Confirmation
4066
4067Confirm via tests.
4068"#;
4069        let adr_path = repo.adr_path().join("0002-byte-identity-check.md");
4070        fs::write(&adr_path, content).unwrap();
4071        let before = content.to_string();
4072
4073        let mut adr = repo.get(2).unwrap();
4074        adr.context = "Updated context.".into();
4075        repo.update(
4076            &adr,
4077            BodySectionPatch {
4078                context: Some("Updated context.".into()),
4079                ..Default::default()
4080            },
4081        )
4082        .unwrap();
4083
4084        let after = fs::read_to_string(&adr_path).unwrap();
4085        assert_h2_block_unchanged(&before, &after, "Decision Outcome");
4086    }
4087
4088    #[test]
4089    fn test_update_madr_context_patch_does_not_round_trip_lossy_fields() {
4090        let temp = TempDir::new().unwrap();
4091        let repo = Repository::init(temp.path(), None, true).unwrap();
4092
4093        let content = r#"---
4094number: 2
4095title: Lossy parse guard
4096date: 2026-01-15
4097status: proposed
4098---
4099
4100## Context and Problem Statement
4101
4102Original context.
4103
4104## Decision Outcome
4105
4106See [Redis docs](https://redis.io) and use `redis-cli`.
4107
4108* Chosen option: "Redis"
4109* Because it is **fast**
4110
4111### Consequences
4112
4113* Good item
4114"#;
4115        let adr_path = repo.adr_path().join("0002-lossy-parse-guard.md");
4116        fs::write(&adr_path, content).unwrap();
4117        let before = content.to_string();
4118
4119        let mut adr = repo.get(2).unwrap();
4120        // Simulate MCP path: get() lossy-parses decision, but we only patch context.
4121        adr.context = "Updated context.".into();
4122        repo.update(
4123            &adr,
4124            BodySectionPatch {
4125                context: Some("Updated context.".into()),
4126                ..Default::default()
4127            },
4128        )
4129        .unwrap();
4130
4131        let after = fs::read_to_string(&adr_path).unwrap();
4132        assert_h2_block_unchanged(&before, &after, "Decision Outcome");
4133        assert!(after.contains("[Redis docs](https://redis.io)"));
4134        assert!(after.contains("`redis-cli`"));
4135        assert!(after.contains("**fast**"));
4136    }
4137
4138    // ========== BodySectionPatch write-path regressions ==========
4139
4140    #[test]
4141    fn test_fence_in_decision_outcome_preserved_on_consequences_patch() {
4142        let temp = TempDir::new().unwrap();
4143        let repo = Repository::init(temp.path(), None, true).unwrap();
4144
4145        let content = r#"---
4146number: 2
4147title: Fenced example in decision
4148date: 2026-01-15
4149status: proposed
4150---
4151
4152## Context and Problem Statement
4153
4154Context.
4155
4156## Decision Outcome
4157
4158Chosen option: "Redis", because it is fast.
4159
4160```markdown
4161## Consequences
4162
4163Example consequences inside a fence.
4164```
4165
4166Trailing text after the fence.
4167
4168### Consequences
4169
4170* Good, because it provides pub/sub
4171
4172### Confirmation
4173
4174We will confirm via load tests.
4175"#;
4176        let adr_path = repo.adr_path().join("0002-fenced-decision-outcome.md");
4177        fs::write(&adr_path, content).unwrap();
4178        let before = content.to_string();
4179
4180        let mut adr = repo.get(2).unwrap();
4181        adr.consequences = "* Updated consequence".into();
4182        repo.update(
4183            &adr,
4184            BodySectionPatch {
4185                consequences: Some("* Updated consequence".into()),
4186                ..Default::default()
4187            },
4188        )
4189        .unwrap();
4190
4191        let after = fs::read_to_string(&adr_path).unwrap();
4192        assert!(after.contains("```markdown"));
4193        assert!(after.contains("Example consequences inside a fence."));
4194        assert!(after.contains("Trailing text after the fence."));
4195        assert!(after.contains("### Confirmation"));
4196        assert!(after.contains("We will confirm via load tests."));
4197        assert!(after.contains("* Updated consequence"));
4198        assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
4199    }
4200
4201    #[test]
4202    fn test_body_patch_preserves_sections_without_trailing_newline() {
4203        let temp = TempDir::new().unwrap();
4204        let repo = Repository::init(temp.path(), None, false).unwrap();
4205
4206        let content = "# 2. Compact file\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nOld context.\n\n## Decision\n\nOld decision.\n\n## Consequences\n\nOld consequences.";
4207        let adr_path = repo.adr_path().join("0002-no-trailing-newline.md");
4208        fs::write(&adr_path, content).unwrap();
4209        assert_ne!(fs::read(&adr_path).unwrap().last(), Some(&b'\n'));
4210
4211        let mut adr = repo.get(2).unwrap();
4212        adr.decision = "New decision.".into();
4213        repo.update(
4214            &adr,
4215            BodySectionPatch {
4216                decision: Some("New decision.".into()),
4217                ..Default::default()
4218            },
4219        )
4220        .unwrap();
4221
4222        let after = fs::read_to_string(&adr_path).unwrap();
4223        assert!(!after.contains("Old context.## Decision"));
4224        let reparsed = repo.get(2).unwrap();
4225        assert!(reparsed.decision.contains("New decision."));
4226    }
4227
4228    #[test]
4229    fn test_people_field_yaml_unchanged_skip_rewrite() {
4230        let temp = TempDir::new().unwrap();
4231        let repo = Repository::init(temp.path(), None, true).unwrap();
4232
4233        let content = r#"---
4234number: 2
4235title: Zero indent consulted
4236date: 2026-01-15
4237status: proposed
4238consulted:
4239- alice
4240- bob
4241---
4242
4243## Context
4244
4245Context.
4246"#;
4247        let adr_path = repo.adr_path().join("0002-zero-indent-consulted.md");
4248        fs::write(&adr_path, content).unwrap();
4249        let before = fs::read_to_string(&adr_path).unwrap();
4250
4251        let adr = repo.get(2).unwrap();
4252        repo.update_metadata(&adr).unwrap();
4253
4254        let after = fs::read_to_string(&adr_path).unwrap();
4255        assert_eq!(before, after);
4256    }
4257
4258    #[test]
4259    fn test_body_only_update_preserves_non_canonical_status() {
4260        let temp = TempDir::new().unwrap();
4261        let repo = Repository::init(temp.path(), None, false).unwrap();
4262
4263        let content = r#"# 2. Non-canonical status
4264
4265Date: 2026-01-15
4266
4267## Status
4268
4269Approved by the architecture board on 2026-01-15.
4270
4271## Context
4272
4273Original context.
4274
4275## Decision
4276
4277Decision.
4278
4279## Consequences
4280
4281Consequences.
4282"#;
4283        let adr_path = repo.adr_path().join("0002-non-canonical-status.md");
4284        fs::write(&adr_path, content).unwrap();
4285
4286        let mut adr = repo.get(2).unwrap();
4287        adr.context = "Updated context.".into();
4288        repo.update(
4289            &adr,
4290            BodySectionPatch {
4291                context: Some("Updated context.".into()),
4292                ..Default::default()
4293            },
4294        )
4295        .unwrap();
4296
4297        let after = fs::read_to_string(&adr_path).unwrap();
4298        assert!(after.contains("Approved by the architecture board"));
4299        assert!(after.contains("Updated context."));
4300    }
4301
4302    #[test]
4303    fn test_missing_section_patch_returns_error() {
4304        let temp = TempDir::new().unwrap();
4305        let repo = Repository::init(temp.path(), None, false).unwrap();
4306
4307        let content = r#"# 2. No decision or consequences sections
4308
4309Date: 2026-01-15
4310
4311## Status
4312
4313Accepted
4314
4315## Context
4316
4317Context only.
4318"#;
4319        let adr_path = repo.adr_path().join("0002-no-consequences.md");
4320        fs::write(&adr_path, content).unwrap();
4321
4322        let mut adr = repo.get(2).unwrap();
4323        adr.consequences = "New consequences.".into();
4324        let err = repo
4325            .update(
4326                &adr,
4327                BodySectionPatch {
4328                    consequences: Some("New consequences.".into()),
4329                    ..Default::default()
4330                },
4331            )
4332            .unwrap_err();
4333        assert!(err.to_string().contains("consequences patch requested"));
4334    }
4335
4336    #[test]
4337    fn test_nygard_consequences_patch_errors_without_consequences_section() {
4338        let temp = TempDir::new().unwrap();
4339        let repo = Repository::init(temp.path(), None, false).unwrap();
4340
4341        let content = r#"# 3. Nygard decision without consequences section
4342
4343Date: 2026-01-15
4344
4345## Status
4346
4347Accepted
4348
4349## Context
4350
4351Context.
4352
4353## Decision
4354
4355We decided X.
4356"#;
4357        let adr_path = repo.adr_path().join("0003-nygard-no-consequences.md");
4358        fs::write(&adr_path, content).unwrap();
4359
4360        let mut adr = repo.get(3).unwrap();
4361        adr.consequences = "New consequences.".into();
4362        let err = repo
4363            .update(
4364                &adr,
4365                BodySectionPatch {
4366                    consequences: Some("New consequences.".into()),
4367                    ..Default::default()
4368                },
4369            )
4370            .unwrap_err();
4371        assert!(err.to_string().contains("consequences patch requested"));
4372        let after = fs::read_to_string(&adr_path).unwrap();
4373        assert!(!after.contains("### Consequences"));
4374        assert!(!after.contains("New consequences."));
4375    }
4376
4377    #[test]
4378    fn test_decision_patch_preserves_fence_in_context() {
4379        let temp = TempDir::new().unwrap();
4380        let repo = Repository::init(temp.path(), None, false).unwrap();
4381
4382        let content = r#"# 4. Fence in context
4383
4384Date: 2026-01-15
4385
4386## Status
4387
4388Accepted
4389
4390## Context
4391
4392Example:
4393
4394```
4395## Decision
4396not a real heading
4397```
4398
4399## Decision
4400
4401We decided.
4402"#;
4403        let adr_path = repo.adr_path().join("0004-fence-in-context.md");
4404        fs::write(&adr_path, content).unwrap();
4405
4406        repo.update(
4407            &repo.get(4).unwrap(),
4408            BodySectionPatch {
4409                decision: Some("Updated decision.".into()),
4410                ..Default::default()
4411            },
4412        )
4413        .unwrap();
4414
4415        let after = fs::read_to_string(&adr_path).unwrap();
4416        assert!(after.contains("## Decision\nnot a real heading"));
4417        assert!(after.contains("Updated decision."));
4418        assert!(!after.contains("We decided."));
4419    }
4420
4421    // ========== CRLF line endings on the write path (#339) ==========
4422
4423    /// True when every newline in `content` is part of a `\r\n` pair, i.e.
4424    /// stripping all `\r\n` occurrences leaves no bare `\n` behind.
4425    fn is_uniformly_crlf(content: &str) -> bool {
4426        content.contains("\r\n") && !content.replace("\r\n", "").contains('\n')
4427    }
4428
4429    #[test]
4430    fn test_crlf_madr_body_patch_preserves_line_endings() {
4431        let temp = TempDir::new().unwrap();
4432        let repo = Repository::init(temp.path(), None, true).unwrap();
4433
4434        let lf_content = "---\nnumber: 2\ntitle: CRLF MADR\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context and Problem Statement\n\nOld context.\n\n## Decision Outcome\n\nChosen option: \"Redis\", because it is fast.\n\n### Consequences\n\n* Old consequence\n";
4435        let crlf_content = lf_content.replace('\n', "\r\n");
4436        let adr_path = repo.adr_path().join("0002-crlf-madr.md");
4437        fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
4438
4439        let mut adr = repo.get(2).unwrap();
4440        adr.context = "New context.".into();
4441        adr.consequences = "* New good\n* New bad".into();
4442        repo.update(
4443            &adr,
4444            BodySectionPatch::new()
4445                .with_context("New context.")
4446                .with_consequences("* New good\n* New bad"),
4447        )
4448        .unwrap();
4449
4450        let after = String::from_utf8(fs::read(&adr_path).unwrap()).unwrap();
4451
4452        // The whole file -- untouched frontmatter/headings and the two
4453        // patched section bodies alike -- uses \r\n exclusively.
4454        assert!(
4455            is_uniformly_crlf(&after),
4456            "expected uniform CRLF, found a bare \\n\n{after:?}"
4457        );
4458        assert!(after.contains("New context.\r\n"));
4459        // The patch text arrived with `\n`; it must be re-emitted as `\r\n`.
4460        assert!(after.contains("* New good\r\n* New bad\r\n"));
4461        assert!(after.contains("Chosen option: \"Redis\", because it is fast."));
4462
4463        let reparsed = repo.get(2).unwrap();
4464        assert_eq!(reparsed.context, "New context.");
4465        // With #338 fixed, `### Consequences` under `## Decision Outcome`
4466        // routes to the `consequences` field on read; the decision field
4467        // keeps only the Decision Outcome intro.
4468        assert!(reparsed.consequences.contains("New good"));
4469        assert!(!reparsed.decision.contains("New good"));
4470        assert!(!reparsed.context.contains('\r'), "stray \\r in context");
4471        assert!(!reparsed.decision.contains('\r'), "stray \\r in decision");
4472        assert!(
4473            !reparsed.consequences.contains('\r'),
4474            "stray \\r in consequences"
4475        );
4476    }
4477
4478    #[test]
4479    fn test_crlf_nygard_body_patch_preserves_line_endings() {
4480        let temp = TempDir::new().unwrap();
4481        let repo = Repository::init(temp.path(), None, false).unwrap();
4482
4483        let lf_content = "# 2. CRLF Nygard\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nOld context.\n\n## Decision\n\nOld decision.\n\n## Consequences\n\nOld consequences.\n";
4484        let crlf_content = lf_content.replace('\n', "\r\n");
4485        let adr_path = repo.adr_path().join("0002-crlf-nygard.md");
4486        fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
4487
4488        let mut adr = repo.get(2).unwrap();
4489        adr.decision = "New decision.".into();
4490        repo.update(&adr, BodySectionPatch::new().with_decision("New decision."))
4491            .unwrap();
4492
4493        let after = String::from_utf8(fs::read(&adr_path).unwrap()).unwrap();
4494
4495        assert!(
4496            is_uniformly_crlf(&after),
4497            "expected uniform CRLF, found a bare \\n\n{after:?}"
4498        );
4499        assert!(after.contains("New decision.\r\n"));
4500        assert!(after.contains("Old context."));
4501        assert!(after.contains("Old consequences."));
4502
4503        let reparsed = repo.get(2).unwrap();
4504        assert_eq!(reparsed.decision, "New decision.");
4505        assert!(!reparsed.context.contains('\r'), "stray \\r in context");
4506        assert!(!reparsed.decision.contains('\r'), "stray \\r in decision");
4507        assert!(
4508            !reparsed.consequences.contains('\r'),
4509            "stray \\r in consequences"
4510        );
4511    }
4512
4513    #[test]
4514    fn test_update_metadata_on_crlf_frontmatter_file_updates_and_preserves_crlf() {
4515        // Before the #339 fix, `content.starts_with("---\n")` never matched a
4516        // CRLF file's `---\r\n` delimiter, so the update silently fell through
4517        // to the legacy `## Status` splice, found no such heading, and
4518        // returned the file completely untouched. Pin the fixed behavior:
4519        // the status actually changes, and the file stays uniformly CRLF.
4520        let temp = TempDir::new().unwrap();
4521        let repo = Repository::init(temp.path(), None, true).unwrap();
4522
4523        let lf_content = "---\nnumber: 2\ntitle: CRLF metadata\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
4524        let crlf_content = lf_content.replace('\n', "\r\n");
4525        let adr_path = repo.adr_path().join("0002-crlf-metadata.md");
4526        fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
4527
4528        let mut adr = repo.get(2).unwrap();
4529        adr.status = AdrStatus::Accepted;
4530        repo.update_metadata(&adr).unwrap();
4531
4532        let after = String::from_utf8(fs::read(&adr_path).unwrap()).unwrap();
4533        assert!(
4534            after.contains("status: accepted"),
4535            "status must actually update on a CRLF file\n{after}"
4536        );
4537        assert!(
4538            is_uniformly_crlf(&after),
4539            "expected uniform CRLF, found a bare \\n\n{after:?}"
4540        );
4541
4542        let listed = repo.get(2).unwrap();
4543        assert_eq!(listed.status, AdrStatus::Accepted);
4544    }
4545
4546    #[test]
4547    fn test_noop_metadata_update_on_crlf_file_is_byte_identical() {
4548        let temp = TempDir::new().unwrap();
4549        let repo = Repository::init(temp.path(), None, true).unwrap();
4550
4551        let lf_content = "---\nnumber: 2\ntitle: CRLF noop\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
4552        let crlf_content = lf_content.replace('\n', "\r\n");
4553        let adr_path = repo.adr_path().join("0002-crlf-noop.md");
4554        fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
4555
4556        let before = fs::read(&adr_path).unwrap();
4557        let adr = repo.get(2).unwrap();
4558        repo.update_metadata(&adr).unwrap();
4559        let after = fs::read(&adr_path).unwrap();
4560
4561        assert_eq!(
4562            before, after,
4563            "no-op metadata update on a CRLF file must be byte-identical"
4564        );
4565    }
4566
4567    #[test]
4568    fn test_update_metadata_number_field_is_noop_when_unchanged() {
4569        // #356: `update_frontmatter_metadata` now manages `number` too, so
4570        // `Repository::renumber` can rewrite it through the same surgical
4571        // path. Every existing caller fetches its `Adr` via `get`/`list`
4572        // immediately before writing, so `adr.number` always matches the
4573        // on-disk value already -- pin that this stays a true no-op and
4574        // doesn't spuriously mark the file dirty.
4575        let temp = TempDir::new().unwrap();
4576        let repo = Repository::init(temp.path(), None, true).unwrap();
4577
4578        let adr = repo.get(1).unwrap();
4579        let path = adr.path.clone().unwrap();
4580        let before = fs::read(&path).unwrap();
4581
4582        repo.update_metadata(&adr).unwrap();
4583
4584        let after = fs::read(&path).unwrap();
4585        assert_eq!(
4586            before, after,
4587            "update_metadata with an unchanged number must be byte-identical"
4588        );
4589    }
4590
4591    #[test]
4592    fn test_update_legacy_metadata_on_crlf_file_preserves_crlf() {
4593        // #344: `update_legacy_metadata` (no-frontmatter adr-tools files) rebuilt
4594        // the file via `lines()` + `\n` joins, so a metadata write on a CRLF
4595        // legacy file converted every ending to LF. Pin the fix: the status
4596        // updates and the file stays uniformly CRLF.
4597        let temp = TempDir::new().unwrap();
4598        let repo = Repository::init(temp.path(), None, false).unwrap();
4599
4600        let lf_content = "# 2. CRLF legacy\n\nDate: 2026-01-15\n\n## Status\n\nProposed\n\n## Context\n\nContext.\n\n## Decision\n\nDecision.\n\n## Consequences\n\nConsequences.\n";
4601        let crlf_content = lf_content.replace('\n', "\r\n");
4602        let adr_path = repo.adr_path().join("0002-crlf-legacy.md");
4603        fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
4604
4605        let mut adr = repo.get(2).unwrap();
4606        adr.status = AdrStatus::Accepted;
4607        repo.update_metadata(&adr).unwrap();
4608
4609        let after = String::from_utf8(fs::read(&adr_path).unwrap()).unwrap();
4610        assert!(
4611            after.contains("## Status\r\n\r\nAccepted\r\n"),
4612            "status must update on a CRLF legacy file\n{after:?}"
4613        );
4614        assert!(
4615            is_uniformly_crlf(&after),
4616            "expected uniform CRLF, found a bare \\n\n{after:?}"
4617        );
4618
4619        let listed = repo.get(2).unwrap();
4620        assert_eq!(listed.status, AdrStatus::Accepted);
4621    }
4622
4623    #[test]
4624    fn test_noop_legacy_metadata_update_on_crlf_file_is_byte_identical() {
4625        let temp = TempDir::new().unwrap();
4626        let repo = Repository::init(temp.path(), None, false).unwrap();
4627
4628        let lf_content = "# 2. CRLF legacy noop\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nContext.\n\n## Decision\n\nDecision.\n\n## Consequences\n\nConsequences.\n";
4629        let crlf_content = lf_content.replace('\n', "\r\n");
4630        let adr_path = repo.adr_path().join("0002-crlf-legacy-noop.md");
4631        fs::write(&adr_path, crlf_content.as_bytes()).unwrap();
4632
4633        let before = fs::read(&adr_path).unwrap();
4634        let adr = repo.get(2).unwrap();
4635        repo.update_metadata(&adr).unwrap();
4636        let after = fs::read(&adr_path).unwrap();
4637
4638        assert_eq!(
4639            before, after,
4640            "no-op legacy metadata update on a CRLF file must be byte-identical"
4641        );
4642    }
4643
4644    // ========== Blank line after a patched section (#340) ==========
4645
4646    #[test]
4647    fn test_decision_patch_middle_section_exact_blank_line_boundary() {
4648        let temp = TempDir::new().unwrap();
4649        let repo = Repository::init(temp.path(), None, false).unwrap();
4650
4651        let content = "# 2. Blank line boundary\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nOld context.\n\n## Decision\n\nOld decision.\n\n## Consequences\n\nOld consequences.\n";
4652        let adr_path = repo.adr_path().join("0002-blank-line-boundary.md");
4653        fs::write(&adr_path, content).unwrap();
4654
4655        let mut adr = repo.get(2).unwrap();
4656        adr.decision = "New text.".into();
4657        repo.update(&adr, BodySectionPatch::new().with_decision("New text."))
4658            .unwrap();
4659
4660        let after = fs::read_to_string(&adr_path).unwrap();
4661        let expected = "# 2. Blank line boundary\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nOld context.\n\n## Decision\n\nNew text.\n\n## Consequences\n\nOld consequences.\n";
4662        assert_eq!(after, expected);
4663    }
4664
4665    #[test]
4666    fn test_decision_patch_middle_section_exact_blank_line_boundary_crlf() {
4667        let temp = TempDir::new().unwrap();
4668        let repo = Repository::init(temp.path(), None, false).unwrap();
4669
4670        let lf_content = "# 2. Blank line boundary\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nOld context.\n\n## Decision\n\nOld decision.\n\n## Consequences\n\nOld consequences.\n";
4671        let content = lf_content.replace('\n', "\r\n");
4672        let adr_path = repo.adr_path().join("0002-blank-line-boundary-crlf.md");
4673        fs::write(&adr_path, content.as_bytes()).unwrap();
4674
4675        let mut adr = repo.get(2).unwrap();
4676        adr.decision = "New text.".into();
4677        repo.update(&adr, BodySectionPatch::new().with_decision("New text."))
4678            .unwrap();
4679
4680        let after = fs::read_to_string(&adr_path).unwrap();
4681        let expected_lf = "# 2. Blank line boundary\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nOld context.\n\n## Decision\n\nNew text.\n\n## Consequences\n\nOld consequences.\n";
4682        let expected = expected_lf.replace('\n', "\r\n");
4683        assert_eq!(after, expected);
4684    }
4685
4686    #[test]
4687    fn test_consequences_patch_last_section_no_trailing_blank_accumulation() {
4688        let temp = TempDir::new().unwrap();
4689        let repo = Repository::init(temp.path(), None, false).unwrap();
4690
4691        let content = "# 2. Last section\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nOld context.\n\n## Decision\n\nOld decision.\n\n## Consequences\n\nOld consequences.\n";
4692        let adr_path = repo.adr_path().join("0002-last-section.md");
4693        fs::write(&adr_path, content).unwrap();
4694
4695        let mut adr = repo.get(2).unwrap();
4696        adr.consequences = "New consequences.".into();
4697        repo.update(
4698            &adr,
4699            BodySectionPatch::new().with_consequences("New consequences."),
4700        )
4701        .unwrap();
4702
4703        let after = fs::read_to_string(&adr_path).unwrap();
4704        let expected = "# 2. Last section\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nOld context.\n\n## Decision\n\nOld decision.\n\n## Consequences\n\nNew consequences.\n";
4705        assert_eq!(after, expected);
4706        assert!(
4707            !after.ends_with("\n\n"),
4708            "trailing blank line accumulated at EOF"
4709        );
4710    }
4711
4712    #[test]
4713    fn test_repeated_identical_context_patch_is_idempotent() {
4714        let temp = TempDir::new().unwrap();
4715        let repo = Repository::init(temp.path(), None, false).unwrap();
4716
4717        let content = "# 2. Idempotent context\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nOld context.\n\n## Decision\n\nOld decision.\n\n## Consequences\n\nOld consequences.\n";
4718        let adr_path = repo.adr_path().join("0002-idempotent-context.md");
4719        fs::write(&adr_path, content).unwrap();
4720
4721        let adr = repo.get(2).unwrap();
4722        let patch = BodySectionPatch::new().with_context("New context.");
4723
4724        repo.update(&adr, patch.clone()).unwrap();
4725        let after_first = fs::read(&adr_path).unwrap();
4726
4727        repo.update(&adr, patch).unwrap();
4728        let after_second = fs::read(&adr_path).unwrap();
4729
4730        assert_eq!(
4731            after_first, after_second,
4732            "repeating an identical patch must produce identical bytes"
4733        );
4734    }
4735
4736    #[test]
4737    fn test_repeated_identical_madr_consequences_append_is_idempotent() {
4738        // Exercises the trickiest append path: no `### Consequences` subsection
4739        // exists yet, so the first patch synthesizes one; the second patch
4740        // must reproduce exactly the same bytes rather than accumulating an
4741        // extra blank line or duplicate heading.
4742        let temp = TempDir::new().unwrap();
4743        let repo = Repository::init(temp.path(), None, true).unwrap();
4744
4745        let content = "---\nnumber: 2\ntitle: Idempotence\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context and Problem Statement\n\nContext.\n\n## Decision Outcome\n\nOld intro.\n\n## Links\n\nSee also.\n";
4746        let adr_path = repo.adr_path().join("0002-idempotence.md");
4747        fs::write(&adr_path, content).unwrap();
4748
4749        let adr = repo.get(2).unwrap();
4750        let patch = BodySectionPatch::new()
4751            .with_decision("New intro.")
4752            .with_consequences("* New consequence");
4753
4754        repo.update(&adr, patch.clone()).unwrap();
4755        let after_first = fs::read(&adr_path).unwrap();
4756
4757        repo.update(&adr, patch).unwrap();
4758        let after_second = fs::read(&adr_path).unwrap();
4759
4760        assert_eq!(
4761            after_first, after_second,
4762            "repeating an identical patch must produce identical bytes"
4763        );
4764    }
4765
4766    // ========== Renumber Tests (#356) ==========
4767
4768    /// Snapshot every file under `dir` as (path, bytes), sorted, for
4769    /// before/after byte-identity comparisons.
4770    fn snapshot_dir(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
4771        let mut files: Vec<(PathBuf, Vec<u8>)> = WalkDir::new(dir)
4772            .into_iter()
4773            .filter_map(|e| e.ok())
4774            .filter(|e| e.file_type().is_file())
4775            .map(|e| {
4776                let path = e.path().to_path_buf();
4777                let bytes = fs::read(&path).unwrap();
4778                (path, bytes)
4779            })
4780            .collect();
4781        files.sort_by(|a, b| a.0.cmp(&b.0));
4782        files
4783    }
4784
4785    #[test]
4786    fn test_renumber_nextgen_rewrites_filename_frontmatter_h1_and_inbound_link() {
4787        let temp = TempDir::new().unwrap();
4788        let repo = Repository::init(temp.path(), None, true).unwrap();
4789
4790        // `supersede` bakes the link into the new record's body at creation
4791        // time (unlike a later `link()` call, which in nextgen mode only
4792        // touches frontmatter -- see the compatible-mode test below for
4793        // that path), so ADR 3 ends up with both a frontmatter
4794        // `links[].target` *and* a rendered body markdown link to ADR 2.
4795        repo.new_adr("Use MySQL").unwrap(); // ADR 2
4796        repo.supersede("Use PostgreSQL", 2).unwrap(); // ADR 3, supersedes ADR 2
4797
4798        let old_path = repo.adr_path().join("0002-use-mysql.md");
4799        let new_path = repo.adr_path().join("0005-use-mysql.md");
4800
4801        let result = repo.renumber(2, 5, None, false).unwrap();
4802
4803        assert!(!result.no_op);
4804        assert_eq!(
4805            result.renamed_file,
4806            Some((old_path.clone(), new_path.clone()))
4807        );
4808        assert!(result.frontmatter_updated);
4809        assert!(result.h1_updated);
4810
4811        assert!(!old_path.exists());
4812        assert!(new_path.exists());
4813
4814        let content = fs::read_to_string(&new_path).unwrap();
4815        assert!(
4816            content.contains("number: 5"),
4817            "frontmatter number must be rewritten\n{content}"
4818        );
4819        assert!(
4820            content.contains("# 5. Use MySQL"),
4821            "H1 must be rewritten\n{content}"
4822        );
4823
4824        // The other record's frontmatter `links[].target` and rendered body
4825        // link must both point at the new number/filename.
4826        let adr3 = repo.get(3).unwrap();
4827        assert!(adr3.links.iter().any(|l| l.target == 5));
4828        assert!(!adr3.links.iter().any(|l| l.target == 2));
4829
4830        let adr3_path = repo.adr_path().join("0003-use-postgresql.md");
4831        let adr3_content = fs::read_to_string(&adr3_path).unwrap();
4832        assert!(
4833            adr3_content.contains("Supersedes [5. Use MySQL](0005-use-mysql.md)"),
4834            "inbound body link must be rewritten\n{adr3_content}"
4835        );
4836        assert!(!adr3_content.contains("0002-use-mysql.md"));
4837
4838        assert_eq!(result.updated_references, vec![adr3_path]);
4839        assert!(result.prose_warnings.is_empty());
4840    }
4841
4842    #[test]
4843    fn test_renumber_compatible_rewrites_filename_h1_and_inbound_body_link() {
4844        let temp = TempDir::new().unwrap();
4845        let repo = Repository::init(temp.path(), None, false).unwrap();
4846
4847        repo.new_adr("First decision").unwrap(); // ADR 2
4848        repo.new_adr("Second decision").unwrap(); // ADR 3
4849        repo.link(3, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
4850            .unwrap();
4851
4852        let old_path = repo.adr_path().join("0002-first-decision.md");
4853        let new_path = repo.adr_path().join("0005-first-decision.md");
4854
4855        let result = repo.renumber(2, 5, None, false).unwrap();
4856
4857        assert!(!result.no_op);
4858        // Compatible mode has no frontmatter to rewrite.
4859        assert!(!result.frontmatter_updated);
4860        assert!(result.h1_updated);
4861
4862        assert!(!old_path.exists());
4863        assert!(new_path.exists());
4864
4865        let content = fs::read_to_string(&new_path).unwrap();
4866        assert!(
4867            content.starts_with("# 5. First decision"),
4868            "H1 must be rewritten\n{content}"
4869        );
4870
4871        let adr3_path = repo.adr_path().join("0003-second-decision.md");
4872        let adr3_content = fs::read_to_string(&adr3_path).unwrap();
4873        assert!(
4874            adr3_content.contains("[5. First decision](0005-first-decision.md)"),
4875            "inbound body link, including its text, must be rewritten\n{adr3_content}"
4876        );
4877        assert!(!adr3_content.contains("0002-first-decision.md"));
4878
4879        assert_eq!(result.updated_references, vec![adr3_path]);
4880    }
4881
4882    #[test]
4883    fn test_renumber_resolves_duplicate_via_file_and_leaves_other_untouched() {
4884        let temp = TempDir::new().unwrap();
4885        let repo = Repository::init(temp.path(), None, true).unwrap();
4886
4887        let content_a = "---\nnumber: 3\ntitle: Branch A\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 3. Branch A\n\n## Context\n\nA.\n";
4888        let path_a = repo.adr_path().join("0003-branch-a.md");
4889        fs::write(&path_a, content_a).unwrap();
4890
4891        let content_b = "---\nnumber: 3\ntitle: Branch B\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 3. Branch B\n\n## Context\n\nB.\n";
4892        let path_b = repo.adr_path().join("0003-branch-b.md");
4893        fs::write(&path_b, content_b).unwrap();
4894
4895        let before = crate::lint::check_all(&repo).unwrap();
4896        assert!(
4897            before.issues.iter().any(|i| i.rule_id == "ADR012"),
4898            "expected ADR012 (duplicate number) before the fix: {:?}",
4899            before.issues
4900        );
4901
4902        let new_path = repo.adr_path().join("0004-branch-b.md");
4903        let result = repo.renumber(3, 4, Some(&path_b), false).unwrap();
4904
4905        assert_eq!(
4906            result.renamed_file,
4907            Some((path_b.clone(), new_path.clone()))
4908        );
4909        assert!(!path_b.exists());
4910        assert!(new_path.exists());
4911
4912        // Untouched: byte-identical.
4913        let content_a_after = fs::read(&path_a).unwrap();
4914        assert_eq!(content_a_after, content_a.as_bytes());
4915
4916        let after = crate::lint::check_all(&repo).unwrap();
4917        assert!(
4918            !after.issues.iter().any(|i| i.rule_id == "ADR012"),
4919            "doctor should be clean after the fix: {:?}",
4920            after.issues
4921        );
4922    }
4923
4924    #[test]
4925    fn test_renumber_leaves_ambiguous_number_references_alone_and_reports_them() {
4926        let temp = TempDir::new().unwrap();
4927        let repo = Repository::init(temp.path(), None, true).unwrap();
4928
4929        // ADR 1 is superseded by the PostgreSQL record, which is number 2.
4930        // A second record numbered 2 then arrives from another branch. Moving
4931        // that second record to 3 must not repoint ADR 1's link: the link
4932        // means PostgreSQL, PostgreSQL keeps number 2, and rewriting it to 3
4933        // would silently aim a correct relationship at the wrong record.
4934        let adr1 = "---\nnumber: 1\ntitle: Record architecture decisions\ndate: 2026-01-15\nstatus: superseded\nlinks:\n- target: 2\n  kind: supersededby\n---\n\n# 1. Record architecture decisions\n\n## Context\n\nOne.\n";
4935        let path1 = repo
4936            .adr_path()
4937            .join("0001-record-architecture-decisions.md");
4938        fs::write(&path1, adr1).unwrap();
4939
4940        let pg = "---\nnumber: 2\ntitle: Use PostgreSQL\ndate: 2026-01-15\nstatus: accepted\nlinks:\n- target: 1\n  kind: supersedes\n---\n\n# 2. Use PostgreSQL\n\n## Context\n\nPg.\n";
4941        fs::write(repo.adr_path().join("0002-use-postgresql.md"), pg).unwrap();
4942
4943        let redis = "---\nnumber: 2\ntitle: Use Redis\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 2. Use Redis\n\n## Context\n\nFrom another branch.\n";
4944        let redis_path = repo.adr_path().join("0002-use-redis.md");
4945        fs::write(&redis_path, redis).unwrap();
4946
4947        let result = repo.renumber(2, 3, Some(&redis_path), false).unwrap();
4948
4949        // ADR 1 is byte-identical: its link still reads `target: 2`.
4950        assert_eq!(fs::read(&path1).unwrap(), adr1.as_bytes());
4951        assert!(
4952            !result.updated_references.contains(&path1),
4953            "ADR 1 must not be counted as rewritten"
4954        );
4955        assert!(
4956            result.ambiguous_references.contains(&path1),
4957            "ADR 1's ambiguous reference should be reported, got: {:?}",
4958            result.ambiguous_references
4959        );
4960    }
4961
4962    #[test]
4963    fn test_renumber_rewrites_reference_held_by_the_other_duplicate() {
4964        let temp = TempDir::new().unwrap();
4965        let repo = Repository::init(temp.path(), None, true).unwrap();
4966
4967        // Two records share number 3. The one that stays behind holds a body
4968        // link naming the other's file, so renaming that file must rewrite it.
4969        // Skipping every record numbered `from` would leave this dangling.
4970        let content_a = "---\nnumber: 3\ntitle: Branch A\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 3. Branch A\n\n## Context\n\nSee [3. Branch B](0003-branch-b.md).\n";
4971        let path_a = repo.adr_path().join("0003-branch-a.md");
4972        fs::write(&path_a, content_a).unwrap();
4973
4974        let content_b = "---\nnumber: 3\ntitle: Branch B\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 3. Branch B\n\n## Context\n\nB.\n";
4975        let path_b = repo.adr_path().join("0003-branch-b.md");
4976        fs::write(&path_b, content_b).unwrap();
4977
4978        repo.renumber(3, 4, Some(&path_b), false).unwrap();
4979
4980        let after_a = fs::read_to_string(&path_a).unwrap();
4981        assert!(
4982            after_a.contains("[4. Branch B](0004-branch-b.md)"),
4983            "the remaining duplicate's link to the renumbered file should be rewritten, got: {after_a}"
4984        );
4985        assert!(
4986            !after_a.contains("0003-branch-b.md"),
4987            "no reference to the old filename should survive, got: {after_a}"
4988        );
4989    }
4990
4991    #[test]
4992    fn test_renumber_ambiguous_source_without_file_errors() {
4993        let temp = TempDir::new().unwrap();
4994        let repo = Repository::init(temp.path(), None, true).unwrap();
4995
4996        let content_a = "---\nnumber: 3\ntitle: Branch A\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 3. Branch A\n\n## Context\n\nA.\n";
4997        let path_a = repo.adr_path().join("0003-branch-a.md");
4998        fs::write(&path_a, content_a).unwrap();
4999
5000        let content_b = "---\nnumber: 3\ntitle: Branch B\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 3. Branch B\n\n## Context\n\nB.\n";
5001        let path_b = repo.adr_path().join("0003-branch-b.md");
5002        fs::write(&path_b, content_b).unwrap();
5003
5004        let err = repo.renumber(3, 4, None, false).unwrap_err();
5005        let msg = err.to_string();
5006        assert!(
5007            msg.contains("0003-branch-a.md") && msg.contains("0003-branch-b.md"),
5008            "error should name both candidates: {msg}"
5009        );
5010        assert!(
5011            msg.contains("--file"),
5012            "error should direct the user to --file: {msg}"
5013        );
5014
5015        // Nothing written.
5016        assert_eq!(fs::read(&path_a).unwrap(), content_a.as_bytes());
5017        assert_eq!(fs::read(&path_b).unwrap(), content_b.as_bytes());
5018    }
5019
5020    #[test]
5021    fn test_renumber_occupied_target_errors_and_writes_nothing() {
5022        let temp = TempDir::new().unwrap();
5023        let repo = Repository::init(temp.path(), None, true).unwrap();
5024        repo.new_adr("Second decision").unwrap(); // ADR 2
5025
5026        let before = snapshot_dir(&repo.adr_path());
5027
5028        let err = repo.renumber(2, 1, None, false).unwrap_err();
5029        let msg = err.to_string();
5030        assert!(
5031            msg.contains("already used") && msg.contains(crate::init_adr::TITLE),
5032            "error should name the occupying record: {msg}"
5033        );
5034        // Numbers 1 and 2 both exist; the smallest free number is 3.
5035        assert!(
5036            msg.contains("try 3"),
5037            "error should suggest the smallest free number: {msg}"
5038        );
5039
5040        let after = snapshot_dir(&repo.adr_path());
5041        assert_eq!(before, after, "nothing should be written on refusal");
5042    }
5043
5044    #[test]
5045    fn test_renumber_from_equals_to_is_a_reported_noop() {
5046        let temp = TempDir::new().unwrap();
5047        let repo = Repository::init(temp.path(), None, true).unwrap();
5048
5049        let before = snapshot_dir(&repo.adr_path());
5050        let result = repo.renumber(1, 1, None, false).unwrap();
5051        let after = snapshot_dir(&repo.adr_path());
5052
5053        assert!(result.no_op);
5054        assert_eq!(before, after);
5055    }
5056
5057    #[test]
5058    fn test_renumber_dry_run_writes_nothing() {
5059        let temp = TempDir::new().unwrap();
5060        let repo = Repository::init(temp.path(), None, true).unwrap();
5061
5062        repo.new_adr("First decision").unwrap(); // ADR 2
5063        repo.new_adr("Second decision").unwrap(); // ADR 3
5064        repo.link(3, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
5065            .unwrap();
5066
5067        let before = snapshot_dir(&repo.adr_path());
5068
5069        let result = repo.renumber(2, 5, None, true).unwrap();
5070
5071        assert!(!result.no_op);
5072        assert!(result.renamed_file.is_some());
5073        assert!(result.frontmatter_updated);
5074        assert!(result.h1_updated);
5075        assert_eq!(result.updated_references.len(), 1);
5076
5077        let after = snapshot_dir(&repo.adr_path());
5078        assert_eq!(before, after, "dry run must not write anything");
5079    }
5080
5081    #[test]
5082    fn test_renumber_preserves_crlf_and_leaves_unrelated_file_untouched() {
5083        let temp = TempDir::new().unwrap();
5084        let repo = Repository::init(temp.path(), None, true).unwrap();
5085
5086        let lf_content = "---\nnumber: 2\ntitle: CRLF renumber\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 2. CRLF renumber\n\n## Context\n\nContext.\n";
5087        let crlf_content = lf_content.replace('\n', "\r\n");
5088        let path = repo.adr_path().join("0002-crlf-renumber.md");
5089        fs::write(&path, crlf_content.as_bytes()).unwrap();
5090
5091        repo.new_adr("Unrelated").unwrap(); // ADR 3, no reference to ADR 2 at all
5092        let unrelated_path = repo.adr_path().join("0003-unrelated.md");
5093        let unrelated_before = fs::read(&unrelated_path).unwrap();
5094
5095        let result = repo.renumber(2, 5, None, false).unwrap();
5096
5097        let new_path = repo.adr_path().join("0005-crlf-renumber.md");
5098        let after = fs::read_to_string(&new_path).unwrap();
5099        assert!(
5100            is_uniformly_crlf(&after),
5101            "expected uniform CRLF, found a bare \\n\n{after:?}"
5102        );
5103        assert!(after.contains("number: 5"));
5104        assert!(after.contains("# 5. CRLF renumber"));
5105
5106        let unrelated_after = fs::read(&unrelated_path).unwrap();
5107        assert_eq!(
5108            unrelated_before, unrelated_after,
5109            "a record with no reference to `from` must not be rewritten at all"
5110        );
5111        assert!(result.updated_references.is_empty());
5112    }
5113
5114    #[test]
5115    fn test_renumber_preserves_hand_written_body_content() {
5116        // Regression guard against reusing import's render-and-write path
5117        // (#310-class data loss): a section with no template counterpart
5118        // must survive renumbering byte-for-byte, aside from the number/H1.
5119        let temp = TempDir::new().unwrap();
5120        let repo = Repository::init(temp.path(), None, true).unwrap();
5121
5122        let content = "---\nnumber: 2\ntitle: Hand Written\ndate: 2026-01-15\nstatus: proposed\n---\n\n# 2. Hand Written\n\n## Context\n\nSome context with <!-- a comment --> and a\ncustom code block:\n\n```rust\nfn custom() {}\n```\n\n## Decision\n\nHand-authored, not template-derived: * bullet * bullet\n\n## Consequences\n\nConsequences.\n\n## My Custom Section\n\nThis section is not part of any template and must survive verbatim.\n";
5123        let path = repo.adr_path().join("0002-hand-written.md");
5124        fs::write(&path, content).unwrap();
5125
5126        repo.renumber(2, 5, None, false).unwrap();
5127
5128        let new_path = repo.adr_path().join("0005-hand-written.md");
5129        let after = fs::read_to_string(&new_path).unwrap();
5130
5131        let expected = content
5132            .replace("number: 2", "number: 5")
5133            .replace("# 2. Hand Written", "# 5. Hand Written");
5134        assert_eq!(
5135            after, expected,
5136            "only number/H1 may change; everything else must survive verbatim"
5137        );
5138    }
5139
5140    #[test]
5141    fn test_renumber_reports_prose_references_outside_adr_dir_without_rewriting() {
5142        let temp = TempDir::new().unwrap();
5143        let repo = Repository::init(temp.path(), None, true).unwrap();
5144
5145        repo.new_adr("First decision").unwrap(); // ADR 2
5146
5147        let readme_path = temp.path().join("README.md");
5148        let readme_before =
5149            "See [2. First decision](doc/adr/0002-first-decision.md) for details.\n";
5150        fs::write(&readme_path, readme_before).unwrap();
5151
5152        let result = repo.renumber(2, 5, None, false).unwrap();
5153
5154        assert_eq!(result.prose_warnings, vec![readme_path.clone()]);
5155
5156        // Reported, never rewritten.
5157        let readme_after = fs::read_to_string(&readme_path).unwrap();
5158        assert_eq!(readme_after, readme_before);
5159    }
5160}