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 serde_yaml_neo::{Mapping, Value};
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13use walkdir::WalkDir;
14
15/// Selects which ADR body sections to patch on [`Repository::update`].
16///
17/// `None` for a field means leave that section's on-disk bytes untouched.
18/// `Some(text)` replaces the targeted portion (for MADR 4.0.0 `## Decision Outcome`,
19/// only the intro before `###` subsections unless `consequences` is also set).
20///
21/// # Migration from pre-`BodySectionPatch` `update(&adr)`
22///
23/// The previous `Repository::update(&adr)` re-rendered body sections from
24/// `adr.context` / `adr.decision` / `adr.consequences`. An empty
25/// [`BodySectionPatch::default()`] does **not** do that: it only updates
26/// metadata. To change body text, put the new content in the corresponding
27/// patch field; values on `adr` alone are ignored for body content.
28#[derive(Debug, Default, Clone, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct BodySectionPatch {
31    /// Patch the context section (`## Context` / `## Context and Problem Statement`).
32    pub context: Option<String>,
33    /// Patch the decision intro (`## Decision` / `## Decision Outcome` body before H3 subsections).
34    pub decision: Option<String>,
35    /// Patch consequences (`## Consequences`, or MADR `### Consequences` under Decision Outcome).
36    pub consequences: Option<String>,
37}
38
39impl BodySectionPatch {
40    /// Create an empty patch (metadata-only when passed to [`Repository::update`]).
41    pub fn new() -> Self {
42        Self {
43            context: None,
44            decision: None,
45            consequences: None,
46        }
47    }
48
49    /// Returns true when no body sections should be modified.
50    pub fn is_empty(&self) -> bool {
51        self.context.is_none() && self.decision.is_none() && self.consequences.is_none()
52    }
53
54    /// Set the context section patch.
55    pub fn with_context(mut self, text: impl Into<String>) -> Self {
56        self.context = Some(text.into());
57        self
58    }
59
60    /// Set the decision section patch.
61    pub fn with_decision(mut self, text: impl Into<String>) -> Self {
62        self.decision = Some(text.into());
63        self
64    }
65
66    /// Set the consequences section patch.
67    pub fn with_consequences(mut self, text: impl Into<String>) -> Self {
68        self.consequences = Some(text.into());
69        self
70    }
71}
72
73/// A repository of Architecture Decision Records.
74#[derive(Debug)]
75pub struct Repository {
76    /// The root directory of the project.
77    root: PathBuf,
78
79    /// Configuration for this repository.
80    config: Config,
81
82    /// Parser for reading ADRs.
83    parser: Parser,
84
85    /// Template engine for creating ADRs.
86    template_engine: TemplateEngine,
87}
88
89impl Repository {
90    /// Open an existing repository at the given root.
91    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
92        let root = root.into();
93        let config = Config::load(&root)?;
94        let template_engine = Self::engine_from_config(&config);
95
96        Ok(Self {
97            root,
98            config,
99            parser: Parser::new(),
100            template_engine,
101        })
102    }
103
104    /// Open a repository, or create default config if not found.
105    pub fn open_or_default(root: impl Into<PathBuf>) -> Self {
106        let root = root.into();
107        let config = Config::load_or_default(&root);
108        let template_engine = Self::engine_from_config(&config);
109
110        Self {
111            root,
112            config,
113            parser: Parser::new(),
114            template_engine,
115        }
116    }
117
118    /// Initialize a new repository at the given root.
119    pub fn init(root: impl Into<PathBuf>, adr_dir: Option<PathBuf>, ng: bool) -> Result<Self> {
120        let root = root.into();
121        let adr_dir = adr_dir.unwrap_or_else(|| PathBuf::from(crate::config::DEFAULT_ADR_DIR));
122        let adr_path = root.join(&adr_dir);
123
124        // Check if directory exists and count existing ADRs
125        let existing_adrs = if adr_path.exists() {
126            count_existing_adrs(&adr_path)
127        } else {
128            // Create the directory
129            fs::create_dir_all(&adr_path)?;
130            0
131        };
132
133        // Create config
134        let config = Config {
135            adr_dir,
136            mode: if ng {
137                ConfigMode::NextGen
138            } else {
139                ConfigMode::Compatible
140            },
141            ..Default::default()
142        };
143        config.save(&root)?;
144
145        let template_engine = Self::engine_from_config(&config);
146
147        let repo = Self {
148            root,
149            config,
150            parser: Parser::new(),
151            template_engine,
152        };
153
154        // Only create initial ADR if no ADRs exist
155        if existing_adrs == 0 {
156            let mut adr = Adr::new(1, crate::init_adr::TITLE);
157            adr.status = AdrStatus::Accepted;
158            adr.context = crate::init_adr::CONTEXT.into();
159            adr.decision = crate::init_adr::DECISION.into();
160            adr.consequences = crate::init_adr::CONSEQUENCES.into();
161            repo.create(&adr)?;
162        }
163
164        Ok(repo)
165    }
166
167    /// Get the repository root path.
168    pub fn root(&self) -> &Path {
169        &self.root
170    }
171
172    /// Get the configuration.
173    pub fn config(&self) -> &Config {
174        &self.config
175    }
176
177    /// Get the full path to the ADR directory.
178    pub fn adr_path(&self) -> PathBuf {
179        self.config.adr_path(&self.root)
180    }
181
182    /// Build a template engine that respects the config's template format.
183    fn engine_from_config(config: &Config) -> TemplateEngine {
184        let mut engine = TemplateEngine::new();
185        if let Some(ref fmt) = config.templates.format
186            && let Ok(format) = fmt.parse::<TemplateFormat>()
187        {
188            engine = engine.with_format(format);
189        }
190        engine
191    }
192
193    /// Set the template format.
194    pub fn with_template_format(mut self, format: TemplateFormat) -> Self {
195        self.template_engine = self.template_engine.with_format(format);
196        self
197    }
198
199    /// Set the template variant.
200    pub fn with_template_variant(mut self, variant: TemplateVariant) -> Self {
201        self.template_engine = self.template_engine.with_variant(variant);
202        self
203    }
204
205    /// Override the configuration mode.
206    pub fn with_mode(mut self, mode: ConfigMode) -> Self {
207        self.config.mode = mode;
208        self
209    }
210
211    /// Set a custom template.
212    pub fn with_custom_template(mut self, template: Template) -> Self {
213        self.template_engine = self.template_engine.with_custom_template(template);
214        self
215    }
216
217    /// List all ADRs in the repository.
218    pub fn list(&self) -> Result<Vec<Adr>> {
219        let adr_path = self.adr_path();
220        if !adr_path.exists() {
221            return Err(Error::AdrDirNotFound);
222        }
223
224        let mut adrs: Vec<Adr> = WalkDir::new(&adr_path)
225            .max_depth(1)
226            .into_iter()
227            .filter_map(|e| e.ok())
228            .filter(|e| {
229                e.path().extension().is_some_and(|ext| ext == "md")
230                    && e.path()
231                        .file_name()
232                        .and_then(|n| n.to_str())
233                        .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
234            })
235            .filter_map(|e| self.parser.parse_file(e.path()).ok())
236            .collect();
237
238        adrs.sort_by_key(|a| a.number);
239        Ok(adrs)
240    }
241
242    /// List all ADRs, also returning parse errors for files that look like ADRs
243    /// but failed to parse.
244    ///
245    /// This is used by the `doctor` command to report files that could not be
246    /// parsed (e.g., invalid frontmatter).
247    #[allow(clippy::type_complexity)]
248    pub fn list_with_errors(&self) -> Result<(Vec<Adr>, Vec<(PathBuf, crate::Error)>)> {
249        let adr_path = self.adr_path();
250        if !adr_path.exists() {
251            return Err(Error::AdrDirNotFound);
252        }
253
254        let mut adrs = Vec::new();
255        let mut errors = Vec::new();
256
257        let candidates: Vec<_> = WalkDir::new(&adr_path)
258            .max_depth(1)
259            .into_iter()
260            .filter_map(|e| e.ok())
261            .filter(|e| {
262                e.path().extension().is_some_and(|ext| ext == "md")
263                    && e.path()
264                        .file_name()
265                        .and_then(|n| n.to_str())
266                        .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
267            })
268            .collect();
269
270        for entry in candidates {
271            match self.parser.parse_file(entry.path()) {
272                Ok(adr) => adrs.push(adr),
273                Err(e) => errors.push((entry.path().to_path_buf(), e)),
274            }
275        }
276
277        adrs.sort_by_key(|a| a.number);
278        Ok((adrs, errors))
279    }
280
281    /// Get the next available ADR number.
282    pub fn next_number(&self) -> Result<u32> {
283        let adrs = self.list()?;
284        Ok(adrs.last().map(|a| a.number + 1).unwrap_or(1))
285    }
286
287    /// Find an ADR by number.
288    pub fn get(&self, number: u32) -> Result<Adr> {
289        let adrs = self.list()?;
290        adrs.into_iter()
291            .find(|a| a.number == number)
292            .ok_or_else(|| Error::AdrNotFound(number.to_string()))
293    }
294
295    /// Find an ADR by query (number or fuzzy title match).
296    pub fn find(&self, query: &str) -> Result<Adr> {
297        // Try parsing as number first
298        if let Ok(number) = query.parse::<u32>() {
299            return self.get(number);
300        }
301
302        // Fuzzy match on title
303        let adrs = self.list()?;
304        let matcher = SkimMatcherV2::default();
305
306        let mut matches: Vec<_> = adrs
307            .into_iter()
308            .filter_map(|adr| {
309                let score = matcher.fuzzy_match(&adr.title, query)?;
310                Some((adr, score))
311            })
312            .collect();
313
314        matches.sort_by_key(|m| std::cmp::Reverse(m.1));
315
316        match matches.len() {
317            0 => Err(Error::AdrNotFound(query.to_string())),
318            1 => Ok(matches.remove(0).0),
319            _ => {
320                // If top match is significantly better, use it
321                if matches[0].1 > matches[1].1 * 2 {
322                    Ok(matches.remove(0).0)
323                } else {
324                    Err(Error::AmbiguousAdr {
325                        query: query.to_string(),
326                        matches: matches
327                            .iter()
328                            .take(5)
329                            .map(|(a, _)| a.title.clone())
330                            .collect(),
331                    })
332                }
333            }
334        }
335    }
336
337    /// Resolve link target titles and filenames for an ADR's links.
338    fn resolve_link_titles(&self, adr: &Adr) -> HashMap<u32, (String, String)> {
339        let mut map = HashMap::new();
340        for link in &adr.links {
341            if map.contains_key(&link.target) {
342                continue;
343            }
344            if let Ok(target_adr) = self.get(link.target) {
345                map.insert(
346                    link.target,
347                    (target_adr.title.clone(), Self::link_href(&target_adr)),
348                );
349            }
350        }
351        map
352    }
353
354    /// The href to use when rendering a link to `target_adr`.
355    ///
356    /// Prefers the target's actual on-disk filename over a filename
357    /// re-derived from its title. The two can diverge (hand-named files,
358    /// files renamed after a title edit), and re-deriving produces hrefs
359    /// that point at files that don't exist (#325). Falls back to the
360    /// title-derived filename only when the target has no resolvable path.
361    fn link_href(target_adr: &Adr) -> String {
362        target_adr
363            .path
364            .as_ref()
365            .and_then(|p| p.file_name())
366            .and_then(|f| f.to_str())
367            .map(str::to_string)
368            .unwrap_or_else(|| target_adr.filename())
369    }
370
371    /// Create a new ADR.
372    pub fn create(&self, adr: &Adr) -> Result<PathBuf> {
373        let path = self.adr_path().join(adr.filename());
374
375        let link_titles = self.resolve_link_titles(adr);
376        let content = self
377            .template_engine
378            .render(adr, &self.config, &link_titles)?;
379        fs::write(&path, content)?;
380
381        Ok(path)
382    }
383
384    /// Create a new ADR with the given title.
385    pub fn new_adr(&self, title: impl Into<String>) -> Result<(Adr, PathBuf)> {
386        let number = self.next_number()?;
387        let mut adr = Adr::new(number, title);
388        if let Some(default_status) = self.config.default_status.as_deref() {
389            adr.status = default_status.parse::<AdrStatus>().unwrap();
390        }
391        let path = self.create(&adr)?;
392        Ok((adr, path))
393    }
394
395    /// Create a new ADR that supersedes another.
396    pub fn supersede(&self, title: impl Into<String>, superseded: u32) -> Result<(Adr, PathBuf)> {
397        let number = self.next_number()?;
398        let mut adr = Adr::new(number, title);
399        adr.add_link(AdrLink::new(superseded, LinkKind::Supersedes));
400
401        // Create the new ADR first so its file exists on disk when
402        // the old ADR's "Superseded by" link is resolved.
403        let path = self.create(&adr)?;
404
405        // Now update the superseded ADR — the new ADR is on disk so
406        // its title and filename can be resolved for the link.
407        let mut old_adr = self.get(superseded)?;
408        old_adr.status = AdrStatus::Superseded;
409        old_adr.add_link(AdrLink::new(number, LinkKind::SupersededBy));
410        self.update_metadata(&old_adr)?;
411
412        Ok((adr, path))
413    }
414
415    /// Change the status of an ADR.
416    ///
417    /// If the new status is `Superseded` and `superseded_by` is provided,
418    /// a superseded-by link will be added automatically.
419    pub fn set_status(
420        &self,
421        number: u32,
422        status: AdrStatus,
423        superseded_by: Option<u32>,
424    ) -> Result<PathBuf> {
425        // Reject empty or whitespace-only custom statuses. Serializing one
426        // yields a YAML null that fails to deserialize, silently dropping the
427        // ADR from `list()` (see issue #305).
428        if let AdrStatus::Custom(s) = &status
429            && s.trim().is_empty()
430        {
431            return Err(Error::InvalidStatus(
432                "status cannot be empty or whitespace-only".to_string(),
433            ));
434        }
435
436        let mut adr = self.get(number)?;
437        adr.status = status.clone();
438
439        // If superseded by another ADR, add the link
440        if let (AdrStatus::Superseded, Some(by)) = (&status, superseded_by) {
441            // Check that the superseding ADR exists
442            let _ = self.get(by)?;
443
444            // Add superseded-by link if not already present
445            if !adr
446                .links
447                .iter()
448                .any(|l| matches!(l.kind, LinkKind::SupersededBy) && l.target == by)
449            {
450                adr.add_link(AdrLink::new(by, LinkKind::SupersededBy));
451            }
452        }
453
454        self.update_metadata(&adr)
455    }
456
457    /// Link two ADRs together.
458    pub fn link(
459        &self,
460        source: u32,
461        target: u32,
462        source_kind: LinkKind,
463        target_kind: LinkKind,
464    ) -> Result<()> {
465        let mut source_adr = self.get(source)?;
466        let mut target_adr = self.get(target)?;
467
468        source_adr.add_link(AdrLink::new(target, source_kind));
469        target_adr.add_link(AdrLink::new(source, target_kind));
470
471        self.update_metadata(&source_adr)?;
472        self.update_metadata(&target_adr)?;
473
474        Ok(())
475    }
476
477    /// Update an existing ADR.
478    ///
479    /// When `body` is non-empty, only the listed body sections are patched in place;
480    /// metadata bytes on disk are left unchanged. When `body` is empty, metadata
481    /// (status, links, tags, and MADR 4.0.0 frontmatter fields) is updated via the
482    /// same path as [`Self::update_metadata`].
483    ///
484    /// Empty `body` does **not** re-render context/decision/consequences from `adr`
485    /// (unlike the pre-`BodySectionPatch` API). Mutating those fields on `adr` and
486    /// calling `update(&adr, BodySectionPatch::default())` writes metadata only;
487    /// body text on disk is unchanged. Pass the new text in `body` to patch sections.
488    ///
489    /// `adr.title` and `adr.date` are not written by this method.
490    ///
491    /// Unlisted body sections are left byte-for-byte unchanged on disk, including MADR
492    /// `### Consequences` / `### Confirmation` subsections under `## Decision Outcome`.
493    pub fn update(&self, adr: &Adr, body: BodySectionPatch) -> Result<PathBuf> {
494        let path = adr
495            .path
496            .clone()
497            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
498
499        let content = fs::read_to_string(&path)?;
500
501        let content = if body.is_empty() {
502            if content.starts_with("---\n") {
503                self.update_frontmatter_metadata(adr, &content)?
504            } else {
505                self.update_legacy_metadata(adr, &content)?
506            }
507        } else {
508            content
509        };
510
511        let updated = if body.is_empty() {
512            content
513        } else {
514            self.update_body_sections(&content, &body)?
515        };
516        fs::write(&path, updated)?;
517
518        Ok(path)
519    }
520
521    /// Read the content of an ADR file.
522    pub fn read_content(&self, adr: &Adr) -> Result<String> {
523        let path = adr
524            .path
525            .as_ref()
526            .cloned()
527            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
528
529        Ok(fs::read_to_string(path)?)
530    }
531
532    /// Write content to an ADR file.
533    pub fn write_content(&self, adr: &Adr, content: &str) -> Result<PathBuf> {
534        let path = adr
535            .path
536            .as_ref()
537            .cloned()
538            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
539
540        fs::write(&path, content)?;
541        Ok(path)
542    }
543
544    /// Update only the metadata (status, links, tags) of an existing ADR file,
545    /// preserving all other content byte-for-byte.
546    pub fn update_metadata(&self, adr: &Adr) -> Result<PathBuf> {
547        let path = adr
548            .path
549            .clone()
550            .unwrap_or_else(|| self.adr_path().join(adr.filename()));
551
552        let content = fs::read_to_string(&path)?;
553
554        let updated = if content.starts_with("---\n") {
555            self.update_frontmatter_metadata(adr, &content)?
556        } else {
557            self.update_legacy_metadata(adr, &content)?
558        };
559
560        fs::write(&path, updated)?;
561        Ok(path)
562    }
563
564    /// Update managed metadata fields in a YAML frontmatter file.
565    ///
566    /// Parses the frontmatter into a YAML [`Mapping`], mutates managed keys
567    /// (`status`, `links`, `tags`, and MADR people fields), and re-emits the
568    /// mapping. Unknown keys are preserved; the markdown body is untouched.
569    /// When no managed field changes, the original file bytes are returned.
570    ///
571    /// Re-emitting via a standard YAML parser may drop YAML comments (e.g. SPDX
572    /// headers) when any managed field changes — see ADR 0006.
573    fn update_frontmatter_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
574        // Split into frontmatter and body at the closing `---`
575        let Some(rest) = content.strip_prefix("---\n") else {
576            return Err(Error::InvalidFormat {
577                path: Default::default(),
578                reason: "Missing opening frontmatter delimiter".into(),
579            });
580        };
581
582        let Some(end_idx) = rest.find("\n---\n").or_else(|| {
583            // Handle case where closing delimiter is at end of file with no trailing newline
584            if rest.ends_with("\n---") {
585                Some(rest.len() - 3)
586            } else {
587                None
588            }
589        }) else {
590            return Err(Error::InvalidFormat {
591                path: Default::default(),
592                reason: "Missing closing frontmatter delimiter".into(),
593            });
594        };
595
596        let yaml_block = &rest[..end_idx + 1]; // include trailing \n
597        let after_yaml = &rest[end_idx..]; // starts with \n---\n...
598
599        let parsed: Value = serde_yaml_neo::from_str(yaml_block)?;
600        let Value::Mapping(mut map) = parsed else {
601            return Err(Error::InvalidFormat {
602                path: Default::default(),
603                reason: "Frontmatter YAML must be a mapping".into(),
604            });
605        };
606
607        let mut dirty = false;
608
609        // 1. status
610        let status_val = Value::String(adr.status.to_string().to_lowercase());
611        if map.get(Self::yaml_str_key("status")) != Some(&status_val) {
612            map.insert(Self::yaml_str_key("status"), status_val);
613            dirty = true;
614        }
615
616        // 2. links
617        if Self::set_yaml_sequence_field(&mut map, "links", &adr.links)? {
618            dirty = true;
619        }
620
621        // 3. tags (string-or-list on disk)
622        if !Self::yaml_string_list_matches(&map, "tags", &adr.tags)
623            && Self::set_yaml_string_list_field(&mut map, "tags", &adr.tags)?
624        {
625            dirty = true;
626        }
627
628        // 4. MADR people fields (string-or-list on disk; leave Value untouched when
629        // semantically equal so block scalars survive no-op metadata writes).
630        if !Self::yaml_string_list_matches(&map, "decision-makers", &adr.decision_makers)
631            && Self::set_yaml_string_list_field(&mut map, "decision-makers", &adr.decision_makers)?
632        {
633            dirty = true;
634        }
635        if !Self::yaml_string_list_matches(&map, "consulted", &adr.consulted)
636            && Self::set_yaml_string_list_field(&mut map, "consulted", &adr.consulted)?
637        {
638            dirty = true;
639        }
640        if !Self::yaml_string_list_matches(&map, "informed", &adr.informed)
641            && Self::set_yaml_string_list_field(&mut map, "informed", &adr.informed)?
642        {
643            dirty = true;
644        }
645
646        if !dirty {
647            return Ok(content.to_string());
648        }
649
650        let new_yaml = serde_yaml_neo::to_string(&Value::Mapping(map))?;
651        let new_yaml = new_yaml.trim_end_matches('\n');
652        Ok(format!("---\n{new_yaml}{after_yaml}"))
653    }
654
655    /// Surgically update metadata in a legacy (no-frontmatter) ADR file.
656    ///
657    /// Replaces the content between `## Status` and the next `## ` heading
658    /// with the new status and link lines. All other sections pass through untouched.
659    fn update_legacy_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
660        let lines: Vec<&str> = content.lines().collect();
661        let mut result = String::with_capacity(content.len());
662
663        // Find the ## Status section
664        let status_idx = lines.iter().position(|l| {
665            l.trim().eq_ignore_ascii_case("## Status") || l.trim().eq_ignore_ascii_case("## STATUS")
666        });
667
668        let Some(status_idx) = status_idx else {
669            // No status section found -- just return content unchanged
670            return Ok(content.to_string());
671        };
672
673        // Find the next ## heading after status
674        let next_heading_idx = lines[status_idx + 1..]
675            .iter()
676            .position(|l| l.starts_with("## "))
677            .map(|i| i + status_idx + 1);
678
679        // Write everything before the status section (including the ## Status line)
680        for line in &lines[..=status_idx] {
681            result.push_str(line);
682            result.push('\n');
683        }
684
685        // Write new status content
686        result.push('\n');
687        result.push_str(&adr.status.to_string());
688        result.push('\n');
689
690        // Write link lines with resolved titles
691        let link_titles = self.resolve_link_titles(adr);
692        for link in &adr.links {
693            result.push('\n');
694            if let Some((title, filename)) = link_titles.get(&link.target) {
695                result.push_str(&format!(
696                    "{} [{}. {}]({})",
697                    link.kind, link.target, title, filename
698                ));
699            } else {
700                result.push_str(&format!(
701                    "{} [{}. ...]({:04}-....md)",
702                    link.kind, link.target, link.target
703                ));
704            }
705            result.push('\n');
706        }
707
708        // Write everything from the next heading onward
709        if let Some(next_idx) = next_heading_idx {
710            result.push('\n');
711            for (i, line) in lines[next_idx..].iter().enumerate() {
712                result.push_str(line);
713                // Preserve trailing newline behavior
714                if next_idx + i < lines.len() - 1 || content.ends_with('\n') {
715                    result.push('\n');
716                }
717            }
718        } else if content.ends_with('\n') {
719            // No next heading, but original ended with newline
720        }
721
722        Ok(result)
723    }
724
725    /// Patch only the body sections listed in `patch`, preserving everything else.
726    fn update_body_sections(&self, content: &str, patch: &BodySectionPatch) -> Result<String> {
727        let lines: Vec<&str> = content.lines().collect();
728        let mut result = String::with_capacity(content.len());
729        let mut i = 0;
730        let mut found_context = patch.context.is_none();
731        let mut found_decision = patch.decision.is_none();
732        let mut found_consequences = patch.consequences.is_none();
733
734        while i < lines.len() {
735            let line = lines[i];
736            if Self::is_h2_outside_fence(&lines, i)
737                && let Some(heading_text) = line.strip_prefix("## ")
738                && let Some(field) = crate::parse::canonical_section_field(heading_text.trim())
739            {
740                result.push_str(line);
741                result.push('\n');
742                i += 1;
743                let body_end = Self::next_h2_index(&lines, i);
744
745                match field {
746                    "context" => {
747                        if patch.context.is_some() {
748                            found_context = true;
749                        }
750                        if let Some(ref text) = patch.context {
751                            Self::write_section_body(&mut result, text);
752                        } else {
753                            Self::append_lines(&mut result, &lines, i, body_end, content);
754                        }
755                    }
756                    "decision" => {
757                        let madr_decision_outcome =
758                            heading_text.trim().eq_ignore_ascii_case("Decision Outcome");
759                        let (decision_found, consequences_applied) = Self::patch_decision_section(
760                            &mut result,
761                            &lines,
762                            content,
763                            i,
764                            body_end,
765                            patch,
766                            madr_decision_outcome,
767                        );
768                        if decision_found {
769                            found_decision = true;
770                        }
771                        if consequences_applied {
772                            found_consequences = true;
773                        }
774                    }
775                    "consequences" => {
776                        if patch.consequences.is_some() {
777                            found_consequences = true;
778                        }
779                        if let Some(ref text) = patch.consequences {
780                            Self::write_section_body(&mut result, text);
781                        } else {
782                            Self::append_lines(&mut result, &lines, i, body_end, content);
783                        }
784                    }
785                    _ => {
786                        Self::append_lines(&mut result, &lines, i, body_end, content);
787                    }
788                }
789
790                i = body_end;
791                continue;
792            }
793
794            result.push_str(line);
795            if i < lines.len() - 1 || content.ends_with('\n') {
796                result.push('\n');
797            }
798            i += 1;
799        }
800
801        if !found_context {
802            return Err(Error::InvalidFormat {
803                path: PathBuf::new(),
804                reason: "context patch requested but no matching section heading found".into(),
805            });
806        }
807        if !found_decision {
808            return Err(Error::InvalidFormat {
809                path: PathBuf::new(),
810                reason: "decision patch requested but no matching section heading found".into(),
811            });
812        }
813        if !found_consequences {
814            return Err(Error::InvalidFormat {
815                path: PathBuf::new(),
816                reason: "consequences patch requested but no matching section heading found".into(),
817            });
818        }
819
820        if content.ends_with('\n') && !result.ends_with('\n') {
821            result.push('\n');
822        }
823
824        Ok(result)
825    }
826
827    /// CommonMark fence opener/closer: 0–3 leading spaces, then a run of ≥3
828    /// `` ` `` or `~`. Lines indented ≥4 spaces are indented code, not fences.
829    fn fence_run(line: &str) -> Option<(char, usize)> {
830        let indent = line.chars().take_while(|c| *c == ' ').count();
831        if indent >= 4 {
832            return None;
833        }
834        let rest = &line[indent..];
835        let ch = rest.chars().next()?;
836        if ch != '`' && ch != '~' {
837            return None;
838        }
839        let run = rest.chars().take_while(|c| *c == ch).count();
840        if run < 3 {
841            return None;
842        }
843        Some((ch, run))
844    }
845
846    /// Whether `line` can close an open fence of `(ch, open_len)` (same character,
847    /// run length ≥ opener, only whitespace after the run).
848    fn is_fence_close(line: &str, ch: char, open_len: usize) -> bool {
849        let Some((close_ch, run)) = Self::fence_run(line) else {
850            return false;
851        };
852        if close_ch != ch || run < open_len {
853            return false;
854        }
855        let indent = line.chars().take_while(|c| *c == ' ').count();
856        let after_run = &line[indent + run..];
857        after_run.chars().all(|c| c == ' ' || c == '\t')
858    }
859
860    fn in_fence_at_line(lines: &[&str], index: usize) -> bool {
861        let mut open: Option<(char, usize)> = None;
862        for line in &lines[..index] {
863            match open {
864                None => {
865                    if let Some((ch, run)) = Self::fence_run(line) {
866                        open = Some((ch, run));
867                    }
868                }
869                Some((ch, open_len)) => {
870                    if Self::is_fence_close(line, ch, open_len) {
871                        open = None;
872                    }
873                }
874            }
875        }
876        open.is_some()
877    }
878
879    fn is_h2_outside_fence(lines: &[&str], index: usize) -> bool {
880        lines[index].starts_with("## ") && !Self::in_fence_at_line(lines, index)
881    }
882
883    fn is_h3_outside_fence(lines: &[&str], index: usize) -> bool {
884        lines[index].starts_with("### ") && !Self::in_fence_at_line(lines, index)
885    }
886
887    fn next_h2_index(lines: &[&str], start: usize) -> usize {
888        lines[start..]
889            .iter()
890            .enumerate()
891            .find(|(offset, _)| Self::is_h2_outside_fence(lines, start + offset))
892            .map(|(offset, _)| start + offset)
893            .unwrap_or(lines.len())
894    }
895
896    fn append_lines(result: &mut String, lines: &[&str], start: usize, end: usize, content: &str) {
897        for (offset, line) in lines[start..end].iter().enumerate() {
898            result.push_str(line);
899            if start + offset < end - 1 || end < lines.len() || content.ends_with('\n') {
900                result.push('\n');
901            }
902        }
903    }
904
905    fn write_section_body(result: &mut String, text: &str) {
906        result.push('\n');
907        result.push_str(text);
908        if !text.ends_with('\n') {
909            result.push('\n');
910        }
911    }
912
913    fn is_consequences_h3(line: &str) -> bool {
914        line.strip_prefix("### ")
915            .is_some_and(|title| title.trim().eq_ignore_ascii_case("consequences"))
916    }
917
918    fn is_consequences_h2(line: &str) -> bool {
919        line.strip_prefix("## ")
920            .is_some_and(|title| title.trim().eq_ignore_ascii_case("consequences"))
921    }
922
923    /// Patch `## Decision` / `## Decision Outcome`, preserving MADR H3 subsections
924    /// unless `patch.decision` or `patch.consequences` targets them.
925    ///
926    /// Returns `(found_decision, consequences_applied)`.
927    fn patch_decision_section(
928        result: &mut String,
929        lines: &[&str],
930        content: &str,
931        body_start: usize,
932        body_end: usize,
933        patch: &BodySectionPatch,
934        madr_decision_outcome: bool,
935    ) -> (bool, bool) {
936        if patch.decision.is_none() && patch.consequences.is_none() {
937            Self::append_lines(result, lines, body_start, body_end, content);
938            return (false, false);
939        }
940
941        let mut consequences_applied = false;
942
943        // Top-level `## Consequences` H2 (anywhere outside fences) is patched
944        // separately. Leave Decision bytes untouched when only consequences is
945        // set and such an H2 exists — including before Decision Outcome.
946        if patch.decision.is_none()
947            && patch.consequences.is_some()
948            && Self::has_consequences_h2(lines)
949        {
950            Self::append_lines(result, lines, body_start, body_end, content);
951            return (true, false);
952        }
953
954        let body = &lines[body_start..body_end];
955        let first_h3 = body
956            .iter()
957            .enumerate()
958            .find(|(offset, _)| Self::is_h3_outside_fence(lines, body_start + offset))
959            .map(|(offset, _)| body_start + offset);
960        let intro_end = first_h3.unwrap_or(body_end);
961
962        if let Some(ref text) = patch.decision {
963            Self::write_section_body(result, text);
964        } else {
965            Self::append_lines(result, lines, body_start, intro_end, content);
966        }
967
968        if intro_end >= body_end {
969            if let Some(ref text) = patch.consequences
970                && !Self::has_consequences_h2(lines)
971                && madr_decision_outcome
972            {
973                Self::write_madr_consequences_subsection(result, text);
974                consequences_applied = true;
975            }
976            return (true, consequences_applied);
977        }
978
979        let mut j = intro_end;
980
981        while j < body_end {
982            if !Self::is_h3_outside_fence(lines, j) {
983                j += 1;
984                continue;
985            }
986
987            let sub_start = j;
988            let sub_end = lines[sub_start + 1..body_end]
989                .iter()
990                .enumerate()
991                .find(|(offset, _)| {
992                    let idx = sub_start + 1 + offset;
993                    Self::is_h2_outside_fence(lines, idx) || Self::is_h3_outside_fence(lines, idx)
994                })
995                .map(|(offset, _)| sub_start + 1 + offset)
996                .unwrap_or(body_end);
997
998            if Self::is_consequences_h3(lines[sub_start])
999                && let Some(ref text) = patch.consequences
1000            {
1001                consequences_applied = true;
1002                result.push_str(lines[sub_start]);
1003                result.push('\n');
1004                Self::write_section_body(result, text);
1005                j = sub_end;
1006                continue;
1007            }
1008
1009            Self::append_lines(result, lines, sub_start, sub_end, content);
1010            j = sub_end;
1011        }
1012
1013        if let Some(ref text) = patch.consequences
1014            && !consequences_applied
1015            && !Self::has_consequences_h2(lines)
1016            && madr_decision_outcome
1017        {
1018            Self::write_madr_consequences_subsection(result, text);
1019            consequences_applied = true;
1020        }
1021
1022        (true, consequences_applied)
1023    }
1024
1025    /// True when a fence-aware `## Consequences` H2 exists anywhere in the file.
1026    fn has_consequences_h2(lines: &[&str]) -> bool {
1027        lines.iter().enumerate().any(|(idx, line)| {
1028            Self::is_h2_outside_fence(lines, idx) && Self::is_consequences_h2(line)
1029        })
1030    }
1031
1032    fn write_madr_consequences_subsection(result: &mut String, text: &str) {
1033        // append_lines may omit a final newline when the source file has none
1034        // and the append ends at EOF; re-establish a separator before the H3.
1035        if !result.is_empty() && !result.ends_with('\n') {
1036            result.push('\n');
1037        }
1038        result.push_str("### Consequences\n");
1039        Self::write_section_body(result, text);
1040    }
1041
1042    fn yaml_str_key(key: &str) -> Value {
1043        Value::String(key.to_string())
1044    }
1045
1046    /// Set or remove a sequence-valued frontmatter field. Returns true if `map` changed.
1047    fn set_yaml_sequence_field<T: serde::Serialize>(
1048        map: &mut Mapping,
1049        key: &str,
1050        values: &[T],
1051    ) -> Result<bool> {
1052        let key = Self::yaml_str_key(key);
1053        if values.is_empty() {
1054            return Ok(map.remove(&key).is_some());
1055        }
1056        let desired = serde_yaml_neo::to_value(values)?;
1057        if map.get(&key) == Some(&desired) {
1058            return Ok(false);
1059        }
1060        map.insert(key, desired);
1061        Ok(true)
1062    }
1063
1064    /// Set or remove a string-list frontmatter field. Returns true if `map` changed.
1065    fn set_yaml_string_list_field(map: &mut Mapping, key: &str, values: &[String]) -> Result<bool> {
1066        Self::set_yaml_sequence_field(map, key, values)
1067    }
1068
1069    /// Whether a frontmatter string-or-list field already matches `desired`
1070    /// (same rules as `Adr`'s `string_or_vec` deserializer).
1071    fn yaml_string_list_matches(map: &Mapping, key: &str, desired: &[String]) -> bool {
1072        match map.get(Self::yaml_str_key(key)) {
1073            None | Some(Value::Null) => desired.is_empty(),
1074            Some(Value::String(s)) => desired.len() == 1 && desired[0] == *s,
1075            Some(Value::Sequence(seq)) => {
1076                let got: Option<Vec<&str>> = seq.iter().map(|v| v.as_str()).collect();
1077                match got {
1078                    Some(got) => got == desired.iter().map(String::as_str).collect::<Vec<_>>(),
1079                    None => false,
1080                }
1081            }
1082            Some(_) => false,
1083        }
1084    }
1085}
1086
1087/// Count existing ADR files in a directory.
1088fn count_existing_adrs(path: &Path) -> usize {
1089    if !path.is_dir() {
1090        return 0;
1091    }
1092
1093    fs::read_dir(path)
1094        .map(|entries| {
1095            entries
1096                .filter_map(|e| e.ok())
1097                .filter(|e| {
1098                    let path = e.path();
1099                    path.is_file()
1100                        && path.extension().is_some_and(|ext| ext == "md")
1101                        && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
1102                            // Match NNNN-*.md pattern (adr-tools style)
1103                            n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
1104                        })
1105                })
1106                .count()
1107        })
1108        .unwrap_or(0)
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114    use tempfile::TempDir;
1115
1116    // ========== Initialization Tests ==========
1117
1118    #[test]
1119    fn test_init_repository() {
1120        let temp = TempDir::new().unwrap();
1121        let repo = Repository::init(temp.path(), None, false).unwrap();
1122
1123        assert!(repo.adr_path().exists());
1124        assert!(temp.path().join(".adr-dir").exists());
1125
1126        let adrs = repo.list().unwrap();
1127        assert_eq!(adrs.len(), 1);
1128        assert_eq!(adrs[0].number, 1);
1129        assert_eq!(adrs[0].title, "Record architecture decisions");
1130    }
1131
1132    #[test]
1133    fn test_init_repository_ng() {
1134        let temp = TempDir::new().unwrap();
1135        let repo = Repository::init(temp.path(), None, true).unwrap();
1136
1137        assert!(temp.path().join("adrs.toml").exists());
1138        assert!(repo.config().is_next_gen());
1139    }
1140
1141    #[test]
1142    fn test_init_repository_custom_dir() {
1143        let temp = TempDir::new().unwrap();
1144        let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
1145
1146        assert!(temp.path().join("decisions").exists());
1147        assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
1148    }
1149
1150    #[test]
1151    fn test_init_repository_nested_dir() {
1152        let temp = TempDir::new().unwrap();
1153        let _repo =
1154            Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
1155
1156        assert!(temp.path().join("docs/architecture/adr").exists());
1157    }
1158
1159    #[test]
1160    fn test_init_repository_already_exists_skips_initial_adr() {
1161        let temp = TempDir::new().unwrap();
1162        Repository::init(temp.path(), None, false).unwrap();
1163
1164        // Re-init should succeed but not create another ADR
1165        let repo = Repository::init(temp.path(), None, false).unwrap();
1166        let adrs = repo.list().unwrap();
1167        assert_eq!(adrs.len(), 1); // Still just the original initial ADR
1168    }
1169
1170    #[test]
1171    fn test_init_with_existing_adrs_skips_initial() {
1172        let temp = TempDir::new().unwrap();
1173        let adr_dir = temp.path().join("doc/adr");
1174        fs::create_dir_all(&adr_dir).unwrap();
1175
1176        // Create some existing ADR files
1177        fs::write(
1178            adr_dir.join("0001-existing-decision.md"),
1179            "# 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",
1180        )
1181        .unwrap();
1182        fs::write(
1183            adr_dir.join("0002-another-decision.md"),
1184            "# 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",
1185        )
1186        .unwrap();
1187
1188        // Init should succeed and NOT create initial ADR
1189        let repo = Repository::init(temp.path(), None, false).unwrap();
1190        let adrs = repo.list().unwrap();
1191        assert_eq!(adrs.len(), 2); // Only the existing ADRs, no "Record architecture decisions"
1192        assert_eq!(adrs[0].title, "Existing Decision");
1193        assert_eq!(adrs[1].title, "Another Decision");
1194    }
1195
1196    #[test]
1197    fn test_init_creates_first_adr() {
1198        let temp = TempDir::new().unwrap();
1199        let repo = Repository::init(temp.path(), None, false).unwrap();
1200
1201        let adr = repo.get(1).unwrap();
1202        assert_eq!(adr.title, crate::init_adr::TITLE);
1203        assert_eq!(adr.status, AdrStatus::Accepted);
1204        assert_eq!(adr.context, crate::init_adr::CONTEXT);
1205        assert_eq!(adr.decision, crate::init_adr::DECISION);
1206        assert_eq!(adr.consequences, crate::init_adr::CONSEQUENCES);
1207    }
1208
1209    #[test]
1210    fn test_init_first_adr_has_markdown_links_in_both_modes() {
1211        for ng in [false, true] {
1212            let temp = TempDir::new().unwrap();
1213            let repo = Repository::init(temp.path(), None, ng).unwrap();
1214            let path = repo
1215                .adr_path()
1216                .join("0001-record-architecture-decisions.md");
1217            let content = fs::read_to_string(path).unwrap();
1218
1219            assert!(
1220                content.contains(
1221                    "[Documenting Architecture Decisions](https://www.cognitect.com/blog/2011/11/15/documenting-architecture-decisions)"
1222                ),
1223                "expected Nygard article link (ng={ng})"
1224            );
1225            assert!(
1226                content.contains("[adrs](https://github.com/joshrotenberg/adrs)"),
1227                "expected adrs link (ng={ng})"
1228            );
1229            assert!(
1230                content.contains("[adr-tools](https://github.com/npryce/adr-tools)"),
1231                "expected adr-tools link (ng={ng})"
1232            );
1233            assert!(
1234                content.ends_with('\n'),
1235                "init ADR should end with a newline (ng={ng})"
1236            );
1237        }
1238    }
1239
1240    // ========== Open Tests ==========
1241
1242    #[test]
1243    fn test_open_repository() {
1244        let temp = TempDir::new().unwrap();
1245        Repository::init(temp.path(), None, false).unwrap();
1246
1247        let repo = Repository::open(temp.path()).unwrap();
1248        assert_eq!(repo.list().unwrap().len(), 1);
1249    }
1250
1251    #[test]
1252    fn test_open_repository_not_found() {
1253        let temp = TempDir::new().unwrap();
1254        let result = Repository::open(temp.path());
1255        assert!(result.is_err());
1256    }
1257
1258    #[test]
1259    fn test_open_or_default() {
1260        let temp = TempDir::new().unwrap();
1261        let repo = Repository::open_or_default(temp.path());
1262        assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
1263    }
1264
1265    #[test]
1266    fn test_open_or_default_existing() {
1267        let temp = TempDir::new().unwrap();
1268        Repository::init(temp.path(), Some("custom".into()), false).unwrap();
1269
1270        let repo = Repository::open_or_default(temp.path());
1271        assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
1272    }
1273
1274    // ========== Create and List Tests ==========
1275
1276    #[test]
1277    fn test_create_and_list() {
1278        let temp = TempDir::new().unwrap();
1279        let repo = Repository::init(temp.path(), None, false).unwrap();
1280
1281        let (adr, _) = repo.new_adr("Use Rust").unwrap();
1282        assert_eq!(adr.number, 2);
1283
1284        let adrs = repo.list().unwrap();
1285        assert_eq!(adrs.len(), 2);
1286    }
1287
1288    #[test]
1289    fn test_create_multiple() {
1290        let temp = TempDir::new().unwrap();
1291        let repo = Repository::init(temp.path(), None, false).unwrap();
1292
1293        repo.new_adr("Second").unwrap();
1294        repo.new_adr("Third").unwrap();
1295        repo.new_adr("Fourth").unwrap();
1296
1297        let adrs = repo.list().unwrap();
1298        assert_eq!(adrs.len(), 4);
1299        assert_eq!(adrs[0].number, 1);
1300        assert_eq!(adrs[1].number, 2);
1301        assert_eq!(adrs[2].number, 3);
1302        assert_eq!(adrs[3].number, 4);
1303    }
1304
1305    #[test]
1306    fn test_list_sorted_by_number() {
1307        let temp = TempDir::new().unwrap();
1308        let repo = Repository::init(temp.path(), None, false).unwrap();
1309
1310        repo.new_adr("B").unwrap();
1311        repo.new_adr("A").unwrap();
1312        repo.new_adr("C").unwrap();
1313
1314        let adrs = repo.list().unwrap();
1315        assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
1316    }
1317
1318    #[test]
1319    fn test_next_number() {
1320        let temp = TempDir::new().unwrap();
1321        let repo = Repository::init(temp.path(), None, false).unwrap();
1322
1323        assert_eq!(repo.next_number().unwrap(), 2);
1324
1325        repo.new_adr("Second").unwrap();
1326        assert_eq!(repo.next_number().unwrap(), 3);
1327    }
1328
1329    #[test]
1330    fn test_create_file_exists() {
1331        let temp = TempDir::new().unwrap();
1332        let repo = Repository::init(temp.path(), None, false).unwrap();
1333
1334        let (_, path) = repo.new_adr("Test ADR").unwrap();
1335        assert!(path.exists());
1336        assert!(path.to_string_lossy().contains("0002-test-adr.md"));
1337    }
1338
1339    #[test]
1340    fn test_new_adr_uses_custom_default_status_from_config() {
1341        let temp = TempDir::new().unwrap();
1342        Repository::init(temp.path(), None, false).unwrap();
1343
1344        std::fs::write(
1345            temp.path().join("adrs.toml"),
1346            r#"
1347adr_dir = "doc/adr"
1348mode = "compatible"
1349default_status = "draft"
1350"#,
1351        )
1352        .unwrap();
1353
1354        let repo = Repository::open(temp.path()).unwrap();
1355        let (adr, _) = repo.new_adr("Custom status ADR").unwrap();
1356
1357        assert_eq!(adr.status, AdrStatus::Custom("draft".into()));
1358    }
1359
1360    // ========== Get and Find Tests ==========
1361
1362    #[test]
1363    fn test_get_by_number() {
1364        let temp = TempDir::new().unwrap();
1365        let repo = Repository::init(temp.path(), None, false).unwrap();
1366        repo.new_adr("Second").unwrap();
1367
1368        let adr = repo.get(2).unwrap();
1369        assert_eq!(adr.title, "Second");
1370    }
1371
1372    #[test]
1373    fn test_get_not_found() {
1374        let temp = TempDir::new().unwrap();
1375        let repo = Repository::init(temp.path(), None, false).unwrap();
1376
1377        let result = repo.get(99);
1378        assert!(result.is_err());
1379    }
1380
1381    #[test]
1382    fn test_find_by_number() {
1383        let temp = TempDir::new().unwrap();
1384        let repo = Repository::init(temp.path(), None, false).unwrap();
1385
1386        let adr = repo.find("1").unwrap();
1387        assert_eq!(adr.number, 1);
1388    }
1389
1390    #[test]
1391    fn test_find_by_title() {
1392        let temp = TempDir::new().unwrap();
1393        let repo = Repository::init(temp.path(), None, false).unwrap();
1394
1395        let adr = repo.find("architecture").unwrap();
1396        assert_eq!(adr.number, 1);
1397    }
1398
1399    #[test]
1400    fn test_find_fuzzy_match() {
1401        let temp = TempDir::new().unwrap();
1402        let repo = Repository::init(temp.path(), None, false).unwrap();
1403        repo.new_adr("Use PostgreSQL for database").unwrap();
1404        repo.new_adr("Use Redis for caching").unwrap();
1405
1406        let adr = repo.find("postgres").unwrap();
1407        assert!(adr.title.contains("PostgreSQL"));
1408    }
1409
1410    #[test]
1411    fn test_find_not_found() {
1412        let temp = TempDir::new().unwrap();
1413        let repo = Repository::init(temp.path(), None, false).unwrap();
1414
1415        let result = repo.find("nonexistent");
1416        assert!(result.is_err());
1417    }
1418
1419    // ========== Supersede Tests ==========
1420
1421    #[test]
1422    fn test_supersede() {
1423        let temp = TempDir::new().unwrap();
1424        let repo = Repository::init(temp.path(), None, false).unwrap();
1425
1426        let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
1427        assert_eq!(new_adr.number, 2);
1428        assert_eq!(new_adr.links.len(), 1);
1429        assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
1430
1431        let old_adr = repo.get(1).unwrap();
1432        assert_eq!(old_adr.status, AdrStatus::Superseded);
1433    }
1434
1435    #[test]
1436    fn test_supersede_creates_bidirectional_links() {
1437        let temp = TempDir::new().unwrap();
1438        let repo = Repository::init(temp.path(), None, false).unwrap();
1439
1440        repo.supersede("New approach", 1).unwrap();
1441
1442        let old_adr = repo.get(1).unwrap();
1443        assert_eq!(old_adr.links.len(), 1);
1444        assert_eq!(old_adr.links[0].target, 2);
1445        assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
1446
1447        let new_adr = repo.get(2).unwrap();
1448        assert_eq!(new_adr.links.len(), 1);
1449        assert_eq!(new_adr.links[0].target, 1);
1450        assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
1451    }
1452
1453    #[test]
1454    fn test_supersede_not_found() {
1455        let temp = TempDir::new().unwrap();
1456        let repo = Repository::init(temp.path(), None, false).unwrap();
1457
1458        let result = repo.supersede("New", 99);
1459        assert!(result.is_err());
1460    }
1461
1462    // ========== Link Resolution Tests (Issue #180) ==========
1463
1464    #[test]
1465    fn test_supersede_generates_functional_links() {
1466        let temp = TempDir::new().unwrap();
1467        let repo = Repository::init(temp.path(), None, false).unwrap();
1468
1469        // Create ADR 2, then supersede it with ADR 3
1470        repo.new_adr("Use MySQL for persistence").unwrap();
1471        repo.supersede("Use PostgreSQL instead", 2).unwrap();
1472
1473        // Check the new ADR (3) has a functional "Supersedes" link to ADR 2
1474        let new_content =
1475            fs::read_to_string(repo.adr_path().join("0003-use-postgresql-instead.md")).unwrap();
1476        assert!(
1477            new_content.contains(
1478                "Supersedes [2. Use MySQL for persistence](0002-use-mysql-for-persistence.md)"
1479            ),
1480            "New ADR should have functional Supersedes link. Got:\n{new_content}"
1481        );
1482
1483        // Check the old ADR (2) has a functional "Superseded by" link to ADR 3
1484        let old_content =
1485            fs::read_to_string(repo.adr_path().join("0002-use-mysql-for-persistence.md")).unwrap();
1486        assert!(
1487            old_content.contains(
1488                "Superseded by [3. Use PostgreSQL instead](0003-use-postgresql-instead.md)"
1489            ),
1490            "Old ADR should have functional Superseded by link. Got:\n{old_content}"
1491        );
1492    }
1493
1494    #[test]
1495    fn test_link_generates_functional_links() {
1496        let temp = TempDir::new().unwrap();
1497        let repo = Repository::init(temp.path(), None, false).unwrap();
1498
1499        repo.new_adr("Use REST API").unwrap();
1500        repo.new_adr("Use JSON for API responses").unwrap();
1501
1502        repo.link(3, 2, LinkKind::Amends, LinkKind::AmendedBy)
1503            .unwrap();
1504
1505        // Check source ADR has functional link
1506        let source_content =
1507            fs::read_to_string(repo.adr_path().join("0003-use-json-for-api-responses.md")).unwrap();
1508        assert!(
1509            source_content.contains("Amends [2. Use REST API](0002-use-rest-api.md)"),
1510            "Source ADR should have functional Amends link. Got:\n{source_content}"
1511        );
1512
1513        // Check target ADR has functional reverse link
1514        let target_content =
1515            fs::read_to_string(repo.adr_path().join("0002-use-rest-api.md")).unwrap();
1516        assert!(
1517            target_content.contains(
1518                "Amended by [3. Use JSON for API responses](0003-use-json-for-api-responses.md)"
1519            ),
1520            "Target ADR should have functional Amended by link. Got:\n{target_content}"
1521        );
1522    }
1523
1524    #[test]
1525    fn test_set_status_superseded_generates_functional_link() {
1526        let temp = TempDir::new().unwrap();
1527        let repo = Repository::init(temp.path(), None, false).unwrap();
1528
1529        repo.new_adr("First Decision").unwrap();
1530        repo.new_adr("Second Decision").unwrap();
1531
1532        repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
1533
1534        let content = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
1535        assert!(
1536            content.contains("Superseded by [3. Second Decision](0003-second-decision.md)"),
1537            "ADR should have functional Superseded by link. Got:\n{content}"
1538        );
1539    }
1540
1541    #[test]
1542    fn test_supersede_chain_generates_functional_links() {
1543        let temp = TempDir::new().unwrap();
1544        let repo = Repository::init(temp.path(), None, false).unwrap();
1545
1546        // ADR 1 is "Record architecture decisions" (from init)
1547        // Create ADR 2
1548        repo.new_adr("Use SQLite").unwrap();
1549        // ADR 3 supersedes ADR 2
1550        repo.supersede("Use PostgreSQL", 2).unwrap();
1551        // ADR 4 supersedes ADR 3
1552        repo.supersede("Use CockroachDB", 3).unwrap();
1553
1554        // Check ADR 3 has both directions
1555        let adr3_content =
1556            fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1557        assert!(
1558            adr3_content.contains("Supersedes [2. Use SQLite](0002-use-sqlite.md)"),
1559            "ADR 3 should supersede ADR 2. Got:\n{adr3_content}"
1560        );
1561        assert!(
1562            adr3_content.contains("Superseded by [4. Use CockroachDB](0004-use-cockroachdb.md)"),
1563            "ADR 3 should be superseded by ADR 4. Got:\n{adr3_content}"
1564        );
1565    }
1566
1567    #[test]
1568    fn test_ng_mode_supersede_generates_functional_links() {
1569        let temp = TempDir::new().unwrap();
1570        let repo = Repository::init(temp.path(), None, true).unwrap();
1571
1572        repo.new_adr("Use MySQL").unwrap();
1573        repo.supersede("Use PostgreSQL", 2).unwrap();
1574
1575        // Check the new ADR has functional links in both frontmatter and body
1576        let new_content =
1577            fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1578
1579        // Body should have functional markdown link
1580        assert!(
1581            new_content.contains("Supersedes [2. Use MySQL](0002-use-mysql.md)"),
1582            "NG mode should have functional link in body. Got:\n{new_content}"
1583        );
1584        // Frontmatter should have structured link
1585        assert!(new_content.contains("links:"));
1586        assert!(new_content.contains("target: 2"));
1587    }
1588
1589    // ========== Set Status Tests ==========
1590
1591    #[test]
1592    fn test_set_status_accepted() {
1593        let temp = TempDir::new().unwrap();
1594        let repo = Repository::init(temp.path(), None, false).unwrap();
1595        repo.new_adr("Test Decision").unwrap();
1596
1597        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1598
1599        let adr = repo.get(2).unwrap();
1600        assert_eq!(adr.status, AdrStatus::Accepted);
1601    }
1602
1603    #[test]
1604    fn test_set_status_deprecated() {
1605        let temp = TempDir::new().unwrap();
1606        let repo = Repository::init(temp.path(), None, false).unwrap();
1607        repo.new_adr("Old Decision").unwrap();
1608
1609        repo.set_status(2, AdrStatus::Deprecated, None).unwrap();
1610
1611        let adr = repo.get(2).unwrap();
1612        assert_eq!(adr.status, AdrStatus::Deprecated);
1613    }
1614
1615    #[test]
1616    fn test_set_status_superseded_with_link() {
1617        let temp = TempDir::new().unwrap();
1618        let repo = Repository::init(temp.path(), None, false).unwrap();
1619        repo.new_adr("First Decision").unwrap();
1620        repo.new_adr("Second Decision").unwrap();
1621
1622        repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
1623
1624        let adr = repo.get(2).unwrap();
1625        assert_eq!(adr.status, AdrStatus::Superseded);
1626        assert_eq!(adr.links.len(), 1);
1627        assert_eq!(adr.links[0].target, 3);
1628        assert_eq!(adr.links[0].kind, LinkKind::SupersededBy);
1629    }
1630
1631    #[test]
1632    fn test_set_status_superseded_without_link() {
1633        let temp = TempDir::new().unwrap();
1634        let repo = Repository::init(temp.path(), None, false).unwrap();
1635        repo.new_adr("Decision").unwrap();
1636
1637        repo.set_status(2, AdrStatus::Superseded, None).unwrap();
1638
1639        let adr = repo.get(2).unwrap();
1640        assert_eq!(adr.status, AdrStatus::Superseded);
1641        assert_eq!(adr.links.len(), 0);
1642    }
1643
1644    #[test]
1645    fn test_set_status_custom() {
1646        let temp = TempDir::new().unwrap();
1647        let repo = Repository::init(temp.path(), None, false).unwrap();
1648        repo.new_adr("Test Decision").unwrap();
1649
1650        repo.set_status(2, AdrStatus::Custom("Draft".into()), None)
1651            .unwrap();
1652
1653        let adr = repo.get(2).unwrap();
1654        assert_eq!(adr.status, AdrStatus::Custom("Draft".into()));
1655    }
1656
1657    #[test]
1658    fn test_set_status_adr_not_found() {
1659        let temp = TempDir::new().unwrap();
1660        let repo = Repository::init(temp.path(), None, false).unwrap();
1661
1662        let result = repo.set_status(99, AdrStatus::Accepted, None);
1663        assert!(result.is_err());
1664    }
1665
1666    #[test]
1667    fn test_set_status_superseded_by_not_found() {
1668        let temp = TempDir::new().unwrap();
1669        let repo = Repository::init(temp.path(), None, false).unwrap();
1670        repo.new_adr("Decision").unwrap();
1671
1672        let result = repo.set_status(2, AdrStatus::Superseded, Some(99));
1673        assert!(result.is_err());
1674    }
1675
1676    // ========== Link Tests ==========
1677
1678    #[test]
1679    fn test_link_adrs() {
1680        let temp = TempDir::new().unwrap();
1681        let repo = Repository::init(temp.path(), None, false).unwrap();
1682        repo.new_adr("Second").unwrap();
1683
1684        repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
1685            .unwrap();
1686
1687        let adr1 = repo.get(1).unwrap();
1688        assert_eq!(adr1.links.len(), 1);
1689        assert_eq!(adr1.links[0].target, 2);
1690        assert_eq!(adr1.links[0].kind, LinkKind::Amends);
1691
1692        let adr2 = repo.get(2).unwrap();
1693        assert_eq!(adr2.links.len(), 1);
1694        assert_eq!(adr2.links[0].target, 1);
1695        assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
1696    }
1697
1698    #[test]
1699    fn test_link_relates_to() {
1700        let temp = TempDir::new().unwrap();
1701        let repo = Repository::init(temp.path(), None, false).unwrap();
1702        repo.new_adr("Second").unwrap();
1703
1704        repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
1705            .unwrap();
1706
1707        let adr1 = repo.get(1).unwrap();
1708        assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
1709
1710        let adr2 = repo.get(2).unwrap();
1711        assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
1712    }
1713
1714    // ========== Update Tests ==========
1715
1716    #[test]
1717    fn test_update_adr() {
1718        let temp = TempDir::new().unwrap();
1719        let repo = Repository::init(temp.path(), None, false).unwrap();
1720
1721        let mut adr = repo.get(1).unwrap();
1722        adr.status = AdrStatus::Deprecated;
1723
1724        repo.update(&adr, BodySectionPatch::default()).unwrap();
1725
1726        let updated = repo.get(1).unwrap();
1727        assert_eq!(updated.status, AdrStatus::Deprecated);
1728    }
1729
1730    #[test]
1731    fn test_update_preserves_content() {
1732        let temp = TempDir::new().unwrap();
1733        let repo = Repository::init(temp.path(), None, false).unwrap();
1734
1735        let mut adr = repo.get(1).unwrap();
1736        let original_title = adr.title.clone();
1737        adr.status = AdrStatus::Deprecated;
1738
1739        repo.update(&adr, BodySectionPatch::default()).unwrap();
1740
1741        let updated = repo.get(1).unwrap();
1742        assert_eq!(updated.title, original_title);
1743    }
1744
1745    // ========== Read/Write Content Tests ==========
1746
1747    #[test]
1748    fn test_read_content() {
1749        let temp = TempDir::new().unwrap();
1750        let repo = Repository::init(temp.path(), None, false).unwrap();
1751
1752        let adr = repo.get(1).unwrap();
1753        let content = repo.read_content(&adr).unwrap();
1754
1755        assert!(content.contains("Record architecture decisions"));
1756        assert!(content.contains("## Status"));
1757    }
1758
1759    #[test]
1760    fn test_write_content() {
1761        let temp = TempDir::new().unwrap();
1762        let repo = Repository::init(temp.path(), None, false).unwrap();
1763
1764        let adr = repo.get(1).unwrap();
1765        let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
1766
1767        repo.write_content(&adr, new_content).unwrap();
1768
1769        let content = repo.read_content(&adr).unwrap();
1770        assert!(content.contains("Modified"));
1771    }
1772
1773    // ========== Mode Override Tests ==========
1774
1775    #[test]
1776    fn test_with_mode_overrides_compatible_to_ng() {
1777        let temp = TempDir::new().unwrap();
1778        // Init in compatible mode
1779        let repo = Repository::init(temp.path(), None, false)
1780            .unwrap()
1781            .with_mode(ConfigMode::NextGen);
1782
1783        let (_, path) = repo.new_adr("Mode Override Test").unwrap();
1784        let content = fs::read_to_string(path).unwrap();
1785
1786        assert!(
1787            content.starts_with("---\n"),
1788            "with_mode(NextGen) on compatible repo should produce YAML frontmatter. Got:\n{content}"
1789        );
1790        assert!(content.contains("status: proposed"));
1791    }
1792
1793    #[test]
1794    fn test_with_mode_ng_to_compatible() {
1795        let temp = TempDir::new().unwrap();
1796        // Init in ng mode, then override to compatible
1797        let repo = Repository::init(temp.path(), None, true)
1798            .unwrap()
1799            .with_mode(ConfigMode::Compatible);
1800
1801        let (_, path) = repo.new_adr("Downgrade Mode Test").unwrap();
1802        let content = fs::read_to_string(path).unwrap();
1803
1804        assert!(
1805            !content.starts_with("---\n"),
1806            "with_mode(Compatible) on ng repo should NOT produce YAML frontmatter. Got:\n{content}"
1807        );
1808    }
1809
1810    // ========== Template Configuration Tests ==========
1811
1812    #[test]
1813    fn test_with_template_format() {
1814        let temp = TempDir::new().unwrap();
1815        let repo = Repository::init(temp.path(), None, false)
1816            .unwrap()
1817            .with_template_format(TemplateFormat::Madr);
1818
1819        let (_, path) = repo.new_adr("MADR Test").unwrap();
1820        let content = fs::read_to_string(path).unwrap();
1821
1822        assert!(content.contains("Context and Problem Statement"));
1823    }
1824
1825    #[test]
1826    fn test_with_custom_template() {
1827        let temp = TempDir::new().unwrap();
1828        let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
1829        let repo = Repository::init(temp.path(), None, false)
1830            .unwrap()
1831            .with_custom_template(custom);
1832
1833        let (_, path) = repo.new_adr("Custom Test").unwrap();
1834        let content = fs::read_to_string(path).unwrap();
1835
1836        assert_eq!(content, "# ADR 2: Custom Test\n");
1837    }
1838
1839    // ========== Accessor Tests ==========
1840
1841    #[test]
1842    fn test_root() {
1843        let temp = TempDir::new().unwrap();
1844        let repo = Repository::init(temp.path(), None, false).unwrap();
1845
1846        assert_eq!(repo.root(), temp.path());
1847    }
1848
1849    #[test]
1850    fn test_config() {
1851        let temp = TempDir::new().unwrap();
1852        let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
1853
1854        assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
1855        assert!(repo.config().is_next_gen());
1856    }
1857
1858    #[test]
1859    fn test_adr_path() {
1860        let temp = TempDir::new().unwrap();
1861        let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
1862
1863        assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
1864    }
1865
1866    // ========== NextGen Mode Tests ==========
1867
1868    #[test]
1869    fn test_ng_mode_creates_frontmatter() {
1870        let temp = TempDir::new().unwrap();
1871        let repo = Repository::init(temp.path(), None, true).unwrap();
1872
1873        let (_, path) = repo.new_adr("NG Test").unwrap();
1874        let content = fs::read_to_string(path).unwrap();
1875
1876        assert!(content.starts_with("---"));
1877        assert!(content.contains("number: 2"));
1878        assert!(content.contains("title: NG Test"));
1879    }
1880
1881    #[test]
1882    fn test_ng_mode_parses_frontmatter() {
1883        let temp = TempDir::new().unwrap();
1884        let repo = Repository::init(temp.path(), None, true).unwrap();
1885
1886        repo.new_adr("NG ADR").unwrap();
1887
1888        let adr = repo.get(2).unwrap();
1889        assert_eq!(adr.title, "NG ADR");
1890        assert_eq!(adr.number, 2);
1891    }
1892
1893    // ========== Edge Cases ==========
1894
1895    #[test]
1896    fn test_list_empty_after_init_removal() {
1897        let temp = TempDir::new().unwrap();
1898        let repo = Repository::init(temp.path(), None, false).unwrap();
1899
1900        // Remove the initial ADR
1901        fs::remove_file(
1902            repo.adr_path()
1903                .join("0001-record-architecture-decisions.md"),
1904        )
1905        .unwrap();
1906
1907        let adrs = repo.list().unwrap();
1908        assert!(adrs.is_empty());
1909    }
1910
1911    #[test]
1912    fn test_list_ignores_non_adr_files() {
1913        let temp = TempDir::new().unwrap();
1914        let repo = Repository::init(temp.path(), None, false).unwrap();
1915
1916        // Create non-ADR files
1917        fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
1918        fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
1919
1920        let adrs = repo.list().unwrap();
1921        assert_eq!(adrs.len(), 1); // Only the initial ADR
1922    }
1923
1924    #[test]
1925    fn test_special_characters_in_title() {
1926        let temp = TempDir::new().unwrap();
1927        let repo = Repository::init(temp.path(), None, false).unwrap();
1928
1929        let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
1930        assert!(path.exists());
1931        assert_eq!(adr.title, "Use C++ & Rust!");
1932    }
1933
1934    // ========== Metadata Preservation Tests (issue #187) ==========
1935
1936    #[test]
1937    fn test_set_status_preserves_madr_body() {
1938        let temp = TempDir::new().unwrap();
1939        let repo = Repository::init(temp.path(), None, true).unwrap();
1940
1941        let madr_content = r#"---
1942number: 2
1943title: Use Redis for caching
1944date: 2026-01-15
1945status: proposed
1946---
1947
1948# Use Redis for caching
1949
1950## Context and Problem Statement
1951
1952We need a **fast** caching layer for our [API](https://api.example.com).
1953
1954## Considered Options
1955
1956* Redis
1957* Memcached
1958* In-memory cache
1959
1960## Decision Outcome
1961
1962Chosen option: "Redis", because it supports data structures beyond simple key-value.
1963
1964### Consequences
1965
1966* Good, because it provides pub/sub
1967* Bad, because it adds operational complexity
1968
1969## Pros and Cons of the Options
1970
1971### Redis
1972
1973* Good, because it supports complex data types
1974* Bad, because it requires a separate server
1975
1976### Memcached
1977
1978* Good, because it's simpler
1979* Bad, because it only supports strings
1980"#;
1981        let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
1982        fs::write(&adr_path, madr_content).unwrap();
1983
1984        // Change status
1985        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1986
1987        let result = fs::read_to_string(&adr_path).unwrap();
1988
1989        // Status should be updated
1990        assert!(result.contains("status: accepted"));
1991        assert!(!result.contains("status: proposed"));
1992
1993        // Body should be completely preserved
1994        let body_start = result.find("\n# Use Redis").unwrap();
1995        let original_body_start = madr_content.find("\n# Use Redis").unwrap();
1996        assert_eq!(
1997            &result[body_start..],
1998            &madr_content[original_body_start..],
1999            "Body content was modified"
2000        );
2001    }
2002
2003    #[test]
2004    fn test_set_status_via_mapping_preserves_unknown_keys_and_body() {
2005        // Frontmatter is re-emitted via a YAML Mapping (ADR 0006) and does not
2006        // round-trip comments. Unknown keys and the markdown body must survive.
2007        let temp = TempDir::new().unwrap();
2008        let repo = Repository::init(temp.path(), None, true).unwrap();
2009
2010        let content_with_comments = r#"---
2011# SPDX-License-Identifier: MIT
2012number: 2
2013title: Use MADR format
2014date: 2026-01-15
2015status: proposed
2016custom-meta: keep-me
2017---
2018
2019## Context and Problem Statement
2020
2021We need a standard ADR format.
2022
2023## Decision Outcome
2024
2025Use MADR 4.0.0.
2026"#;
2027        let adr_path = repo.adr_path().join("0002-use-madr-format.md");
2028        fs::write(&adr_path, content_with_comments).unwrap();
2029
2030        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2031
2032        let result = fs::read_to_string(&adr_path).unwrap();
2033
2034        assert!(result.contains("status: accepted"));
2035        assert!(
2036            result.contains("custom-meta: keep-me"),
2037            "unknown frontmatter key was dropped\n{result}"
2038        );
2039        assert!(
2040            result.contains("## Decision Outcome") && result.contains("Use MADR 4.0.0."),
2041            "markdown body must survive\n{result}"
2042        );
2043    }
2044
2045    #[test]
2046    fn test_set_status_preserves_markdown_links() {
2047        let temp = TempDir::new().unwrap();
2048        let repo = Repository::init(temp.path(), None, true).unwrap();
2049
2050        let content = r#"---
2051number: 2
2052title: Use PostgreSQL
2053date: 2026-01-15
2054status: proposed
2055---
2056
2057## Context
2058
2059See the [PostgreSQL docs](https://www.postgresql.org/docs/) for details.
2060
2061Also see [RFC 7159](https://tools.ietf.org/html/rfc7159) and `inline code`.
2062
2063## Decision
2064
2065We will use **PostgreSQL** version `16.x`.
2066
2067## Consequences
2068
2069- [Monitoring guide](https://example.com/monitoring)
2070- Performance benchmarks in [this report](./benchmarks.md)
2071"#;
2072        let adr_path = repo.adr_path().join("0002-use-postgresql.md");
2073        fs::write(&adr_path, content).unwrap();
2074
2075        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2076
2077        let result = fs::read_to_string(&adr_path).unwrap();
2078
2079        assert!(result.contains("[PostgreSQL docs](https://www.postgresql.org/docs/)"));
2080        assert!(result.contains("[RFC 7159](https://tools.ietf.org/html/rfc7159)"));
2081        assert!(result.contains("`inline code`"));
2082        assert!(result.contains("**PostgreSQL**"));
2083        assert!(result.contains("[Monitoring guide](https://example.com/monitoring)"));
2084        assert!(result.contains("[this report](./benchmarks.md)"));
2085    }
2086
2087    #[test]
2088    fn test_link_preserves_body_content() {
2089        let temp = TempDir::new().unwrap();
2090        let repo = Repository::init(temp.path(), None, true).unwrap();
2091
2092        let content_1 = r#"---
2093number: 2
2094title: First decision
2095date: 2026-01-15
2096status: accepted
2097---
2098
2099## Context
2100
2101Custom context with **bold** and [links](https://example.com).
2102
2103## Decision
2104
2105A detailed decision paragraph.
2106
2107## Consequences
2108
2109- Important consequence 1
2110- Important consequence 2
2111"#;
2112        let content_2 = r#"---
2113number: 3
2114title: Second decision
2115date: 2026-01-16
2116status: accepted
2117---
2118
2119## Context
2120
2121Different context entirely.
2122
2123## Decision
2124
2125Another decision.
2126
2127## Consequences
2128
2129None significant.
2130"#;
2131        fs::write(repo.adr_path().join("0002-first-decision.md"), content_1).unwrap();
2132        fs::write(repo.adr_path().join("0003-second-decision.md"), content_2).unwrap();
2133
2134        repo.link(2, 3, LinkKind::Amends, LinkKind::AmendedBy)
2135            .unwrap();
2136
2137        let result_1 = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
2138        let result_2 = fs::read_to_string(repo.adr_path().join("0003-second-decision.md")).unwrap();
2139
2140        // Bodies must be intact
2141        assert!(result_1.contains("Custom context with **bold** and [links](https://example.com)"));
2142        assert!(result_1.contains("A detailed decision paragraph."));
2143        assert!(result_2.contains("Different context entirely."));
2144        assert!(result_2.contains("None significant."));
2145
2146        // Links must be present in frontmatter
2147        assert!(result_1.contains("links:"));
2148        assert!(result_1.contains("target: 3"));
2149        assert!(result_2.contains("links:"));
2150        assert!(result_2.contains("target: 2"));
2151    }
2152
2153    #[test]
2154    fn test_supersede_preserves_old_adr_body() {
2155        let temp = TempDir::new().unwrap();
2156        let repo = Repository::init(temp.path(), None, true).unwrap();
2157
2158        let rich_content = r#"---
2159number: 2
2160title: Original approach
2161date: 2026-01-15
2162status: accepted
2163---
2164
2165## Context and Problem Statement
2166
2167This has **rich** markdown with [links](https://example.com).
2168
2169```rust
2170fn important_code() -> bool {
2171    true
2172}
2173```
2174
2175## Decision Outcome
2176
2177We chose the original approach.
2178
2179| Criteria | Score |
2180|----------|-------|
2181| Speed    | 9/10  |
2182| Safety   | 8/10  |
2183"#;
2184        fs::write(
2185            repo.adr_path().join("0002-original-approach.md"),
2186            rich_content,
2187        )
2188        .unwrap();
2189
2190        repo.supersede("Better approach", 2).unwrap();
2191
2192        let old_content =
2193            fs::read_to_string(repo.adr_path().join("0002-original-approach.md")).unwrap();
2194
2195        // Old ADR body must be preserved
2196        assert!(old_content.contains("```rust"));
2197        assert!(old_content.contains("fn important_code()"));
2198        assert!(old_content.contains("| Criteria | Score |"));
2199        assert!(old_content.contains("[links](https://example.com)"));
2200
2201        // Status and links must be updated
2202        assert!(old_content.contains("status: superseded"));
2203        assert!(old_content.contains("target: 3"));
2204    }
2205
2206    #[test]
2207    fn test_set_status_legacy_preserves_sections() {
2208        let temp = TempDir::new().unwrap();
2209        let repo = Repository::init(temp.path(), None, false).unwrap();
2210
2211        let legacy_content = r#"# 2. Use Rust for backend
2212
2213Date: 2026-01-15
2214
2215## Status
2216
2217Proposed
2218
2219## Context
2220
2221We need a fast, safe language for our backend services.
2222
2223See the [Rust book](https://doc.rust-lang.org/book/) for details.
2224
2225## Decision
2226
2227We will use **Rust** with the `tokio` runtime.
2228
2229```toml
2230[dependencies]
2231tokio = { version = "1", features = ["full"] }
2232```
2233
2234## Consequences
2235
2236- Type safety prevents many bugs at compile time
2237- Learning curve for team members
2238"#;
2239        let adr_path = repo.adr_path().join("0002-use-rust-for-backend.md");
2240        fs::write(&adr_path, legacy_content).unwrap();
2241
2242        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2243
2244        let result = fs::read_to_string(&adr_path).unwrap();
2245
2246        // Status should change
2247        assert!(result.contains("Accepted"));
2248
2249        // Other sections must be preserved exactly
2250        assert!(result.contains("[Rust book](https://doc.rust-lang.org/book/)"));
2251        assert!(result.contains("**Rust**"));
2252        assert!(result.contains("`tokio`"));
2253        assert!(result.contains("```toml"));
2254        assert!(result.contains("tokio = { version = \"1\", features = [\"full\"] }"));
2255        assert!(result.contains("Type safety prevents many bugs"));
2256    }
2257
2258    #[test]
2259    fn test_set_status_frontmatter_with_existing_links() {
2260        let temp = TempDir::new().unwrap();
2261        let repo = Repository::init(temp.path(), None, true).unwrap();
2262
2263        let content = r#"---
2264number: 2
2265title: Updated approach
2266date: 2026-01-15
2267status: proposed
2268links:
2269  - target: 1
2270    kind: amends
2271---
2272
2273## Context
2274
2275Context.
2276
2277## Decision
2278
2279Decision.
2280"#;
2281        let adr_path = repo.adr_path().join("0002-updated-approach.md");
2282        fs::write(&adr_path, content).unwrap();
2283
2284        // Just change status, links should be preserved
2285        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2286
2287        let result = fs::read_to_string(&adr_path).unwrap();
2288        assert!(result.contains("status: accepted"));
2289        assert!(result.contains("links:"));
2290        assert!(result.contains("target: 1"));
2291        assert!(result.contains("kind: amends"));
2292        // No extra blank line before closing ---
2293        assert!(
2294            !result.contains("\n\n---"),
2295            "Should not have extra blank line before closing ---: {:?}",
2296            result
2297        );
2298    }
2299
2300    #[test]
2301    fn test_set_status_no_extra_newline_before_separator() {
2302        let temp = TempDir::new().unwrap();
2303        let repo = Repository::init(temp.path(), None, true).unwrap();
2304
2305        let content = "---\nnumber: 2\ntitle: Test\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
2306        let adr_path = repo.adr_path().join("0002-test.md");
2307        fs::write(&adr_path, content).unwrap();
2308
2309        repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2310
2311        let result = fs::read_to_string(&adr_path).unwrap();
2312        assert!(result.contains("status: accepted"));
2313        // Frontmatter should close cleanly without extra blank line (#192)
2314        assert!(
2315            result.contains("\n---\n"),
2316            "Should have clean closing separator: {:?}",
2317            result
2318        );
2319        assert!(
2320            !result.contains("\n\n---"),
2321            "Should not have extra blank line before closing ---: {:?}",
2322            result
2323        );
2324    }
2325
2326    #[test]
2327    fn test_set_status_rejects_empty_custom() {
2328        let temp = TempDir::new().unwrap();
2329        let repo = Repository::init(temp.path(), None, true).unwrap();
2330
2331        let content = "---\nnumber: 2\ntitle: Test\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
2332        let adr_path = repo.adr_path().join("0002-test.md");
2333        fs::write(&adr_path, content).unwrap();
2334
2335        // Whitespace-only and empty custom statuses must be rejected (#305).
2336        for bad in ["", " ", "   ", "\t"] {
2337            let err = repo
2338                .set_status(2, AdrStatus::Custom(bad.to_string()), None)
2339                .unwrap_err();
2340            assert!(
2341                matches!(err, Error::InvalidStatus(_)),
2342                "expected InvalidStatus for {:?}, got {:?}",
2343                bad,
2344                err
2345            );
2346        }
2347
2348        // The file must be untouched and still parse.
2349        let result = fs::read_to_string(&adr_path).unwrap();
2350        assert!(result.contains("status: proposed"));
2351        assert!(repo.get(2).is_ok());
2352    }
2353
2354    /// `update_content` on a MADR 4.0.0 ADR must preserve unmodified sections and
2355    /// headings (not re-render as Nygard/adr-tools).
2356    #[test]
2357    fn test_update_madr_content_preserves_unmodified_sections() {
2358        let temp = TempDir::new().unwrap();
2359        let repo = Repository::init(temp.path(), None, true).unwrap();
2360
2361        let madr_content = r#"---
2362number: 2
2363title: Use Redis for caching
2364date: 2026-01-15
2365status: proposed
2366---
2367
2368# Use Redis for caching
2369
2370## Context and Problem Statement
2371
2372Original context about caching needs.
2373
2374## Considered Options
2375
2376* Redis
2377* Memcached
2378
2379## Decision Outcome
2380
2381Chosen option: "Redis", because it supports data structures beyond simple key-value.
2382
2383### Consequences
2384
2385* Good, because it provides pub/sub
2386"#;
2387        let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
2388        fs::write(&adr_path, madr_content).unwrap();
2389
2390        let mut adr = repo.get(2).unwrap();
2391        adr.context = "Updated context text.".into();
2392        repo.update(
2393            &adr,
2394            BodySectionPatch {
2395                context: Some("Updated context text.".into()),
2396                ..Default::default()
2397            },
2398        )
2399        .unwrap();
2400
2401        let result = fs::read_to_string(&adr_path).unwrap();
2402
2403        assert!(result.contains("## Context and Problem Statement"));
2404        assert!(result.contains("Updated context text."));
2405        assert!(result.contains("## Considered Options"));
2406        assert!(result.contains("* Memcached"));
2407        assert!(result.contains("## Decision Outcome"));
2408        assert!(result.contains("Chosen option: \"Redis\""));
2409        assert!(result.contains("### Consequences"));
2410        assert!(result.contains("* Good, because it provides pub/sub"));
2411        assert!(!result.contains("What is the change that we're proposing"));
2412        assert!(!result.contains("## Context\n"));
2413        assert!(!result.contains("## Decision\n"));
2414    }
2415
2416    #[test]
2417    fn test_update_madr_context_preserves_decision_h3_subsections() {
2418        let temp = TempDir::new().unwrap();
2419        let repo = Repository::init(temp.path(), None, true).unwrap();
2420
2421        let madr_content = r#"---
2422number: 2
2423title: Use Redis
2424date: 2026-01-15
2425status: proposed
2426---
2427
2428## Context and Problem Statement
2429
2430Original context.
2431
2432## Decision Outcome
2433
2434Chosen option: "Redis", because it is fast.
2435
2436### Consequences
2437
2438* Good, because it provides pub/sub
2439* Bad, because it needs memory
2440
2441### Confirmation
2442
2443We will confirm via load tests.
2444"#;
2445        let adr_path = repo.adr_path().join("0002-use-redis.md");
2446        fs::write(&adr_path, madr_content).unwrap();
2447
2448        let mut adr = repo.get(2).unwrap();
2449        adr.context = "Updated context only.".into();
2450        repo.update(
2451            &adr,
2452            BodySectionPatch {
2453                context: Some("Updated context only.".into()),
2454                ..Default::default()
2455            },
2456        )
2457        .unwrap();
2458
2459        let result = fs::read_to_string(&adr_path).unwrap();
2460        assert!(result.contains("Updated context only."));
2461        assert!(result.contains("Chosen option: \"Redis\", because it is fast."));
2462        assert!(result.contains("### Consequences"));
2463        assert!(result.contains("* Good, because it provides pub/sub"));
2464        assert!(result.contains("* Bad, because it needs memory"));
2465        assert!(result.contains("### Confirmation"));
2466        assert!(result.contains("We will confirm via load tests."));
2467    }
2468
2469    #[test]
2470    fn test_update_madr_consequences_patches_h3_subsection() {
2471        let temp = TempDir::new().unwrap();
2472        let repo = Repository::init(temp.path(), None, true).unwrap();
2473
2474        let madr_content = r#"---
2475number: 2
2476title: Use Redis
2477date: 2026-01-15
2478status: proposed
2479---
2480
2481## Context and Problem Statement
2482
2483Context.
2484
2485## Decision Outcome
2486
2487Chosen option: "Redis", because it is fast.
2488
2489### Consequences
2490
2491* Old consequence
2492"#;
2493        let adr_path = repo.adr_path().join("0002-use-redis.md");
2494        fs::write(&adr_path, madr_content).unwrap();
2495
2496        let mut adr = repo.get(2).unwrap();
2497        adr.consequences = "* New consequence".into();
2498        repo.update(
2499            &adr,
2500            BodySectionPatch {
2501                consequences: Some("* New consequence".into()),
2502                ..Default::default()
2503            },
2504        )
2505        .unwrap();
2506
2507        let result = fs::read_to_string(&adr_path).unwrap();
2508        assert!(result.contains("Chosen option: \"Redis\", because it is fast."));
2509        assert!(result.contains("### Consequences"));
2510        assert!(result.contains("* New consequence"));
2511        assert!(!result.contains("* Old consequence"));
2512    }
2513
2514    /// MADR 4.0.0 section bodies must round-trip through parse → update → parse.
2515    #[test]
2516    fn test_update_madr_content_round_trip_via_get() {
2517        let temp = TempDir::new().unwrap();
2518        let repo = Repository::init(temp.path(), None, true).unwrap();
2519
2520        let madr_content = r#"---
2521number: 2
2522title: Use PostgreSQL
2523date: 2026-01-15
2524status: proposed
2525---
2526
2527## Context and Problem Statement
2528
2529We need a relational database.
2530
2531## Decision Outcome
2532
2533We will use PostgreSQL 16.
2534"#;
2535        let adr_path = repo.adr_path().join("0002-use-postgresql.md");
2536        fs::write(&adr_path, madr_content).unwrap();
2537
2538        let mut adr = repo.get(2).unwrap();
2539        adr.context = "Updated context only.".into();
2540        repo.update(
2541            &adr,
2542            BodySectionPatch {
2543                context: Some("Updated context only.".into()),
2544                ..Default::default()
2545            },
2546        )
2547        .unwrap();
2548
2549        let reloaded = repo.get(2).unwrap();
2550        assert_eq!(reloaded.context, "Updated context only.");
2551        assert_eq!(reloaded.decision, "We will use PostgreSQL 16.");
2552    }
2553
2554    #[test]
2555    fn test_update_metadata_adds_tags_to_frontmatter() {
2556        let temp = TempDir::new().unwrap();
2557        let repo = Repository::init(temp.path(), None, true).unwrap();
2558
2559        let content = r#"---
2560number: 2
2561title: Tagged ADR
2562date: 2026-01-15
2563status: proposed
2564---
2565
2566## Context
2567
2568Context.
2569"#;
2570        let adr_path = repo.adr_path().join("0002-tagged-adr.md");
2571        fs::write(&adr_path, content).unwrap();
2572
2573        let mut adr = repo.get(2).unwrap();
2574        adr.set_tags(vec!["security".into(), "api".into()]);
2575        repo.update_metadata(&adr).unwrap();
2576
2577        let result = fs::read_to_string(&adr_path).unwrap();
2578        assert!(result.contains("tags:"));
2579        // serde_yaml emits block sequences at column 0; both indent styles are valid.
2580        assert!(
2581            result.contains("- security") && result.contains("- api"),
2582            "tags missing from frontmatter\n{result}"
2583        );
2584        // Body preserved
2585        assert!(result.contains("## Context\n\nContext."));
2586    }
2587
2588    // ========== list_with_errors Tests ==========
2589
2590    #[test]
2591    fn test_list_with_errors_all_valid() {
2592        let temp = TempDir::new().unwrap();
2593        let repo = Repository::init(temp.path(), None, true).unwrap();
2594        repo.new_adr("Valid ADR").unwrap();
2595
2596        let (adrs, errors) = repo.list_with_errors().unwrap();
2597        assert_eq!(adrs.len(), 2); // init ADR + new one
2598        assert!(errors.is_empty());
2599    }
2600
2601    #[test]
2602    fn test_list_with_errors_captures_invalid_frontmatter() {
2603        let temp = TempDir::new().unwrap();
2604        let repo = Repository::init(temp.path(), None, true).unwrap();
2605
2606        // Write a file with invalid YAML frontmatter (bad date format)
2607        let bad_content =
2608            "---\nnumber: 2\nstatus: accepted\ndate: not-a-date\n---\n\n# 2. Bad ADR\n";
2609        fs::write(repo.adr_path().join("0002-bad-adr.md"), bad_content).unwrap();
2610
2611        let (adrs, errors) = repo.list_with_errors().unwrap();
2612        assert_eq!(adrs.len(), 1); // Only the init ADR
2613        assert_eq!(errors.len(), 1);
2614        assert!(errors[0].0.to_string_lossy().contains("0002-bad-adr.md"));
2615    }
2616
2617    #[test]
2618    fn test_list_with_errors_mixed_valid_and_invalid() {
2619        let temp = TempDir::new().unwrap();
2620        let repo = Repository::init(temp.path(), None, true).unwrap();
2621
2622        // Valid ADR
2623        repo.new_adr("Good ADR").unwrap();
2624
2625        // Invalid ADR (completely broken YAML)
2626        let bad_content = "---\n: :\n---\n\n# 3. Broken\n";
2627        fs::write(repo.adr_path().join("0003-broken.md"), bad_content).unwrap();
2628
2629        let (adrs, errors) = repo.list_with_errors().unwrap();
2630        assert_eq!(adrs.len(), 2); // init + good
2631        assert_eq!(errors.len(), 1); // broken
2632    }
2633
2634    #[test]
2635    fn test_list_with_errors_string_decision_makers_is_valid() {
2636        let temp = TempDir::new().unwrap();
2637        let repo = Repository::init(temp.path(), None, true).unwrap();
2638
2639        // This is the exact case from issue #216
2640        let content = r#"---
2641number: 2
2642status: accepted
2643date: 2026-03-18
2644decision-makers: mschoettle
2645---
2646
2647# 2. Use Markdown Architectural Decision Records
2648"#;
2649        fs::write(repo.adr_path().join("0002-use-markdown-adrs.md"), content).unwrap();
2650
2651        let (adrs, errors) = repo.list_with_errors().unwrap();
2652        assert!(errors.is_empty(), "string decision-makers should parse");
2653        assert_eq!(adrs.len(), 2);
2654
2655        let adr = adrs.iter().find(|a| a.number == 2).unwrap();
2656        assert_eq!(adr.decision_makers, vec!["mschoettle"]);
2657    }
2658    // ========== Link metadata round-trip Tests (#323, #325) ==========
2659
2660    #[test]
2661    fn test_update_metadata_preserves_link_descriptions() {
2662        let temp = TempDir::new().unwrap();
2663        let repo = Repository::init(temp.path(), None, true).unwrap();
2664
2665        let content = r#"---
2666number: 2
2667title: Linked ADR
2668date: 2026-01-15
2669status: proposed
2670links:
2671  - target: 1
2672    kind: relatesto
2673    description: Explains the connection
2674---
2675
2676## Context
2677
2678Context.
2679"#;
2680        let adr_path = repo.adr_path().join("0002-linked-adr.md");
2681        fs::write(&adr_path, content).unwrap();
2682
2683        let adr = repo.get(2).unwrap();
2684        assert_eq!(
2685            adr.links[0].description.as_deref(),
2686            Some("Explains the connection")
2687        );
2688
2689        // No-op update_metadata must not drop the description.
2690        repo.update_metadata(&adr).unwrap();
2691        let result = fs::read_to_string(&adr_path).unwrap();
2692        assert!(result.contains("kind: relatesto"));
2693        assert!(result.contains("description: Explains the connection"));
2694    }
2695
2696    #[test]
2697    fn test_update_metadata_kebab_case_kind_round_trips_verbatim() {
2698        // KNOWN-QUIRK (#323, deferred): kebab-case `kind: relates-to` deserializes
2699        // as LinkKind::Custom("relates-to") rather than LinkKind::RelatesTo (see
2700        // the note on LinkKind). update_metadata must at least not silently
2701        // rewrite it to the canonical "relatesto" spelling on write-back.
2702        let temp = TempDir::new().unwrap();
2703        let repo = Repository::init(temp.path(), None, true).unwrap();
2704
2705        let content = r#"---
2706number: 2
2707title: Linked ADR
2708date: 2026-01-15
2709status: proposed
2710links:
2711  - target: 1
2712    kind: relates-to
2713---
2714
2715## Context
2716
2717Context.
2718"#;
2719        let adr_path = repo.adr_path().join("0002-linked-adr.md");
2720        fs::write(&adr_path, content).unwrap();
2721
2722        let adr = repo.get(2).unwrap();
2723        assert_eq!(adr.links[0].kind, LinkKind::Custom("relates-to".into()));
2724
2725        repo.update_metadata(&adr).unwrap();
2726        let result = fs::read_to_string(&adr_path).unwrap();
2727        assert!(result.contains("kind: relates-to"));
2728    }
2729
2730    #[test]
2731    fn test_resolve_link_titles_uses_actual_filename_for_hand_named_target() {
2732        let temp = TempDir::new().unwrap();
2733        let repo = Repository::init(temp.path(), None, false).unwrap();
2734
2735        // Target's on-disk filename deliberately differs from a slug of its title.
2736        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";
2737        fs::write(
2738            repo.adr_path().join("0002-use-rust-for-backend.md"),
2739            target_content,
2740        )
2741        .unwrap();
2742
2743        let mut source = repo.get(1).unwrap();
2744        source.add_link(AdrLink::new(2, LinkKind::Amends));
2745
2746        let titles = repo.resolve_link_titles(&source);
2747        let (title, filename) = titles.get(&2).unwrap();
2748        assert_eq!(title, "Use Rust for backend services");
2749        assert_eq!(filename, "0002-use-rust-for-backend.md");
2750    }
2751
2752    #[test]
2753    fn test_link_href_prefers_actual_path_over_slugified_title() {
2754        let target = Adr {
2755            path: Some(PathBuf::from("/repo/doc/adr/0003-use-rust-for-backend.md")),
2756            ..Adr::new(3, "Use Rust for backend services")
2757        };
2758        assert_eq!(
2759            Repository::link_href(&target),
2760            "0003-use-rust-for-backend.md"
2761        );
2762    }
2763
2764    #[test]
2765    fn test_link_href_falls_back_to_slugified_title_when_path_missing() {
2766        let target = Adr {
2767            path: None,
2768            ..Adr::new(3, "Use Rust for backend services")
2769        };
2770        assert_eq!(
2771            Repository::link_href(&target),
2772            "0003-use-rust-for-backend-services.md"
2773        );
2774    }
2775
2776    // ========== BodySectionPatch preservation tests (issue #310) ==========
2777
2778    /// Extract from `## {heading}` through the line before the next H2 (inclusive).
2779    fn extract_h2_block(content: &str, heading: &str) -> Option<String> {
2780        let lines: Vec<&str> = content.lines().collect();
2781        let marker = format!("## {heading}");
2782        let start = lines.iter().position(|l| l.trim() == marker)?;
2783        let end = lines[(start + 1)..]
2784            .iter()
2785            .position(|l| l.starts_with("## "))
2786            .map(|p| start + 1 + p)
2787            .unwrap_or(lines.len());
2788        Some(lines[start..end].join("\n"))
2789    }
2790
2791    fn assert_h2_block_unchanged(before: &str, after: &str, heading: &str) {
2792        assert_eq!(
2793            extract_h2_block(before, heading),
2794            extract_h2_block(after, heading),
2795            "section `{heading}` should be byte-identical"
2796        );
2797    }
2798
2799    #[test]
2800    fn test_update_ng_nygard_rich_context_preserved_on_decision_patch() {
2801        let temp = TempDir::new().unwrap();
2802        let repo = Repository::init(temp.path(), None, true).unwrap();
2803
2804        let content = r#"---
2805number: 2
2806title: Rich context ADR
2807date: 2026-01-15
2808status: proposed
2809---
2810
2811## Context
2812
2813We need caching. See the [Redis docs](https://redis.io).
2814
2815* Requirement one
2816* Requirement two
2817
2818Use `redis-cli` for debugging.
2819
2820## Decision
2821
2822Old decision.
2823
2824## Consequences
2825
2826Old consequences.
2827"#;
2828        let adr_path = repo.adr_path().join("0002-rich-context-adr.md");
2829        fs::write(&adr_path, content).unwrap();
2830        let before = content.to_string();
2831
2832        let mut adr = repo.get(2).unwrap();
2833        adr.decision = "New decision.".into();
2834        repo.update(
2835            &adr,
2836            BodySectionPatch {
2837                decision: Some("New decision.".into()),
2838                ..Default::default()
2839            },
2840        )
2841        .unwrap();
2842
2843        let after = fs::read_to_string(&adr_path).unwrap();
2844        assert_h2_block_unchanged(&before, &after, "Context");
2845        assert_h2_block_unchanged(&before, &after, "Consequences");
2846        assert!(after.contains("New decision."));
2847    }
2848
2849    #[test]
2850    fn test_update_madr_rich_context_preserved_on_consequences_patch() {
2851        let temp = TempDir::new().unwrap();
2852        let repo = Repository::init(temp.path(), None, true).unwrap();
2853
2854        let content = r#"---
2855number: 2
2856title: Rich MADR context
2857date: 2026-01-15
2858status: proposed
2859---
2860
2861## Context and Problem Statement
2862
2863We need caching. See the [Redis docs](https://redis.io).
2864
2865* Requirement one
2866* Requirement two
2867
2868Use `redis-cli` for debugging.
2869
2870## Decision Outcome
2871
2872Chosen option: "Redis", because it is fast.
2873
2874### Consequences
2875
2876* Old consequence
2877"#;
2878        let adr_path = repo.adr_path().join("0002-rich-madr-context.md");
2879        fs::write(&adr_path, content).unwrap();
2880        let before = content.to_string();
2881
2882        let mut adr = repo.get(2).unwrap();
2883        adr.consequences = "* New consequence".into();
2884        repo.update(
2885            &adr,
2886            BodySectionPatch {
2887                consequences: Some("* New consequence".into()),
2888                ..Default::default()
2889            },
2890        )
2891        .unwrap();
2892
2893        let after = fs::read_to_string(&adr_path).unwrap();
2894        assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
2895        assert!(after.contains("Chosen option: \"Redis\", because it is fast."));
2896        assert!(after.contains("* New consequence"));
2897        assert!(!after.contains("* Old consequence"));
2898    }
2899
2900    #[test]
2901    fn test_update_legacy_rich_context_preserved_on_decision_patch() {
2902        let temp = TempDir::new().unwrap();
2903        let repo = Repository::init(temp.path(), None, false).unwrap();
2904
2905        let content = r#"# 2. Legacy rich context
2906
2907Date: 2026-01-15
2908
2909## Status
2910
2911Proposed
2912
2913## Context
2914
2915We need caching. See the [Redis docs](https://redis.io).
2916
2917* Requirement one
2918* Requirement two
2919
2920Use `redis-cli` for debugging.
2921
2922## Decision
2923
2924Old decision.
2925
2926## Consequences
2927
2928Old consequences.
2929"#;
2930        let adr_path = repo.adr_path().join("0002-legacy-rich-context.md");
2931        fs::write(&adr_path, content).unwrap();
2932        let before = content.to_string();
2933
2934        let mut adr = repo.get(2).unwrap();
2935        adr.decision = "New decision.".into();
2936        repo.update(
2937            &adr,
2938            BodySectionPatch {
2939                decision: Some("New decision.".into()),
2940                ..Default::default()
2941            },
2942        )
2943        .unwrap();
2944
2945        let after = fs::read_to_string(&adr_path).unwrap();
2946        assert_h2_block_unchanged(&before, &after, "Context");
2947        assert_h2_block_unchanged(&before, &after, "Consequences");
2948        assert!(after.contains("New decision."));
2949    }
2950
2951    #[test]
2952    fn test_update_madr_decision_only_preserves_h3_subsections() {
2953        let temp = TempDir::new().unwrap();
2954        let repo = Repository::init(temp.path(), None, true).unwrap();
2955
2956        let content = r#"---
2957number: 2
2958title: Decision patch MADR
2959date: 2026-01-15
2960status: proposed
2961---
2962
2963## Context and Problem Statement
2964
2965Context.
2966
2967## Decision Outcome
2968
2969Old intro text.
2970
2971### Consequences
2972
2973* Good, because it is fast
2974* Bad, because it uses memory
2975
2976### Confirmation
2977
2978Confirm via load tests.
2979"#;
2980        let adr_path = repo.adr_path().join("0002-decision-patch-madr.md");
2981        fs::write(&adr_path, content).unwrap();
2982        let before = content.to_string();
2983
2984        let mut adr = repo.get(2).unwrap();
2985        adr.decision = "New intro text.".into();
2986        repo.update(
2987            &adr,
2988            BodySectionPatch {
2989                decision: Some("New intro text.".into()),
2990                ..Default::default()
2991            },
2992        )
2993        .unwrap();
2994
2995        let after = fs::read_to_string(&adr_path).unwrap();
2996        assert!(after.contains("New intro text."));
2997        assert!(!after.contains("Old intro text."));
2998        assert!(after.contains("### Consequences"));
2999        assert!(after.contains("* Good, because it is fast"));
3000        assert!(after.contains("* Bad, because it uses memory"));
3001        assert!(after.contains("### Confirmation"));
3002        assert!(after.contains("Confirm via load tests."));
3003        assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
3004    }
3005
3006    #[test]
3007    fn test_update_madr_decision_and_consequences_together_preserves_other_h3() {
3008        let temp = TempDir::new().unwrap();
3009        let repo = Repository::init(temp.path(), None, true).unwrap();
3010
3011        let content = r#"---
3012number: 2
3013title: Combined patch MADR
3014date: 2026-01-15
3015status: proposed
3016---
3017
3018## Context and Problem Statement
3019
3020Context.
3021
3022## Decision Outcome
3023
3024Old intro.
3025
3026### Consequences
3027
3028* Old consequence
3029
3030### Confirmation
3031
3032Confirm via load tests.
3033"#;
3034        let adr_path = repo.adr_path().join("0002-combined-patch-madr.md");
3035        fs::write(&adr_path, content).unwrap();
3036        let before = content.to_string();
3037
3038        let mut adr = repo.get(2).unwrap();
3039        adr.decision = "New intro.".into();
3040        adr.consequences = "* New consequence".into();
3041        repo.update(
3042            &adr,
3043            BodySectionPatch {
3044                decision: Some("New intro.".into()),
3045                consequences: Some("* New consequence".into()),
3046                ..Default::default()
3047            },
3048        )
3049        .unwrap();
3050
3051        let after = fs::read_to_string(&adr_path).unwrap();
3052        assert!(after.contains("New intro."));
3053        assert!(after.contains("* New consequence"));
3054        assert!(!after.contains("Old intro."));
3055        assert!(!after.contains("* Old consequence"));
3056        assert!(after.contains("### Confirmation"));
3057        assert!(after.contains("Confirm via load tests."));
3058        assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
3059    }
3060
3061    #[test]
3062    fn test_update_ng_nygard_consequences_only_patch() {
3063        let temp = TempDir::new().unwrap();
3064        let repo = Repository::init(temp.path(), None, true).unwrap();
3065
3066        let content = r#"---
3067number: 2
3068title: Nygard consequences patch
3069date: 2026-01-15
3070status: proposed
3071---
3072
3073## Context
3074
3075Original context.
3076
3077## Decision
3078
3079Original decision.
3080
3081## Consequences
3082
3083* Old item
3084"#;
3085        let adr_path = repo.adr_path().join("0002-nygard-consequences-patch.md");
3086        fs::write(&adr_path, content).unwrap();
3087        let before = content.to_string();
3088
3089        let mut adr = repo.get(2).unwrap();
3090        adr.consequences = "* New item".into();
3091        repo.update(
3092            &adr,
3093            BodySectionPatch {
3094                consequences: Some("* New item".into()),
3095                ..Default::default()
3096            },
3097        )
3098        .unwrap();
3099
3100        let after = fs::read_to_string(&adr_path).unwrap();
3101        assert_h2_block_unchanged(&before, &after, "Context");
3102        assert_h2_block_unchanged(&before, &after, "Decision");
3103        assert!(after.contains("* New item"));
3104        assert!(!after.contains("* Old item"));
3105    }
3106
3107    #[test]
3108    fn test_update_ng_nygard_context_only_preserves_consequences_section() {
3109        let temp = TempDir::new().unwrap();
3110        let repo = Repository::init(temp.path(), None, true).unwrap();
3111
3112        let content = r#"---
3113number: 2
3114title: Nygard context patch
3115date: 2026-01-15
3116status: proposed
3117---
3118
3119## Context
3120
3121Old context.
3122
3123## Decision
3124
3125Original decision.
3126
3127## Consequences
3128
3129* Good, because it is fast
3130* Bad, because it uses memory
3131"#;
3132        let adr_path = repo.adr_path().join("0002-nygard-context-patch.md");
3133        fs::write(&adr_path, content).unwrap();
3134        let before = content.to_string();
3135
3136        let mut adr = repo.get(2).unwrap();
3137        adr.context = "New context.".into();
3138        repo.update(
3139            &adr,
3140            BodySectionPatch {
3141                context: Some("New context.".into()),
3142                ..Default::default()
3143            },
3144        )
3145        .unwrap();
3146
3147        let after = fs::read_to_string(&adr_path).unwrap();
3148        assert_h2_block_unchanged(&before, &after, "Decision");
3149        assert_h2_block_unchanged(&before, &after, "Consequences");
3150        assert!(after.contains("New context."));
3151    }
3152
3153    #[test]
3154    fn test_update_metadata_only_preserves_madr_body_byte_identical() {
3155        let temp = TempDir::new().unwrap();
3156        let repo = Repository::init(temp.path(), None, true).unwrap();
3157
3158        let content = r#"---
3159number: 2
3160title: Metadata only MADR
3161date: 2026-01-15
3162status: proposed
3163---
3164
3165## Context and Problem Statement
3166
3167Context.
3168
3169## Decision Outcome
3170
3171Intro.
3172
3173### Consequences
3174
3175* Good item
3176
3177### Confirmation
3178
3179Confirm via tests.
3180"#;
3181        let adr_path = repo.adr_path().join("0002-metadata-only-madr.md");
3182        fs::write(&adr_path, content).unwrap();
3183        let before = fs::read_to_string(&adr_path).unwrap();
3184        let body_start = before.find("\n\n## Context").unwrap();
3185
3186        let mut adr = repo.get(2).unwrap();
3187        adr.status = AdrStatus::Accepted;
3188        repo.update(&adr, BodySectionPatch::default()).unwrap();
3189
3190        let after = fs::read_to_string(&adr_path).unwrap();
3191        assert_eq!(
3192            &before[body_start..],
3193            &after[after.find("\n\n## Context").unwrap()..]
3194        );
3195        assert!(after.contains("status: accepted"));
3196    }
3197
3198    #[test]
3199    fn test_update_madr_consequences_appends_h3_when_missing() {
3200        let temp = TempDir::new().unwrap();
3201        let repo = Repository::init(temp.path(), None, true).unwrap();
3202
3203        let content = r#"---
3204number: 2
3205title: Append consequences MADR
3206date: 2026-01-15
3207status: proposed
3208---
3209
3210## Context and Problem Statement
3211
3212Context.
3213
3214## Decision Outcome
3215
3216Intro only, no consequences subsection yet.
3217"#;
3218        let adr_path = repo.adr_path().join("0002-append-consequences-madr.md");
3219        fs::write(&adr_path, content).unwrap();
3220
3221        let mut adr = repo.get(2).unwrap();
3222        adr.consequences = "* Appended consequence".into();
3223        repo.update(
3224            &adr,
3225            BodySectionPatch {
3226                consequences: Some("* Appended consequence".into()),
3227                ..Default::default()
3228            },
3229        )
3230        .unwrap();
3231
3232        let after = fs::read_to_string(&adr_path).unwrap();
3233        assert!(after.contains("Intro only, no consequences subsection yet."));
3234        assert!(after.contains("### Consequences"));
3235        assert!(after.contains("* Appended consequence"));
3236    }
3237
3238    #[test]
3239    fn test_update_madr_in_compatible_mode_repo() {
3240        let temp = TempDir::new().unwrap();
3241        let repo = Repository::init(temp.path(), None, false).unwrap();
3242
3243        let content = r#"---
3244number: 2
3245title: Compatible repo MADR file
3246date: 2026-01-15
3247status: proposed
3248---
3249
3250## Context and Problem Statement
3251
3252Original context.
3253
3254## Decision Outcome
3255
3256Chosen option: "Redis", because it is fast.
3257
3258### Consequences
3259
3260* Good item
3261
3262### Confirmation
3263
3264Confirm via tests.
3265"#;
3266        let adr_path = repo.adr_path().join("0002-compatible-repo-madr-file.md");
3267        fs::write(&adr_path, content).unwrap();
3268        let before = content.to_string();
3269
3270        let mut adr = repo.get(2).unwrap();
3271        adr.context = "Updated context.".into();
3272        repo.update(
3273            &adr,
3274            BodySectionPatch {
3275                context: Some("Updated context.".into()),
3276                ..Default::default()
3277            },
3278        )
3279        .unwrap();
3280
3281        let after = fs::read_to_string(&adr_path).unwrap();
3282        assert!(after.contains("Updated context."));
3283        assert_h2_block_unchanged(&before, &after, "Decision Outcome");
3284    }
3285
3286    #[test]
3287    fn test_update_madr_decision_only_without_h3_subsections() {
3288        let temp = TempDir::new().unwrap();
3289        let repo = Repository::init(temp.path(), None, true).unwrap();
3290
3291        let content = r#"---
3292number: 2
3293title: Simple decision MADR
3294date: 2026-01-15
3295status: proposed
3296---
3297
3298## Context and Problem Statement
3299
3300Context.
3301
3302## Decision Outcome
3303
3304Old single-paragraph decision.
3305"#;
3306        let adr_path = repo.adr_path().join("0002-simple-decision-madr.md");
3307        fs::write(&adr_path, content).unwrap();
3308
3309        let mut adr = repo.get(2).unwrap();
3310        adr.decision = "New single-paragraph decision.".into();
3311        repo.update(
3312            &adr,
3313            BodySectionPatch {
3314                decision: Some("New single-paragraph decision.".into()),
3315                ..Default::default()
3316            },
3317        )
3318        .unwrap();
3319
3320        let after = fs::read_to_string(&adr_path).unwrap();
3321        assert!(after.contains("New single-paragraph decision."));
3322        assert!(!after.contains("Old single-paragraph decision."));
3323    }
3324
3325    #[test]
3326    fn test_update_madr_optional_sections_preserved_byte_identical() {
3327        let temp = TempDir::new().unwrap();
3328        let repo = Repository::init(temp.path(), None, true).unwrap();
3329
3330        let content = r#"---
3331number: 2
3332title: MADR optional sections
3333date: 2026-01-15
3334status: proposed
3335---
3336
3337## Context and Problem Statement
3338
3339Original context.
3340
3341## Considered Options
3342
3343* Redis
3344* Memcached
3345
3346## Decision Outcome
3347
3348Chosen option: "Redis", because it is fast.
3349
3350### Consequences
3351
3352* Good item
3353
3354## Pros and Cons of the Options
3355
3356### Redis
3357
3358* Good, because fast
3359* Bad, because memory
3360
3361### Memcached
3362
3363* Good, because simple
3364* Bad, because strings only
3365
3366## More Information
3367
3368See [MADR](https://adr.github.io/madr/) for details.
3369"#;
3370        let adr_path = repo.adr_path().join("0002-madr-optional-sections.md");
3371        fs::write(&adr_path, content).unwrap();
3372        let before = content.to_string();
3373
3374        let mut adr = repo.get(2).unwrap();
3375        adr.context = "Updated context.".into();
3376        repo.update(
3377            &adr,
3378            BodySectionPatch {
3379                context: Some("Updated context.".into()),
3380                ..Default::default()
3381            },
3382        )
3383        .unwrap();
3384
3385        let after = fs::read_to_string(&adr_path).unwrap();
3386        assert!(after.contains("Updated context."));
3387        assert_h2_block_unchanged(&before, &after, "Considered Options");
3388        assert_h2_block_unchanged(&before, &after, "Decision Outcome");
3389        assert_h2_block_unchanged(&before, &after, "Pros and Cons of the Options");
3390        assert_h2_block_unchanged(&before, &after, "More Information");
3391    }
3392
3393    #[test]
3394    fn test_update_unchanged_sections_byte_identical_after_context_patch() {
3395        let temp = TempDir::new().unwrap();
3396        let repo = Repository::init(temp.path(), None, true).unwrap();
3397
3398        let content = r#"---
3399number: 2
3400title: Byte identity check
3401date: 2026-01-15
3402status: proposed
3403---
3404
3405## Context and Problem Statement
3406
3407Original context.
3408
3409## Decision Outcome
3410
3411Intro.
3412
3413### Consequences
3414
3415* Good item
3416
3417### Confirmation
3418
3419Confirm via tests.
3420"#;
3421        let adr_path = repo.adr_path().join("0002-byte-identity-check.md");
3422        fs::write(&adr_path, content).unwrap();
3423        let before = content.to_string();
3424
3425        let mut adr = repo.get(2).unwrap();
3426        adr.context = "Updated context.".into();
3427        repo.update(
3428            &adr,
3429            BodySectionPatch {
3430                context: Some("Updated context.".into()),
3431                ..Default::default()
3432            },
3433        )
3434        .unwrap();
3435
3436        let after = fs::read_to_string(&adr_path).unwrap();
3437        assert_h2_block_unchanged(&before, &after, "Decision Outcome");
3438    }
3439
3440    #[test]
3441    fn test_update_madr_context_patch_does_not_round_trip_lossy_fields() {
3442        let temp = TempDir::new().unwrap();
3443        let repo = Repository::init(temp.path(), None, true).unwrap();
3444
3445        let content = r#"---
3446number: 2
3447title: Lossy parse guard
3448date: 2026-01-15
3449status: proposed
3450---
3451
3452## Context and Problem Statement
3453
3454Original context.
3455
3456## Decision Outcome
3457
3458See [Redis docs](https://redis.io) and use `redis-cli`.
3459
3460* Chosen option: "Redis"
3461* Because it is **fast**
3462
3463### Consequences
3464
3465* Good item
3466"#;
3467        let adr_path = repo.adr_path().join("0002-lossy-parse-guard.md");
3468        fs::write(&adr_path, content).unwrap();
3469        let before = content.to_string();
3470
3471        let mut adr = repo.get(2).unwrap();
3472        // Simulate MCP path: get() lossy-parses decision, but we only patch context.
3473        adr.context = "Updated context.".into();
3474        repo.update(
3475            &adr,
3476            BodySectionPatch {
3477                context: Some("Updated context.".into()),
3478                ..Default::default()
3479            },
3480        )
3481        .unwrap();
3482
3483        let after = fs::read_to_string(&adr_path).unwrap();
3484        assert_h2_block_unchanged(&before, &after, "Decision Outcome");
3485        assert!(after.contains("[Redis docs](https://redis.io)"));
3486        assert!(after.contains("`redis-cli`"));
3487        assert!(after.contains("**fast**"));
3488    }
3489
3490    // ========== BodySectionPatch write-path regressions ==========
3491
3492    #[test]
3493    fn test_fence_in_decision_outcome_preserved_on_consequences_patch() {
3494        let temp = TempDir::new().unwrap();
3495        let repo = Repository::init(temp.path(), None, true).unwrap();
3496
3497        let content = r#"---
3498number: 2
3499title: Fenced example in decision
3500date: 2026-01-15
3501status: proposed
3502---
3503
3504## Context and Problem Statement
3505
3506Context.
3507
3508## Decision Outcome
3509
3510Chosen option: "Redis", because it is fast.
3511
3512```markdown
3513## Consequences
3514
3515Example consequences inside a fence.
3516```
3517
3518Trailing text after the fence.
3519
3520### Consequences
3521
3522* Good, because it provides pub/sub
3523
3524### Confirmation
3525
3526We will confirm via load tests.
3527"#;
3528        let adr_path = repo.adr_path().join("0002-fenced-decision-outcome.md");
3529        fs::write(&adr_path, content).unwrap();
3530        let before = content.to_string();
3531
3532        let mut adr = repo.get(2).unwrap();
3533        adr.consequences = "* Updated consequence".into();
3534        repo.update(
3535            &adr,
3536            BodySectionPatch {
3537                consequences: Some("* Updated consequence".into()),
3538                ..Default::default()
3539            },
3540        )
3541        .unwrap();
3542
3543        let after = fs::read_to_string(&adr_path).unwrap();
3544        assert!(after.contains("```markdown"));
3545        assert!(after.contains("Example consequences inside a fence."));
3546        assert!(after.contains("Trailing text after the fence."));
3547        assert!(after.contains("### Confirmation"));
3548        assert!(after.contains("We will confirm via load tests."));
3549        assert!(after.contains("* Updated consequence"));
3550        assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
3551    }
3552
3553    #[test]
3554    fn test_body_patch_preserves_sections_without_trailing_newline() {
3555        let temp = TempDir::new().unwrap();
3556        let repo = Repository::init(temp.path(), None, false).unwrap();
3557
3558        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.";
3559        let adr_path = repo.adr_path().join("0002-no-trailing-newline.md");
3560        fs::write(&adr_path, content).unwrap();
3561        assert_ne!(fs::read(&adr_path).unwrap().last(), Some(&b'\n'));
3562
3563        let mut adr = repo.get(2).unwrap();
3564        adr.decision = "New decision.".into();
3565        repo.update(
3566            &adr,
3567            BodySectionPatch {
3568                decision: Some("New decision.".into()),
3569                ..Default::default()
3570            },
3571        )
3572        .unwrap();
3573
3574        let after = fs::read_to_string(&adr_path).unwrap();
3575        assert!(!after.contains("Old context.## Decision"));
3576        let reparsed = repo.get(2).unwrap();
3577        assert!(reparsed.decision.contains("New decision."));
3578    }
3579
3580    #[test]
3581    fn test_people_field_yaml_unchanged_skip_rewrite() {
3582        let temp = TempDir::new().unwrap();
3583        let repo = Repository::init(temp.path(), None, true).unwrap();
3584
3585        let content = r#"---
3586number: 2
3587title: Zero indent consulted
3588date: 2026-01-15
3589status: proposed
3590consulted:
3591- alice
3592- bob
3593---
3594
3595## Context
3596
3597Context.
3598"#;
3599        let adr_path = repo.adr_path().join("0002-zero-indent-consulted.md");
3600        fs::write(&adr_path, content).unwrap();
3601        let before = fs::read_to_string(&adr_path).unwrap();
3602
3603        let adr = repo.get(2).unwrap();
3604        repo.update_metadata(&adr).unwrap();
3605
3606        let after = fs::read_to_string(&adr_path).unwrap();
3607        assert_eq!(before, after);
3608    }
3609
3610    #[test]
3611    fn test_body_only_update_preserves_non_canonical_status() {
3612        let temp = TempDir::new().unwrap();
3613        let repo = Repository::init(temp.path(), None, false).unwrap();
3614
3615        let content = r#"# 2. Non-canonical status
3616
3617Date: 2026-01-15
3618
3619## Status
3620
3621Approved by the architecture board on 2026-01-15.
3622
3623## Context
3624
3625Original context.
3626
3627## Decision
3628
3629Decision.
3630
3631## Consequences
3632
3633Consequences.
3634"#;
3635        let adr_path = repo.adr_path().join("0002-non-canonical-status.md");
3636        fs::write(&adr_path, content).unwrap();
3637
3638        let mut adr = repo.get(2).unwrap();
3639        adr.context = "Updated context.".into();
3640        repo.update(
3641            &adr,
3642            BodySectionPatch {
3643                context: Some("Updated context.".into()),
3644                ..Default::default()
3645            },
3646        )
3647        .unwrap();
3648
3649        let after = fs::read_to_string(&adr_path).unwrap();
3650        assert!(after.contains("Approved by the architecture board"));
3651        assert!(after.contains("Updated context."));
3652    }
3653
3654    #[test]
3655    fn test_missing_section_patch_returns_error() {
3656        let temp = TempDir::new().unwrap();
3657        let repo = Repository::init(temp.path(), None, false).unwrap();
3658
3659        let content = r#"# 2. No decision or consequences sections
3660
3661Date: 2026-01-15
3662
3663## Status
3664
3665Accepted
3666
3667## Context
3668
3669Context only.
3670"#;
3671        let adr_path = repo.adr_path().join("0002-no-consequences.md");
3672        fs::write(&adr_path, content).unwrap();
3673
3674        let mut adr = repo.get(2).unwrap();
3675        adr.consequences = "New consequences.".into();
3676        let err = repo
3677            .update(
3678                &adr,
3679                BodySectionPatch {
3680                    consequences: Some("New consequences.".into()),
3681                    ..Default::default()
3682                },
3683            )
3684            .unwrap_err();
3685        assert!(err.to_string().contains("consequences patch requested"));
3686    }
3687
3688    #[test]
3689    fn test_nygard_consequences_patch_errors_without_consequences_section() {
3690        let temp = TempDir::new().unwrap();
3691        let repo = Repository::init(temp.path(), None, false).unwrap();
3692
3693        let content = r#"# 3. Nygard decision without consequences section
3694
3695Date: 2026-01-15
3696
3697## Status
3698
3699Accepted
3700
3701## Context
3702
3703Context.
3704
3705## Decision
3706
3707We decided X.
3708"#;
3709        let adr_path = repo.adr_path().join("0003-nygard-no-consequences.md");
3710        fs::write(&adr_path, content).unwrap();
3711
3712        let mut adr = repo.get(3).unwrap();
3713        adr.consequences = "New consequences.".into();
3714        let err = repo
3715            .update(
3716                &adr,
3717                BodySectionPatch {
3718                    consequences: Some("New consequences.".into()),
3719                    ..Default::default()
3720                },
3721            )
3722            .unwrap_err();
3723        assert!(err.to_string().contains("consequences patch requested"));
3724        let after = fs::read_to_string(&adr_path).unwrap();
3725        assert!(!after.contains("### Consequences"));
3726        assert!(!after.contains("New consequences."));
3727    }
3728
3729    #[test]
3730    fn test_decision_patch_preserves_fence_in_context() {
3731        let temp = TempDir::new().unwrap();
3732        let repo = Repository::init(temp.path(), None, false).unwrap();
3733
3734        let content = r#"# 4. Fence in context
3735
3736Date: 2026-01-15
3737
3738## Status
3739
3740Accepted
3741
3742## Context
3743
3744Example:
3745
3746```
3747## Decision
3748not a real heading
3749```
3750
3751## Decision
3752
3753We decided.
3754"#;
3755        let adr_path = repo.adr_path().join("0004-fence-in-context.md");
3756        fs::write(&adr_path, content).unwrap();
3757
3758        repo.update(
3759            &repo.get(4).unwrap(),
3760            BodySectionPatch {
3761                decision: Some("Updated decision.".into()),
3762                ..Default::default()
3763            },
3764        )
3765        .unwrap();
3766
3767        let after = fs::read_to_string(&adr_path).unwrap();
3768        assert!(after.contains("## Decision\nnot a real heading"));
3769        assert!(after.contains("Updated decision."));
3770        assert!(!after.contains("We decided."));
3771    }
3772}