Skip to main content

adrs_core/
parse.rs

1//! ADR parsing - supports both legacy markdown and YAML frontmatter formats.
2
3use crate::{Adr, AdrLink, AdrStatus, Error, LinkKind, Result};
4use pulldown_cmark::{Event, HeadingLevel, Parser as MdParser, Tag, TagEnd};
5use regex::Regex;
6use std::path::Path;
7use std::sync::LazyLock;
8use time::format_description::well_known::Iso8601;
9use time::{Date, Month, OffsetDateTime};
10
11/// Regex for parsing legacy status links like "Supersedes [1. Title](0001-title.md)".
12static LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
13    Regex::new(r"^([\w\s]+)\s+\[(\d+)\.\s+[^\]]+\]\((\d{4})-[^)]+\.md\)$").unwrap()
14});
15
16/// Regex for extracting ADR number from filename.
17static NUMBER_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\d{4})-.*\.md$").unwrap());
18
19/// Map a markdown H2 heading (normalized to lowercase) to a canonical ADR body field.
20///
21/// Recognizes section names from the two supported on-disk formats:
22/// - **Nygard/adr-tools** — `Context`, `Decision`, `Consequences` (Michael Nygard's
23///   layout, as implemented by [adr-tools](https://github.com/npryce/adr-tools))
24/// - **MADR 4.0.0** — `Context and Problem Statement`, `Decision Outcome`, and
25///   `Consequences` when present as a top-level H2
26///
27/// This function only classifies H2 headings. `### Consequences`, the MADR 4.0.0
28/// H3 that appears under `## Decision Outcome` / `## Decision`, is handled as a
29/// narrow exception in [`Parser::parse_sections`] and
30/// [`Parser::extract_sections_raw`], using this same mapping to recognize the H3's
31/// title so both paths stay in sync (see issue #338).
32pub(crate) fn canonical_section_field(section: &str) -> Option<&'static str> {
33    match section.trim().to_lowercase().as_str() {
34        "context" | "context and problem statement" => Some("context"),
35        "decision" | "decision outcome" => Some("decision"),
36        "consequences" => Some("consequences"),
37        _ => None,
38    }
39}
40
41/// Parser for ADR files.
42#[derive(Debug, Default)]
43pub struct Parser {
44    _private: (),
45}
46
47impl Parser {
48    /// Create a new parser.
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Parse an ADR from a file.
54    pub fn parse_file(&self, path: &Path) -> Result<Adr> {
55        let content = std::fs::read_to_string(path)?;
56        let mut adr = self.parse(&content)?;
57
58        // Extract number from filename if not in frontmatter
59        if adr.number == 0 {
60            adr.number = extract_number_from_path(path)?;
61        }
62
63        adr.path = Some(path.to_path_buf());
64        Ok(adr)
65    }
66
67    /// Parse an ADR from a string.
68    pub fn parse(&self, content: &str) -> Result<Adr> {
69        // Normalize CRLF line endings for parsing only. This is read-side
70        // tolerance: the normalized copy is local to this call and is never
71        // written back anywhere, so on-disk files keep whatever line endings
72        // they already have.
73        let normalized;
74        let content: &str = if content.contains("\r\n") {
75            normalized = content.replace("\r\n", "\n");
76            &normalized
77        } else {
78            content
79        };
80
81        // Check for YAML frontmatter
82        if content.starts_with("---\n") {
83            self.parse_frontmatter(content)
84        } else {
85            self.parse_legacy(content)
86        }
87    }
88
89    /// Parse ADR with YAML frontmatter.
90    fn parse_frontmatter(&self, content: &str) -> Result<Adr> {
91        let parts: Vec<&str> = content.splitn(3, "---\n").collect();
92        if parts.len() < 3 {
93            return Err(Error::InvalidFormat {
94                path: Default::default(),
95                reason: "Invalid frontmatter format".into(),
96            });
97        }
98
99        let yaml = parts[1];
100        let body = parts[2];
101
102        // Parse frontmatter
103        let mut adr: Adr = serde_yaml_neo::from_str(yaml)?;
104
105        // If title is missing from frontmatter, try to extract from body H1
106        if adr.title.is_empty()
107            && let Some((num, title)) = extract_h1_title(body)
108        {
109            adr.title = title;
110            if adr.number == 0 {
111                adr.number = num;
112            }
113        }
114
115        // Parse body sections (Nygard/adr-tools and MADR 4.0.0 heading aliases)
116        let sections = self.parse_sections(body);
117        for (key, value) in &sections {
118            match canonical_section_field(key) {
119                Some("context") => adr.context = value.clone(),
120                Some("decision") => adr.decision = value.clone(),
121                Some("consequences") => adr.consequences = value.clone(),
122                _ => {}
123            }
124        }
125
126        Ok(adr)
127    }
128
129    /// Parse legacy markdown format (adr-tools compatible).
130    fn parse_legacy(&self, content: &str) -> Result<Adr> {
131        let mut adr = Adr::new(0, "");
132
133        // Use a simpler approach: split by H2 sections and parse each
134        let sections = self.extract_sections_raw(content);
135
136        // Parse H1 title
137        if let Some((num, title)) = extract_h1_title(content) {
138            adr.number = num;
139            adr.title = title;
140        }
141
142        // Parse the `Date:` line from the preamble (between the H1 and the
143        // first `## ` section), if present. `Adr::new` already defaulted
144        // `adr.date` to today, so an absent or unparseable line is a no-op.
145        if let Some(date) = extract_legacy_date(content) {
146            adr.date = date;
147        }
148
149        // Apply sections
150        for (name, content) in &sections {
151            self.apply_section(&mut adr, name, content);
152        }
153
154        Ok(adr)
155    }
156
157    /// Extract sections from raw markdown text.
158    ///
159    /// `## ` headings are the section boundaries. As a narrow exception for MADR
160    /// 4.0.0 documents that have no YAML frontmatter (e.g. the bare-minimal
161    /// template), a `### ` heading whose title canonically maps to `consequences`
162    /// and appears while the enclosing `## ` section maps to `decision` starts its
163    /// own `consequences` entry instead of continuing to accumulate into
164    /// `decision`. Any other H3 (e.g. `### Confirmation`) is not specially
165    /// recognized; its raw line and body stay part of the enclosing section's
166    /// text, matching the pre-existing behavior for every heading below H2. See
167    /// [`Self::parse_sections`] for the YAML-frontmatter-body equivalent.
168    fn extract_sections_raw(&self, content: &str) -> Vec<(String, String)> {
169        let mut sections = Vec::new();
170        let mut current_section: Option<String> = None;
171        let mut current_field: Option<&'static str> = None;
172        let mut section_content = String::new();
173
174        // MADR `### Consequences` nested under the decision `## ` section.
175        let mut consequences_content = String::new();
176        let mut has_consequences_subsection = false;
177        let mut in_consequences = false;
178
179        for line in content.lines() {
180            if line.starts_with("## ") {
181                // Save previous section
182                if let Some(ref name) = current_section {
183                    sections.push((name.clone(), section_content.trim().to_string()));
184                }
185                if has_consequences_subsection {
186                    sections.push((
187                        "consequences".to_string(),
188                        consequences_content.trim().to_string(),
189                    ));
190                }
191                current_section = Some(line.trim_start_matches("## ").trim().to_lowercase());
192                current_field = canonical_section_field(current_section.as_deref().unwrap_or(""));
193                section_content.clear();
194                consequences_content.clear();
195                has_consequences_subsection = false;
196                in_consequences = false;
197            } else if line.starts_with("### ") {
198                let heading = line.trim_start_matches("### ").trim();
199                // A new H3 always ends a prior Consequences subsection; only
200                // divert into `consequences` when this H3 itself starts one.
201                in_consequences = current_field == Some("decision")
202                    && canonical_section_field(heading) == Some("consequences");
203                if in_consequences {
204                    has_consequences_subsection = true;
205                } else if current_section.is_some() {
206                    section_content.push_str(line);
207                    section_content.push('\n');
208                }
209            } else if in_consequences {
210                consequences_content.push_str(line);
211                consequences_content.push('\n');
212            } else if current_section.is_some() {
213                section_content.push_str(line);
214                section_content.push('\n');
215            }
216        }
217
218        // Save final section
219        if let Some(ref name) = current_section {
220            sections.push((name.clone(), section_content.trim().to_string()));
221        }
222        if has_consequences_subsection {
223            sections.push((
224                "consequences".to_string(),
225                consequences_content.trim().to_string(),
226            ));
227        }
228
229        sections
230    }
231
232    /// Apply a parsed section to the ADR.
233    fn apply_section(&self, adr: &mut Adr, section: &str, content: &str) {
234        let content = content.trim().to_string();
235        if section == "status" {
236            self.parse_status_section(adr, &content);
237            return;
238        }
239        match canonical_section_field(section) {
240            Some("context") => adr.context = content,
241            Some("decision") => adr.decision = content,
242            Some("consequences") => adr.consequences = content,
243            _ => {}
244        }
245    }
246
247    /// Parse the status section for status and links.
248    fn parse_status_section(&self, adr: &mut Adr, content: &str) {
249        for line in content.lines() {
250            let line = line.trim();
251            if line.is_empty() {
252                continue;
253            }
254
255            // Check for link pattern: "Supersedes [1. Title](0001-title.md)"
256            if let Some(caps) = LINK_REGEX.captures(line) {
257                let kind_str = caps.get(1).map(|m| m.as_str()).unwrap_or("");
258                let target: u32 = caps
259                    .get(2)
260                    .and_then(|m| m.as_str().parse().ok())
261                    .unwrap_or(0);
262
263                if target > 0 {
264                    let kind: LinkKind = kind_str.trim().parse().unwrap_or(LinkKind::RelatesTo);
265
266                    // If this is a "Superseded by" link, set status to Superseded
267                    // (adr-tools doesn't always have a separate status line)
268                    if matches!(kind, LinkKind::SupersededBy) {
269                        adr.status = AdrStatus::Superseded;
270                    }
271
272                    adr.links.push(AdrLink::new(target, kind));
273                }
274            } else if !line.contains('[') && !line.contains(']') {
275                // Plain status text (not a link line)
276                // Only set status if it looks like a simple status word
277                let word = line.split_whitespace().next().unwrap_or("");
278                if matches!(
279                    word.to_lowercase().as_str(),
280                    // Include "superceded" for adr-tools compatibility (common typo)
281                    "proposed"
282                        | "accepted"
283                        | "deprecated"
284                        | "superseded"
285                        | "superceded"
286                        | "draft"
287                        | "rejected"
288                ) {
289                    adr.status = word.parse().unwrap_or(AdrStatus::Proposed);
290                }
291            }
292        }
293    }
294
295    /// Parse markdown sections into a map.
296    ///
297    /// H2 headings are the top-level section boundaries. As a narrow exception
298    /// for MADR 4.0.0 documents, an H3 heading whose title canonically maps to
299    /// `consequences` and appears directly under the H2 that maps to `decision`
300    /// (`## Decision` / `## Decision Outcome`) starts its own `consequences`
301    /// entry instead of continuing to accumulate into `decision`. Any other H3
302    /// (e.g. `### Confirmation`) is not specially recognized: its heading text
303    /// and body continue to fold into the enclosing H2's text, matching the
304    /// pre-existing behavior for every heading level other than H2. Because this
305    /// walks pulldown-cmark's event stream rather than scanning lines, fenced
306    /// code blocks containing heading-lookalike text are never mistaken for real
307    /// boundaries (see [`Self::extract_sections_raw`] for the no-frontmatter
308    /// equivalent of the H3 exception, and issue #338 for the bug this closes).
309    fn parse_sections(&self, content: &str) -> std::collections::HashMap<String, String> {
310        let mut sections = std::collections::HashMap::new();
311        let mut current_section: Option<String> = None;
312        let mut current_field: Option<&'static str> = None;
313        let mut section_content = String::new();
314
315        // MADR `### Consequences` nested under the decision H2. `Some` once such
316        // a subsection has been opened; further text is only routed here while
317        // `in_consequences` is true.
318        let mut consequences_content: Option<String> = None;
319        let mut in_consequences = false;
320
321        // Heading title currently being captured (any level), and the level it
322        // belongs to. Buffered rather than applied event-by-event so a `### `
323        // heading's title can be inspected in full before deciding whether it
324        // starts a Consequences subsection or folds into the enclosing text.
325        let mut heading_level: Option<HeadingLevel> = None;
326        let mut heading_text = String::new();
327
328        // Active list markers, one entry per nesting level. `None` is a bullet
329        // list; `Some(n)` is an ordered list whose next item number is `n`.
330        // pulldown-cmark emits no SoftBreak between sibling items, so each
331        // `Start(Item)` re-emits a separator and marker to keep list items from
332        // concatenating into one run of text.
333        let mut list_stack: Vec<Option<u64>> = Vec::new();
334
335        let parser = MdParser::new(content);
336
337        for event in parser {
338            match event {
339                Event::Start(Tag::Heading { level, .. }) => match level {
340                    HeadingLevel::H2 => {
341                        if let Some(text) = consequences_content.take() {
342                            sections.insert("consequences".to_string(), text.trim().to_string());
343                        }
344                        if let Some(ref section) = current_section {
345                            sections.insert(section.clone(), section_content.trim().to_string());
346                        }
347                        section_content.clear();
348                        current_field = None;
349                        in_consequences = false;
350                        heading_level = Some(HeadingLevel::H2);
351                        heading_text.clear();
352                    }
353                    HeadingLevel::H3 => {
354                        // A new H3 always ends a prior Consequences subsection;
355                        // only this H3 itself (checked at its End event) can
356                        // open a new one.
357                        in_consequences = false;
358                        heading_level = Some(HeadingLevel::H3);
359                        heading_text.clear();
360                    }
361                    _ => {}
362                },
363                Event::End(TagEnd::Heading(level)) => match level {
364                    HeadingLevel::H2 => {
365                        current_section = Some(heading_text.trim().to_lowercase());
366                        current_field = canonical_section_field(&heading_text);
367                        heading_level = None;
368                    }
369                    HeadingLevel::H3 => {
370                        if current_field == Some("decision")
371                            && canonical_section_field(&heading_text) == Some("consequences")
372                        {
373                            in_consequences = true;
374                            consequences_content.get_or_insert_with(String::new);
375                        } else {
376                            section_content.push_str(&heading_text);
377                        }
378                        heading_level = None;
379                    }
380                    _ => {
381                        heading_level = None;
382                    }
383                },
384                Event::Text(text) => {
385                    if heading_level.is_some() {
386                        heading_text.push_str(&text);
387                    } else if in_consequences {
388                        consequences_content
389                            .get_or_insert_with(String::new)
390                            .push_str(&text);
391                    } else {
392                        section_content.push_str(&text);
393                    }
394                }
395                Event::SoftBreak | Event::HardBreak if heading_level.is_none() => {
396                    if in_consequences {
397                        consequences_content
398                            .get_or_insert_with(String::new)
399                            .push('\n');
400                    } else {
401                        section_content.push('\n');
402                    }
403                }
404                Event::Start(Tag::List(first)) => list_stack.push(first),
405                Event::End(TagEnd::List(_)) => {
406                    list_stack.pop();
407                }
408                Event::Start(Tag::Item) if heading_level.is_none() => {
409                    let marker = match list_stack.last_mut() {
410                        Some(Some(n)) => {
411                            let m = format!("{n}. ");
412                            *n += 1;
413                            m
414                        }
415                        _ => "- ".to_string(),
416                    };
417                    let buf = if in_consequences {
418                        consequences_content.get_or_insert_with(String::new)
419                    } else {
420                        &mut section_content
421                    };
422                    if !buf.is_empty() && !buf.ends_with('\n') {
423                        buf.push('\n');
424                    }
425                    buf.push_str(&marker);
426                }
427                _ => {}
428            }
429        }
430
431        if let Some(text) = consequences_content.take() {
432            sections.insert("consequences".to_string(), text.trim().to_string());
433        }
434        if let Some(ref section) = current_section {
435            sections.insert(section.clone(), section_content.trim().to_string());
436        }
437
438        sections
439    }
440}
441
442/// Extract a title from the first H1 heading in markdown content.
443///
444/// Returns `(number, title)` where number is extracted from patterns like `# 1. Title`,
445/// or `0` if the H1 has no number prefix.
446fn extract_h1_title(content: &str) -> Option<(u32, String)> {
447    let title_line = content.lines().find(|l| l.starts_with("# "))?;
448    let title_str = title_line.trim_start_matches("# ").trim();
449    if title_str.is_empty() {
450        return None;
451    }
452    if let Some((num, title)) = parse_numbered_title(title_str) {
453        Some((num, title))
454    } else {
455        Some((0, title_str.to_string()))
456    }
457}
458
459/// Extract the date from a Nygard-style `Date: YYYY-MM-DD` line.
460///
461/// Only lines in the preamble before the ADR's first `## ` section heading
462/// are considered, so a `Date:` mentioned later in the document (e.g. in
463/// prose) is not mistaken for the ADR's date. Returns `None` if no such line
464/// exists or its value does not parse as an ISO 8601 date, in which case the
465/// caller should keep the default (today).
466fn extract_legacy_date(content: &str) -> Option<Date> {
467    for line in content.lines() {
468        if line.starts_with("## ") {
469            break;
470        }
471        if let Some(rest) = line.trim().strip_prefix("Date:") {
472            return Date::parse(rest.trim(), &Iso8601::DATE).ok();
473        }
474    }
475    None
476}
477
478/// Parse a numbered title like "1. Use Rust" into (1, "Use Rust").
479fn parse_numbered_title(title: &str) -> Option<(u32, String)> {
480    let parts: Vec<&str> = title.splitn(2, ". ").collect();
481    if parts.len() == 2
482        && let Ok(num) = parts[0].parse::<u32>()
483    {
484        return Some((num, parts[1].to_string()));
485    }
486    None
487}
488
489/// Extract ADR number from a file path.
490fn extract_number_from_path(path: &Path) -> Result<u32> {
491    let filename =
492        path.file_name()
493            .and_then(|n| n.to_str())
494            .ok_or_else(|| Error::InvalidFormat {
495                path: path.to_path_buf(),
496                reason: "Invalid filename".into(),
497            })?;
498
499    NUMBER_REGEX
500        .captures(filename)
501        .and_then(|caps| caps.get(1))
502        .and_then(|m| m.as_str().parse().ok())
503        .ok_or_else(|| Error::InvalidFormat {
504            path: path.to_path_buf(),
505            reason: "Cannot extract ADR number from filename".into(),
506        })
507}
508
509/// Get today's date.
510pub fn today() -> Date {
511    let now = OffsetDateTime::now_utc();
512    Date::from_calendar_date(now.year(), now.month(), now.day()).unwrap_or_else(|_| {
513        // Fallback to a safe default
514        Date::from_calendar_date(2024, Month::January, 1).unwrap()
515    })
516}
517
518/// Format a date as YYYY-MM-DD.
519pub fn format_date(date: Date) -> String {
520    format!(
521        "{:04}-{:02}-{:02}",
522        date.year(),
523        date.month() as u8,
524        date.day()
525    )
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use tempfile::TempDir;
532    use test_case::test_case;
533
534    // ========== Parser Creation ==========
535
536    #[test]
537    fn test_parser_new() {
538        let _parser = Parser::new();
539        // Parser creation succeeds - just confirms it compiles
540    }
541
542    #[test]
543    fn test_parser_default() {
544        let _parser = Parser::default();
545    }
546
547    // ========== Legacy Format Parsing ==========
548
549    #[test]
550    fn test_parse_legacy_format() {
551        let content = r#"# 1. Use Rust
552
553## Status
554
555Accepted
556
557## Context
558
559We need a systems programming language.
560
561## Decision
562
563We will use Rust.
564
565## Consequences
566
567We get memory safety without garbage collection.
568"#;
569
570        let parser = Parser::new();
571        let adr = parser.parse(content).unwrap();
572
573        assert_eq!(adr.number, 1);
574        assert_eq!(adr.title, "Use Rust");
575        assert_eq!(adr.status, AdrStatus::Accepted);
576        assert!(adr.context.contains("systems programming"));
577        assert!(adr.decision.contains("use Rust"));
578        assert!(adr.consequences.contains("memory safety"));
579    }
580
581    #[test]
582    fn test_parse_legacy_minimal() {
583        let content = r#"# 1. Minimal ADR
584
585## Status
586
587Proposed
588
589## Context
590
591Context.
592
593## Decision
594
595Decision.
596
597## Consequences
598
599Consequences.
600"#;
601
602        let parser = Parser::new();
603        let adr = parser.parse(content).unwrap();
604
605        assert_eq!(adr.number, 1);
606        assert_eq!(adr.title, "Minimal ADR");
607        assert_eq!(adr.status, AdrStatus::Proposed);
608        assert_eq!(adr.context, "Context.");
609        assert_eq!(adr.decision, "Decision.");
610        assert_eq!(adr.consequences, "Consequences.");
611    }
612
613    #[test]
614    fn test_parse_legacy_multiline_sections() {
615        let content = r#"# 1. Multiline Test
616
617## Status
618
619Accepted
620
621## Context
622
623This is a context section
624that spans multiple lines.
625
626With paragraphs too.
627
628## Decision
629
630This is the decision.
631Also multiple lines.
632
633## Consequences
634
635- Point 1
636- Point 2
637- Point 3
638"#;
639
640        let parser = Parser::new();
641        let adr = parser.parse(content).unwrap();
642
643        assert!(adr.context.contains("multiple lines"));
644        assert!(adr.context.contains("paragraphs"));
645        assert!(adr.decision.contains("Also multiple lines"));
646        assert!(adr.consequences.contains("Point 1"));
647        assert!(adr.consequences.contains("Point 2"));
648    }
649
650    #[test_case("Proposed" => AdrStatus::Proposed; "proposed")]
651    #[test_case("Accepted" => AdrStatus::Accepted; "accepted")]
652    #[test_case("Deprecated" => AdrStatus::Deprecated; "deprecated")]
653    #[test_case("Superseded" => AdrStatus::Superseded; "superseded")]
654    #[test_case("Draft" => AdrStatus::Custom("Draft".into()); "draft")]
655    #[test_case("Rejected" => AdrStatus::Custom("Rejected".into()); "rejected")]
656    fn test_parse_legacy_status_types(status: &str) -> AdrStatus {
657        let content = format!(
658            r#"# 1. Test
659
660## Status
661
662{status}
663
664## Context
665
666Context.
667
668## Decision
669
670Decision.
671
672## Consequences
673
674Consequences.
675"#
676        );
677
678        let parser = Parser::new();
679        let adr = parser.parse(&content).unwrap();
680        adr.status
681    }
682
683    #[test]
684    fn test_parse_legacy_with_date_line() {
685        let content = r#"# 1. Record architecture decisions
686
687Date: 2024-01-15
688
689## Status
690
691Accepted
692
693## Context
694
695Context.
696
697## Decision
698
699Decision.
700
701## Consequences
702
703Consequences.
704"#;
705
706        let parser = Parser::new();
707        let adr = parser.parse(content).unwrap();
708
709        assert_eq!(adr.number, 1);
710        assert_eq!(adr.title, "Record architecture decisions");
711        assert_eq!(adr.status, AdrStatus::Accepted);
712    }
713
714    #[test]
715    fn test_parse_legacy_title_without_number() {
716        let content = r#"# Use Rust
717
718## Status
719
720Proposed
721
722## Context
723
724Context.
725
726## Decision
727
728Decision.
729
730## Consequences
731
732Consequences.
733"#;
734
735        let parser = Parser::new();
736        let adr = parser.parse(content).unwrap();
737
738        assert_eq!(adr.number, 0);
739        assert_eq!(adr.title, "Use Rust");
740    }
741
742    #[test]
743    fn test_parse_legacy_status_with_links() {
744        let content = r#"# 2. Use PostgreSQL
745
746## Status
747
748Accepted
749
750Supersedes [1. Use MySQL](0001-use-mysql.md)
751
752## Context
753
754Context.
755
756## Decision
757
758Decision.
759
760## Consequences
761
762Consequences.
763"#;
764
765        let parser = Parser::new();
766        let adr = parser.parse(content).unwrap();
767
768        assert_eq!(adr.status, AdrStatus::Accepted);
769        assert_eq!(adr.links.len(), 1);
770        assert_eq!(adr.links[0].target, 1);
771        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
772    }
773
774    #[test]
775    fn test_parse_legacy_multiple_links() {
776        let content = r#"# 5. Combined Decision
777
778## Status
779
780Accepted
781
782Supersedes [1. First](0001-first.md)
783Supersedes [2. Second](0002-second.md)
784Amends [3. Third](0003-third.md)
785
786## Context
787
788Context.
789
790## Decision
791
792Decision.
793
794## Consequences
795
796Consequences.
797"#;
798
799        let parser = Parser::new();
800        let adr = parser.parse(content).unwrap();
801
802        assert_eq!(adr.links.len(), 3);
803        assert_eq!(adr.links[0].target, 1);
804        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
805        assert_eq!(adr.links[1].target, 2);
806        assert_eq!(adr.links[1].kind, LinkKind::Supersedes);
807        assert_eq!(adr.links[2].target, 3);
808        assert_eq!(adr.links[2].kind, LinkKind::Amends);
809    }
810
811    #[test]
812    fn test_parse_superseded_status() {
813        let content = r#"# 1. Record architecture decisions
814
815Date: 2026-01-22
816
817## Status
818
819Superseded
820
821Superseded by [2. ...](0002-....md)
822
823## Context
824
825Some context.
826
827## Decision
828
829Some decision.
830
831## Consequences
832
833Some consequences.
834"#;
835
836        let parser = Parser::new();
837        let adr = parser.parse(content).unwrap();
838
839        assert_eq!(adr.number, 1);
840        assert_eq!(adr.status, AdrStatus::Superseded);
841    }
842
843    // ========== Frontmatter Format Parsing ==========
844
845    #[test]
846    fn test_parse_frontmatter_format() {
847        let content = r#"---
848number: 2
849title: Use PostgreSQL
850date: 2024-01-15
851status: accepted
852links:
853  - target: 1
854    kind: supersedes
855---
856
857## Context
858
859We need a database.
860
861## Decision
862
863We will use PostgreSQL.
864
865## Consequences
866
867We get ACID compliance.
868"#;
869
870        let parser = Parser::new();
871        let adr = parser.parse(content).unwrap();
872
873        assert_eq!(adr.number, 2);
874        assert_eq!(adr.title, "Use PostgreSQL");
875        assert_eq!(adr.status, AdrStatus::Accepted);
876        assert_eq!(adr.links.len(), 1);
877        assert_eq!(adr.links[0].target, 1);
878        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
879    }
880
881    #[test]
882    fn test_parse_frontmatter_minimal() {
883        let content = r#"---
884number: 1
885title: Simple ADR
886date: 2024-01-01
887status: proposed
888---
889
890## Context
891
892Context.
893
894## Decision
895
896Decision.
897
898## Consequences
899
900Consequences.
901"#;
902
903        let parser = Parser::new();
904        let adr = parser.parse(content).unwrap();
905
906        assert_eq!(adr.number, 1);
907        assert_eq!(adr.title, "Simple ADR");
908        assert_eq!(adr.status, AdrStatus::Proposed);
909    }
910
911    #[test]
912    fn test_parse_frontmatter_no_links() {
913        let content = r#"---
914number: 1
915title: Test ADR
916date: 2024-01-01
917status: accepted
918---
919
920## Context
921
922Context.
923
924## Decision
925
926Decision.
927
928## Consequences
929
930Consequences.
931"#;
932
933        let parser = Parser::new();
934        let adr = parser.parse(content).unwrap();
935
936        assert!(adr.links.is_empty());
937    }
938
939    #[test]
940    fn test_parse_frontmatter_multiple_links() {
941        let content = r#"---
942number: 5
943title: Multi Link ADR
944date: 2024-01-01
945status: accepted
946links:
947  - target: 1
948    kind: supersedes
949  - target: 2
950    kind: amends
951  - target: 3
952    kind: relatesto
953---
954
955## Context
956
957Context.
958
959## Decision
960
961Decision.
962
963## Consequences
964
965Consequences.
966"#;
967
968        let parser = Parser::new();
969        let adr = parser.parse(content).unwrap();
970
971        assert_eq!(adr.links.len(), 3);
972        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
973        assert_eq!(adr.links[1].kind, LinkKind::Amends);
974        assert_eq!(adr.links[2].kind, LinkKind::RelatesTo);
975    }
976
977    #[test]
978    fn test_parse_frontmatter_all_statuses() {
979        for (status_str, expected) in [
980            ("proposed", AdrStatus::Proposed),
981            ("accepted", AdrStatus::Accepted),
982            ("deprecated", AdrStatus::Deprecated),
983            ("superseded", AdrStatus::Superseded),
984        ] {
985            let content = format!(
986                r#"---
987number: 1
988title: Test
989date: 2024-01-01
990status: {status_str}
991---
992
993## Context
994
995Context.
996"#
997            );
998
999            let parser = Parser::new();
1000            let adr = parser.parse(&content).unwrap();
1001            assert_eq!(adr.status, expected, "Failed for status: {status_str}");
1002        }
1003    }
1004
1005    #[test]
1006    fn test_parse_frontmatter_invalid_format() {
1007        let content = r#"---
1008not valid yaml {{{{
1009---
1010
1011## Context
1012
1013Context.
1014"#;
1015
1016        let parser = Parser::new();
1017        let result = parser.parse(content);
1018        assert!(result.is_err());
1019    }
1020
1021    #[test]
1022    fn test_parse_frontmatter_incomplete() {
1023        let content = r#"---
1024number: 1
1025title: Test
1026"#;
1027
1028        let parser = Parser::new();
1029        let result = parser.parse(content);
1030        assert!(result.is_err());
1031    }
1032
1033    // ========== MADR Format Parsing ==========
1034
1035    #[test]
1036    fn test_parse_madr_format() {
1037        // MADR format with number and title in frontmatter
1038        let content = r#"---
1039number: 2
1040title: Use Redis for caching
1041status: proposed
1042date: 2024-01-15
1043---
1044
1045# Use Redis for caching
1046
1047## Context and Problem Statement
1048
1049We need a caching solution.
1050
1051## Decision Outcome
1052
1053We will use Redis.
1054
1055### Consequences
1056
1057* Good, because fast
1058"#;
1059
1060        let parser = Parser::new();
1061        let adr = parser.parse(content).unwrap();
1062
1063        assert_eq!(adr.number, 2);
1064        assert_eq!(adr.title, "Use Redis for caching");
1065        assert_eq!(adr.status, AdrStatus::Proposed);
1066        assert!(adr.context.contains("caching solution"));
1067        assert!(adr.decision.contains("use Redis"));
1068    }
1069
1070    #[test]
1071    fn test_parse_madr_with_decision_makers() {
1072        let content = r#"---
1073number: 1
1074title: Use MADR Format
1075status: accepted
1076date: 2024-01-01
1077---
1078
1079# Use MADR Format
1080
1081## Context and Problem Statement
1082
1083Context.
1084"#;
1085
1086        let parser = Parser::new();
1087        let adr = parser.parse(content).unwrap();
1088
1089        assert_eq!(adr.number, 1);
1090        assert_eq!(adr.title, "Use MADR Format");
1091        assert_eq!(adr.status, AdrStatus::Accepted);
1092        assert_eq!(adr.context, "Context.");
1093    }
1094
1095    #[test]
1096    fn test_parse_madr_frontmatter_populates_body_sections() {
1097        let content = r#"---
1098number: 1
1099title: Use MADR Format
1100date: 2024-09-15
1101status: accepted
1102---
1103
1104## Context and Problem Statement
1105
1106We need a standard format for ADRs.
1107
1108## Decision Outcome
1109
1110Chosen option: "MADR 4.0.0", because it provides rich metadata.
1111"#;
1112
1113        let parser = Parser::new();
1114        let adr = parser.parse(content).unwrap();
1115
1116        assert!(adr.context.contains("standard format"));
1117        assert!(adr.decision.contains("MADR 4.0.0"));
1118    }
1119
1120    // ========== List-item separators in parsed sections (#346) ==========
1121
1122    #[test]
1123    fn test_parse_sections_bullet_items_keep_separator() {
1124        // Consecutive bullet items in a top-level `## Consequences` section must
1125        // not concatenate (pulldown-cmark emits no SoftBreak between siblings).
1126        // Mirrors corpus fixtures 0004 and 0007.
1127        let content = r#"---
1128number: 4
1129title: Use MADR format for ADRs
1130status: accepted
1131date: 2024-02-15
1132---
1133
1134## Consequences
1135
1136- Better tooling support with structured metadata
1137- Clear tracking of decision-makers and stakeholders
1138"#;
1139
1140        let parser = Parser::new();
1141        let adr = parser.parse(content).unwrap();
1142
1143        assert!(
1144            !adr.consequences.contains("metadataClear"),
1145            "list items concatenated without a separator:\n{}",
1146            adr.consequences
1147        );
1148        assert_eq!(
1149            adr.consequences,
1150            "- Better tooling support with structured metadata\n\
1151             - Clear tracking of decision-makers and stakeholders"
1152        );
1153    }
1154
1155    #[test]
1156    fn test_parse_sections_h3_consequences_bullet_items_keep_separator() {
1157        // The `### Consequences` under `## Decision Outcome` buffer needs the
1158        // same separator handling as top-level sections.
1159        let content = r#"---
1160number: 2
1161title: Use Redis for caching
1162status: proposed
1163date: 2024-01-15
1164---
1165
1166## Decision Outcome
1167
1168Chosen option: "Redis".
1169
1170### Consequences
1171
1172- Stateless authentication reduces database load
1173- Refresh token rotation improves security
1174"#;
1175
1176        let parser = Parser::new();
1177        let adr = parser.parse(content).unwrap();
1178
1179        assert!(
1180            !adr.consequences.contains("loadRefresh"),
1181            "H3 list items concatenated without a separator:\n{}",
1182            adr.consequences
1183        );
1184        assert_eq!(
1185            adr.consequences,
1186            "- Stateless authentication reduces database load\n\
1187             - Refresh token rotation improves security"
1188        );
1189    }
1190
1191    #[test]
1192    fn test_parse_sections_ordered_list_reemits_numbers() {
1193        // Ordered lists re-emit their numeric markers rather than a bullet.
1194        let content = r#"---
1195number: 5
1196title: Ordered steps
1197status: accepted
1198date: 2024-02-15
1199---
1200
1201## Context
1202
12031. First step
12042. Second step
12053. Third step
1206"#;
1207
1208        let parser = Parser::new();
1209        let adr = parser.parse(content).unwrap();
1210
1211        assert_eq!(adr.context, "1. First step\n2. Second step\n3. Third step");
1212    }
1213
1214    // ========== MADR H3 Consequences Read Round-Trip (#338) ==========
1215
1216    #[test]
1217    fn test_parse_madr_h3_consequences_excluded_from_decision() {
1218        // `### Consequences` under `## Decision Outcome` must populate
1219        // `adr.consequences`, not get folded into `adr.decision`.
1220        let content = r#"---
1221number: 2
1222title: Use Redis for caching
1223date: 2024-01-15
1224status: proposed
1225---
1226
1227## Context and Problem Statement
1228
1229We need a caching solution.
1230
1231## Decision Outcome
1232
1233Chosen option: "Redis", because it is fast.
1234
1235### Consequences
1236
1237* Good, because it reduces database load
1238* Bad, because it adds operational complexity
1239"#;
1240
1241        let parser = Parser::new();
1242        let adr = parser.parse(content).unwrap();
1243
1244        assert_eq!(
1245            adr.decision,
1246            "Chosen option: \"Redis\", because it is fast."
1247        );
1248        assert!(
1249            !adr.decision.contains("Good, because it reduces"),
1250            "consequences text leaked into decision:\n{}",
1251            adr.decision
1252        );
1253        assert!(
1254            !adr.decision.to_lowercase().contains("consequences"),
1255            "Consequences heading text leaked into decision:\n{}",
1256            adr.decision
1257        );
1258        assert!(
1259            adr.consequences
1260                .contains("Good, because it reduces database load")
1261        );
1262        assert!(
1263            adr.consequences
1264                .contains("Bad, because it adds operational complexity")
1265        );
1266    }
1267
1268    #[test]
1269    fn test_parse_madr_h3_confirmation_stays_in_decision() {
1270        // A non-Consequences H3 (e.g. `### Confirmation`) is not diverted: it
1271        // keeps folding into `decision`, both before and after a real `###
1272        // Consequences` subsection.
1273        let content = r#"---
1274number: 2
1275title: Use Redis for caching
1276date: 2024-01-15
1277status: proposed
1278---
1279
1280## Decision Outcome
1281
1282Chosen option: "Redis", because it is fast.
1283
1284### Consequences
1285
1286* Good, because it reduces database load
1287
1288### Confirmation
1289
1290We will confirm via load tests.
1291"#;
1292
1293        let parser = Parser::new();
1294        let adr = parser.parse(content).unwrap();
1295
1296        assert!(adr.decision.contains("Chosen option: \"Redis\""));
1297        assert!(
1298            adr.decision.contains("We will confirm via load tests."),
1299            "Confirmation subsection should stay part of decision:\n{}",
1300            adr.decision
1301        );
1302        assert!(
1303            !adr.decision.contains("Good, because it reduces"),
1304            "consequences text must not appear in decision:\n{}",
1305            adr.decision
1306        );
1307        // The single list item round-trips with its re-emitted bullet marker
1308        // (#346).
1309        assert_eq!(adr.consequences, "- Good, because it reduces database load");
1310    }
1311
1312    #[test]
1313    fn test_parse_nygard_consequences_h2_and_no_h3_still_works() {
1314        // Control case: a Nygard-style top-level `## Consequences` H2 with no
1315        // H3 anywhere must parse exactly as before the #338 fix.
1316        let content = r#"---
1317number: 2
1318title: Use PostgreSQL
1319date: 2024-01-15
1320status: accepted
1321---
1322
1323## Context
1324
1325We need a database.
1326
1327## Decision
1328
1329We will use PostgreSQL.
1330
1331## Consequences
1332
1333We get ACID compliance.
1334"#;
1335
1336        let parser = Parser::new();
1337        let adr = parser.parse(content).unwrap();
1338
1339        assert_eq!(adr.decision, "We will use PostgreSQL.");
1340        assert_eq!(adr.consequences, "We get ACID compliance.");
1341    }
1342
1343    #[test]
1344    fn test_parse_legacy_madr_h3_consequences_excluded_from_decision() {
1345        // Same exception, no-frontmatter path: MADR's bare/minimal templates
1346        // have no YAML frontmatter, so this goes through `parse_legacy` /
1347        // `extract_sections_raw` rather than `parse_sections`.
1348        let content = r#"# Use Redis for caching
1349
1350## Context and Problem Statement
1351
1352We need a caching solution.
1353
1354## Decision Outcome
1355
1356Chosen option: "Redis", because it is fast.
1357
1358### Consequences
1359
1360* Good, because it reduces database load
1361
1362### Confirmation
1363
1364We will confirm via load tests.
1365"#;
1366
1367        let parser = Parser::new();
1368        let adr = parser.parse(content).unwrap();
1369
1370        assert!(
1371            adr.decision
1372                .contains("Chosen option: \"Redis\", because it is fast.")
1373        );
1374        // The legacy path folds non-Consequences H3 lines verbatim (including
1375        // the `### ` marker), matching its pre-existing behavior for headings
1376        // below H2.
1377        assert!(
1378            adr.decision.contains("### Confirmation"),
1379            "Confirmation subsection should stay part of decision:\n{}",
1380            adr.decision
1381        );
1382        assert!(adr.decision.contains("We will confirm via load tests."));
1383        assert!(
1384            !adr.decision.contains("Good, because it reduces"),
1385            "consequences text must not leak into legacy-path decision:\n{}",
1386            adr.decision
1387        );
1388        assert!(
1389            adr.consequences
1390                .contains("Good, because it reduces database load")
1391        );
1392    }
1393
1394    #[test]
1395    fn test_parse_madr_missing_number_fails() {
1396        // MADR without number field should fail
1397        let content = r#"---
1398title: Missing Number
1399status: proposed
1400date: 2024-01-01
1401---
1402
1403# Missing Number
1404
1405## Context and Problem Statement
1406
1407Context.
1408"#;
1409
1410        let parser = Parser::new();
1411        let result = parser.parse(content);
1412        // Should fail because number is required
1413        assert!(result.is_err() || result.unwrap().number == 0);
1414    }
1415
1416    // ========== File Parsing ==========
1417
1418    #[test]
1419    fn test_parse_file_legacy() {
1420        let temp = TempDir::new().unwrap();
1421        let file_path = temp.path().join("0001-use-rust.md");
1422
1423        std::fs::write(
1424            &file_path,
1425            r#"# 1. Use Rust
1426
1427## Status
1428
1429Accepted
1430
1431## Context
1432
1433Context.
1434
1435## Decision
1436
1437Decision.
1438
1439## Consequences
1440
1441Consequences.
1442"#,
1443        )
1444        .unwrap();
1445
1446        let parser = Parser::new();
1447        let adr = parser.parse_file(&file_path).unwrap();
1448
1449        assert_eq!(adr.number, 1);
1450        assert_eq!(adr.title, "Use Rust");
1451        assert_eq!(adr.path, Some(file_path));
1452    }
1453
1454    #[test]
1455    fn test_parse_file_extracts_number_from_filename() {
1456        let temp = TempDir::new().unwrap();
1457        let file_path = temp.path().join("0042-some-decision.md");
1458
1459        // ADR without number in title
1460        std::fs::write(
1461            &file_path,
1462            r#"# Some Decision
1463
1464## Status
1465
1466Proposed
1467
1468## Context
1469
1470Context.
1471
1472## Decision
1473
1474Decision.
1475
1476## Consequences
1477
1478Consequences.
1479"#,
1480        )
1481        .unwrap();
1482
1483        let parser = Parser::new();
1484        let adr = parser.parse_file(&file_path).unwrap();
1485
1486        assert_eq!(adr.number, 42);
1487    }
1488
1489    #[test]
1490    fn test_parse_file_nonexistent() {
1491        let parser = Parser::new();
1492        let result = parser.parse_file(Path::new("/nonexistent/path/0001-test.md"));
1493        assert!(result.is_err());
1494    }
1495
1496    // ========== Helper Function Tests ==========
1497
1498    #[test]
1499    fn test_parse_numbered_title() {
1500        assert_eq!(
1501            parse_numbered_title("1. Use Rust"),
1502            Some((1, "Use Rust".into()))
1503        );
1504        assert_eq!(
1505            parse_numbered_title("42. Complex Decision"),
1506            Some((42, "Complex Decision".into()))
1507        );
1508        assert_eq!(parse_numbered_title("Use Rust"), None);
1509    }
1510
1511    #[test_case("1. Simple" => Some((1, "Simple".into())); "simple")]
1512    #[test_case("123. Large Number" => Some((123, "Large Number".into())); "large number")]
1513    #[test_case("1. With. Dots. In. Title" => Some((1, "With. Dots. In. Title".into())); "dots in title")]
1514    #[test_case("No Number" => None; "no number")]
1515    #[test_case("1 Missing Period" => None; "missing period")]
1516    #[test_case(". Missing Number" => None; "missing number")]
1517    fn test_parse_numbered_title_cases(input: &str) -> Option<(u32, String)> {
1518        parse_numbered_title(input)
1519    }
1520
1521    #[test]
1522    fn test_extract_number_from_path() {
1523        let path = Path::new("doc/adr/0001-use-rust.md");
1524        assert_eq!(extract_number_from_path(path).unwrap(), 1);
1525
1526        let path = Path::new("0042-complex-decision.md");
1527        assert_eq!(extract_number_from_path(path).unwrap(), 42);
1528
1529        let path = Path::new("9999-max-four-digit.md");
1530        assert_eq!(extract_number_from_path(path).unwrap(), 9999);
1531    }
1532
1533    #[test]
1534    fn test_extract_number_from_path_invalid() {
1535        let result = extract_number_from_path(Path::new("not-an-adr.md"));
1536        assert!(result.is_err());
1537
1538        let result = extract_number_from_path(Path::new("1-too-few-digits.md"));
1539        assert!(result.is_err());
1540    }
1541
1542    #[test]
1543    fn test_today() {
1544        let date = today();
1545        assert!(date.year() >= 2024);
1546        assert!(date.month() as u8 >= 1 && date.month() as u8 <= 12);
1547        assert!(date.day() >= 1 && date.day() <= 31);
1548    }
1549
1550    #[test]
1551    fn test_format_date() {
1552        let date = Date::from_calendar_date(2024, Month::March, 5).unwrap();
1553        assert_eq!(format_date(date), "2024-03-05");
1554    }
1555
1556    #[test_case(2024, Month::January, 1 => "2024-01-01"; "new year")]
1557    #[test_case(2024, Month::December, 31 => "2024-12-31"; "end of year")]
1558    #[test_case(2000, Month::February, 29 => "2000-02-29"; "leap day")]
1559    #[test_case(2024, Month::July, 15 => "2024-07-15"; "mid year")]
1560    fn test_format_date_cases(year: i32, month: Month, day: u8) -> String {
1561        let date = Date::from_calendar_date(year, month, day).unwrap();
1562        format_date(date)
1563    }
1564
1565    // ========== Edge Cases ==========
1566
1567    #[test]
1568    fn test_parse_empty_content() {
1569        let parser = Parser::new();
1570        let adr = parser.parse("").unwrap();
1571
1572        assert_eq!(adr.number, 0);
1573        assert!(adr.title.is_empty());
1574    }
1575
1576    #[test]
1577    fn test_parse_only_title() {
1578        let content = "# 1. Just a Title";
1579
1580        let parser = Parser::new();
1581        let adr = parser.parse(content).unwrap();
1582
1583        assert_eq!(adr.number, 1);
1584        assert_eq!(adr.title, "Just a Title");
1585    }
1586
1587    #[test]
1588    fn test_parse_extra_sections_ignored() {
1589        let content = r#"# 1. Test
1590
1591## Status
1592
1593Proposed
1594
1595## Context
1596
1597Context.
1598
1599## Decision
1600
1601Decision.
1602
1603## Consequences
1604
1605Consequences.
1606
1607## Notes
1608
1609These should be ignored.
1610
1611## References
1612
1613- ref1
1614- ref2
1615"#;
1616
1617        let parser = Parser::new();
1618        let adr = parser.parse(content).unwrap();
1619
1620        // Extra sections are ignored, main content is still parsed
1621        assert_eq!(adr.number, 1);
1622        assert_eq!(adr.status, AdrStatus::Proposed);
1623    }
1624
1625    #[test]
1626    fn test_parse_case_insensitive_sections() {
1627        let content = r#"# 1. Case Test
1628
1629## STATUS
1630
1631Accepted
1632
1633## CONTEXT
1634
1635Context.
1636
1637## DECISION
1638
1639Decision.
1640
1641## CONSEQUENCES
1642
1643Consequences.
1644"#;
1645
1646        let parser = Parser::new();
1647        let adr = parser.parse(content).unwrap();
1648
1649        // Sections should be matched case-insensitively
1650        assert_eq!(adr.status, AdrStatus::Accepted);
1651        assert_eq!(adr.context, "Context.");
1652    }
1653
1654    #[test]
1655    fn test_parse_content_with_markdown_formatting() {
1656        let content = r#"# 1. Formatted ADR
1657
1658## Status
1659
1660Accepted
1661
1662## Context
1663
1664We have **bold** and *italic* text.
1665
1666Also `code` and [links](https://example.com).
1667
1668## Decision
1669
1670```rust
1671fn main() {
1672    println!("Hello");
1673}
1674```
1675
1676## Consequences
1677
1678| Column 1 | Column 2 |
1679|----------|----------|
1680| Value 1  | Value 2  |
1681"#;
1682
1683        let parser = Parser::new();
1684        let adr = parser.parse(content).unwrap();
1685
1686        assert!(adr.context.contains("bold"));
1687        assert!(adr.decision.contains("fn main"));
1688        assert!(adr.consequences.contains("Column 1"));
1689    }
1690
1691    // ========== Regex Tests ==========
1692
1693    #[test]
1694    fn test_link_regex_pattern() {
1695        let content = "Supersedes [1. Use MySQL](0001-use-mysql.md)";
1696        let caps = LINK_REGEX.captures(content).unwrap();
1697
1698        assert_eq!(caps.get(1).unwrap().as_str(), "Supersedes");
1699        assert_eq!(caps.get(2).unwrap().as_str(), "1");
1700        assert_eq!(caps.get(3).unwrap().as_str(), "0001");
1701    }
1702
1703    #[test]
1704    fn test_link_regex_amended_by() {
1705        let content = "Amended by [3. Update API](0003-update-api.md)";
1706        let caps = LINK_REGEX.captures(content).unwrap();
1707
1708        assert_eq!(caps.get(1).unwrap().as_str(), "Amended by");
1709        assert_eq!(caps.get(2).unwrap().as_str(), "3");
1710    }
1711
1712    #[test]
1713    fn test_number_regex_pattern() {
1714        let filename = "0042-some-decision.md";
1715        let caps = NUMBER_REGEX.captures(filename).unwrap();
1716
1717        assert_eq!(caps.get(1).unwrap().as_str(), "0042");
1718    }
1719
1720    #[test]
1721    fn test_number_regex_no_match() {
1722        assert!(NUMBER_REGEX.captures("not-an-adr.md").is_none());
1723        assert!(NUMBER_REGEX.captures("01-short.md").is_none());
1724        assert!(NUMBER_REGEX.captures("00001-too-long.md").is_none());
1725    }
1726
1727    // ========== MADR 4.0.0 Frontmatter Tests ==========
1728
1729    #[test]
1730    fn test_parse_madr_frontmatter() {
1731        let content = r#"---
1732number: 1
1733title: Use MADR Format
1734date: 2024-09-15
1735status: accepted
1736decision-makers:
1737  - Alice
1738  - Bob
1739consulted:
1740  - Carol
1741informed:
1742  - Dave
1743  - Eve
1744---
1745
1746## Context and Problem Statement
1747
1748We need a standard format for ADRs.
1749
1750## Decision Outcome
1751
1752Chosen option: "MADR 4.0.0", because it provides rich metadata.
1753"#;
1754
1755        let parser = Parser::new();
1756        let adr = parser.parse(content).unwrap();
1757
1758        assert_eq!(adr.number, 1);
1759        assert_eq!(adr.title, "Use MADR Format");
1760        assert_eq!(adr.status, AdrStatus::Accepted);
1761        assert_eq!(adr.decision_makers, vec!["Alice", "Bob"]);
1762        assert_eq!(adr.consulted, vec!["Carol"]);
1763        assert_eq!(adr.informed, vec!["Dave", "Eve"]);
1764    }
1765
1766    #[test]
1767    fn test_parse_madr_frontmatter_partial_fields() {
1768        let content = r#"---
1769number: 2
1770title: Partial MADR
1771date: 2024-09-15
1772status: proposed
1773decision-makers:
1774  - Alice
1775---
1776
1777## Context
1778
1779Context.
1780"#;
1781
1782        let parser = Parser::new();
1783        let adr = parser.parse(content).unwrap();
1784
1785        assert_eq!(adr.decision_makers, vec!["Alice"]);
1786        assert!(adr.consulted.is_empty());
1787        assert!(adr.informed.is_empty());
1788    }
1789
1790    #[test]
1791    fn test_parse_madr_frontmatter_empty_fields() {
1792        let content = r#"---
1793number: 3
1794title: No MADR Fields
1795date: 2024-09-15
1796status: accepted
1797---
1798
1799## Context
1800
1801Context.
1802"#;
1803
1804        let parser = Parser::new();
1805        let adr = parser.parse(content).unwrap();
1806
1807        assert!(adr.decision_makers.is_empty());
1808        assert!(adr.consulted.is_empty());
1809        assert!(adr.informed.is_empty());
1810    }
1811
1812    #[test]
1813    fn test_parse_madr_with_links() {
1814        let content = r#"---
1815number: 4
1816title: MADR With Links
1817date: 2024-09-15
1818status: accepted
1819decision-makers:
1820  - Alice
1821links:
1822  - target: 1
1823    kind: supersedes
1824  - target: 2
1825    kind: amends
1826---
1827
1828## Context
1829
1830Context.
1831"#;
1832
1833        let parser = Parser::new();
1834        let adr = parser.parse(content).unwrap();
1835
1836        assert_eq!(adr.decision_makers, vec!["Alice"]);
1837        assert_eq!(adr.links.len(), 2);
1838        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
1839        assert_eq!(adr.links[1].kind, LinkKind::Amends);
1840    }
1841
1842    // ========== Frontmatter Title Fallback (#186) ==========
1843
1844    #[test]
1845    fn test_parse_frontmatter_title_from_body_h1() {
1846        let content = r#"---
1847number: 2
1848date: 2024-01-15
1849status: proposed
1850---
1851
1852# My Decision Title
1853
1854## Context
1855
1856Context.
1857
1858## Decision
1859
1860Decision.
1861
1862## Consequences
1863
1864Consequences.
1865"#;
1866
1867        let parser = Parser::new();
1868        let adr = parser.parse(content).unwrap();
1869
1870        assert_eq!(adr.number, 2);
1871        assert_eq!(adr.title, "My Decision Title");
1872        assert_eq!(adr.status, AdrStatus::Proposed);
1873    }
1874
1875    #[test]
1876    fn test_parse_frontmatter_title_from_body_h1_numbered() {
1877        let content = r#"---
1878number: 2
1879date: 2024-01-15
1880status: proposed
1881---
1882
1883# 2. My Numbered Title
1884
1885## Context
1886
1887Context.
1888"#;
1889
1890        let parser = Parser::new();
1891        let adr = parser.parse(content).unwrap();
1892
1893        assert_eq!(adr.number, 2);
1894        assert_eq!(adr.title, "My Numbered Title");
1895    }
1896
1897    #[test]
1898    fn test_parse_frontmatter_title_prefers_frontmatter() {
1899        let content = r#"---
1900number: 2
1901title: Frontmatter Title
1902date: 2024-01-15
1903status: proposed
1904---
1905
1906# Body Title
1907
1908## Context
1909
1910Context.
1911"#;
1912
1913        let parser = Parser::new();
1914        let adr = parser.parse(content).unwrap();
1915
1916        assert_eq!(adr.title, "Frontmatter Title");
1917    }
1918
1919    // ========== CRLF Line Endings (#326) ==========
1920
1921    #[test]
1922    fn test_parse_crlf_frontmatter_matches_lf() {
1923        let lf = r#"---
1924number: 4
1925title: Use MADR format for ADRs
1926date: 2024-02-15
1927status: accepted
1928decision-makers:
1929  - Alice Smith
1930  - Bob Jones
1931consulted:
1932  - Carol White
1933informed:
1934  - David Brown
1935  - Eve Green
1936---
1937
1938## Context
1939
1940We need a richer metadata format.
1941
1942## Decision
1943
1944We will use MADR.
1945
1946## Consequences
1947
1948More structured metadata.
1949"#;
1950        let crlf = lf.replace('\n', "\r\n");
1951
1952        let parser = Parser::new();
1953        let lf_adr = parser.parse(lf).unwrap();
1954        let crlf_adr = parser.parse(&crlf).unwrap();
1955
1956        assert_eq!(crlf_adr.status, AdrStatus::Accepted);
1957        assert_eq!(crlf_adr.status, lf_adr.status);
1958        assert_eq!(crlf_adr.date, lf_adr.date);
1959        assert_eq!(
1960            crlf_adr.decision_makers,
1961            vec!["Alice Smith".to_string(), "Bob Jones".to_string()]
1962        );
1963        assert_eq!(crlf_adr.decision_makers, lf_adr.decision_makers);
1964        assert_eq!(crlf_adr.consulted, lf_adr.consulted);
1965        assert_eq!(crlf_adr.informed, lf_adr.informed);
1966        assert_eq!(crlf_adr.context, lf_adr.context);
1967        assert_eq!(crlf_adr.decision, lf_adr.decision);
1968        assert_eq!(crlf_adr.consequences, lf_adr.consequences);
1969
1970        // No stray `\r` should leak into any parsed string field.
1971        assert!(!crlf_adr.title.contains('\r'));
1972        assert!(!crlf_adr.context.contains('\r'));
1973        assert!(!crlf_adr.decision.contains('\r'));
1974        assert!(!crlf_adr.consequences.contains('\r'));
1975        for person in crlf_adr
1976            .decision_makers
1977            .iter()
1978            .chain(crlf_adr.consulted.iter())
1979            .chain(crlf_adr.informed.iter())
1980        {
1981            assert!(!person.contains('\r'));
1982        }
1983    }
1984
1985    #[test]
1986    fn test_parse_crlf_legacy_format() {
1987        let lf = r#"# 1. Use Rust
1988
1989## Status
1990
1991Accepted
1992
1993## Context
1994
1995We need a systems programming language.
1996
1997## Decision
1998
1999We will use Rust.
2000
2001## Consequences
2002
2003We get memory safety without garbage collection.
2004"#;
2005        let crlf = lf.replace('\n', "\r\n");
2006
2007        let parser = Parser::new();
2008        let adr = parser.parse(&crlf).unwrap();
2009
2010        assert_eq!(adr.number, 1);
2011        assert_eq!(adr.title, "Use Rust");
2012        assert_eq!(adr.status, AdrStatus::Accepted);
2013        assert!(adr.context.contains("systems programming"));
2014        assert!(adr.decision.contains("use Rust"));
2015        assert!(adr.consequences.contains("memory safety"));
2016
2017        assert!(!adr.title.contains('\r'));
2018        assert!(!adr.context.contains('\r'));
2019        assert!(!adr.decision.contains('\r'));
2020        assert!(!adr.consequences.contains('\r'));
2021    }
2022
2023    // ========== Legacy Date Line (#324) ==========
2024
2025    #[test]
2026    fn test_parse_legacy_date_line_is_parsed() {
2027        let content = r#"# 1. Record architecture decisions
2028
2029Date: 2024-01-15
2030
2031## Status
2032
2033Accepted
2034
2035## Context
2036
2037Context.
2038
2039## Decision
2040
2041Decision.
2042
2043## Consequences
2044
2045Consequences.
2046"#;
2047
2048        let parser = Parser::new();
2049        let adr = parser.parse(content).unwrap();
2050
2051        assert_eq!(adr.date.to_string(), "2024-01-15");
2052    }
2053
2054    #[test]
2055    fn test_parse_legacy_no_date_line_falls_back_to_today() {
2056        let content = r#"# 1. Record architecture decisions
2057
2058## Status
2059
2060Accepted
2061
2062## Context
2063
2064Context.
2065
2066## Decision
2067
2068Decision.
2069
2070## Consequences
2071
2072Consequences.
2073"#;
2074
2075        let parser = Parser::new();
2076        let adr = parser.parse(content).unwrap();
2077
2078        assert_eq!(adr.date, today());
2079    }
2080
2081    #[test]
2082    fn test_parse_legacy_unparseable_date_line_falls_back_to_today() {
2083        let content = r#"# 1. Record architecture decisions
2084
2085Date: not-a-date
2086
2087## Status
2088
2089Accepted
2090
2091## Context
2092
2093Context.
2094
2095## Decision
2096
2097Decision.
2098
2099## Consequences
2100
2101Consequences.
2102"#;
2103
2104        let parser = Parser::new();
2105        let adr = parser.parse(content).unwrap();
2106
2107        assert_eq!(adr.date, today());
2108    }
2109}