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
26pub(crate) fn canonical_section_field(section: &str) -> Option<&'static str> {
27    match section.trim().to_lowercase().as_str() {
28        "context" | "context and problem statement" => Some("context"),
29        "decision" | "decision outcome" => Some("decision"),
30        "consequences" => Some("consequences"),
31        _ => None,
32    }
33}
34
35/// Parser for ADR files.
36#[derive(Debug, Default)]
37pub struct Parser {
38    _private: (),
39}
40
41impl Parser {
42    /// Create a new parser.
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Parse an ADR from a file.
48    pub fn parse_file(&self, path: &Path) -> Result<Adr> {
49        let content = std::fs::read_to_string(path)?;
50        let mut adr = self.parse(&content)?;
51
52        // Extract number from filename if not in frontmatter
53        if adr.number == 0 {
54            adr.number = extract_number_from_path(path)?;
55        }
56
57        adr.path = Some(path.to_path_buf());
58        Ok(adr)
59    }
60
61    /// Parse an ADR from a string.
62    pub fn parse(&self, content: &str) -> Result<Adr> {
63        // Normalize CRLF line endings for parsing only. This is read-side
64        // tolerance: the normalized copy is local to this call and is never
65        // written back anywhere, so on-disk files keep whatever line endings
66        // they already have.
67        let normalized;
68        let content: &str = if content.contains("\r\n") {
69            normalized = content.replace("\r\n", "\n");
70            &normalized
71        } else {
72            content
73        };
74
75        // Check for YAML frontmatter
76        if content.starts_with("---\n") {
77            self.parse_frontmatter(content)
78        } else {
79            self.parse_legacy(content)
80        }
81    }
82
83    /// Parse ADR with YAML frontmatter.
84    fn parse_frontmatter(&self, content: &str) -> Result<Adr> {
85        let parts: Vec<&str> = content.splitn(3, "---\n").collect();
86        if parts.len() < 3 {
87            return Err(Error::InvalidFormat {
88                path: Default::default(),
89                reason: "Invalid frontmatter format".into(),
90            });
91        }
92
93        let yaml = parts[1];
94        let body = parts[2];
95
96        // Parse frontmatter
97        let mut adr: Adr = serde_yaml_neo::from_str(yaml)?;
98
99        // If title is missing from frontmatter, try to extract from body H1
100        if adr.title.is_empty()
101            && let Some((num, title)) = extract_h1_title(body)
102        {
103            adr.title = title;
104            if adr.number == 0 {
105                adr.number = num;
106            }
107        }
108
109        // Parse body sections (Nygard/adr-tools and MADR 4.0.0 heading aliases)
110        let sections = self.parse_sections(body);
111        for (key, value) in &sections {
112            match canonical_section_field(key) {
113                Some("context") => adr.context = value.clone(),
114                Some("decision") => adr.decision = value.clone(),
115                Some("consequences") => adr.consequences = value.clone(),
116                _ => {}
117            }
118        }
119
120        Ok(adr)
121    }
122
123    /// Parse legacy markdown format (adr-tools compatible).
124    fn parse_legacy(&self, content: &str) -> Result<Adr> {
125        let mut adr = Adr::new(0, "");
126
127        // Use a simpler approach: split by H2 sections and parse each
128        let sections = self.extract_sections_raw(content);
129
130        // Parse H1 title
131        if let Some((num, title)) = extract_h1_title(content) {
132            adr.number = num;
133            adr.title = title;
134        }
135
136        // Parse the `Date:` line from the preamble (between the H1 and the
137        // first `## ` section), if present. `Adr::new` already defaulted
138        // `adr.date` to today, so an absent or unparseable line is a no-op.
139        if let Some(date) = extract_legacy_date(content) {
140            adr.date = date;
141        }
142
143        // Apply sections
144        for (name, content) in &sections {
145            self.apply_section(&mut adr, name, content);
146        }
147
148        Ok(adr)
149    }
150
151    /// Extract sections from raw markdown text.
152    fn extract_sections_raw(&self, content: &str) -> Vec<(String, String)> {
153        let mut sections = Vec::new();
154        let mut current_section: Option<String> = None;
155        let mut section_content = String::new();
156
157        for line in content.lines() {
158            if line.starts_with("## ") {
159                // Save previous section
160                if let Some(ref name) = current_section {
161                    sections.push((name.clone(), section_content.trim().to_string()));
162                }
163                current_section = Some(line.trim_start_matches("## ").trim().to_lowercase());
164                section_content.clear();
165            } else if current_section.is_some() {
166                section_content.push_str(line);
167                section_content.push('\n');
168            }
169        }
170
171        // Save final section
172        if let Some(ref name) = current_section {
173            sections.push((name.clone(), section_content.trim().to_string()));
174        }
175
176        sections
177    }
178
179    /// Apply a parsed section to the ADR.
180    fn apply_section(&self, adr: &mut Adr, section: &str, content: &str) {
181        let content = content.trim().to_string();
182        if section == "status" {
183            self.parse_status_section(adr, &content);
184            return;
185        }
186        match canonical_section_field(section) {
187            Some("context") => adr.context = content,
188            Some("decision") => adr.decision = content,
189            Some("consequences") => adr.consequences = content,
190            _ => {}
191        }
192    }
193
194    /// Parse the status section for status and links.
195    fn parse_status_section(&self, adr: &mut Adr, content: &str) {
196        for line in content.lines() {
197            let line = line.trim();
198            if line.is_empty() {
199                continue;
200            }
201
202            // Check for link pattern: "Supersedes [1. Title](0001-title.md)"
203            if let Some(caps) = LINK_REGEX.captures(line) {
204                let kind_str = caps.get(1).map(|m| m.as_str()).unwrap_or("");
205                let target: u32 = caps
206                    .get(2)
207                    .and_then(|m| m.as_str().parse().ok())
208                    .unwrap_or(0);
209
210                if target > 0 {
211                    let kind: LinkKind = kind_str.trim().parse().unwrap_or(LinkKind::RelatesTo);
212
213                    // If this is a "Superseded by" link, set status to Superseded
214                    // (adr-tools doesn't always have a separate status line)
215                    if matches!(kind, LinkKind::SupersededBy) {
216                        adr.status = AdrStatus::Superseded;
217                    }
218
219                    adr.links.push(AdrLink::new(target, kind));
220                }
221            } else if !line.contains('[') && !line.contains(']') {
222                // Plain status text (not a link line)
223                // Only set status if it looks like a simple status word
224                let word = line.split_whitespace().next().unwrap_or("");
225                if matches!(
226                    word.to_lowercase().as_str(),
227                    // Include "superceded" for adr-tools compatibility (common typo)
228                    "proposed"
229                        | "accepted"
230                        | "deprecated"
231                        | "superseded"
232                        | "superceded"
233                        | "draft"
234                        | "rejected"
235                ) {
236                    adr.status = word.parse().unwrap_or(AdrStatus::Proposed);
237                }
238            }
239        }
240    }
241
242    /// Parse markdown sections into a map.
243    fn parse_sections(&self, content: &str) -> std::collections::HashMap<String, String> {
244        let mut sections = std::collections::HashMap::new();
245        let mut current_section: Option<String> = None;
246        let mut section_content = String::new();
247
248        let parser = MdParser::new(content);
249        let mut in_heading = false;
250
251        for event in parser {
252            match event {
253                Event::Start(Tag::Heading {
254                    level: HeadingLevel::H2,
255                    ..
256                }) => {
257                    if let Some(ref section) = current_section {
258                        sections.insert(section.clone(), section_content.trim().to_string());
259                    }
260                    in_heading = true;
261                    section_content.clear();
262                }
263                Event::End(TagEnd::Heading(_)) => {
264                    in_heading = false;
265                }
266                Event::Text(text) => {
267                    if in_heading {
268                        current_section = Some(text.to_string().to_lowercase());
269                    } else {
270                        section_content.push_str(&text);
271                    }
272                }
273                Event::SoftBreak | Event::HardBreak if !in_heading => {
274                    section_content.push('\n');
275                }
276                _ => {}
277            }
278        }
279
280        if let Some(ref section) = current_section {
281            sections.insert(section.clone(), section_content.trim().to_string());
282        }
283
284        sections
285    }
286}
287
288/// Extract a title from the first H1 heading in markdown content.
289///
290/// Returns `(number, title)` where number is extracted from patterns like `# 1. Title`,
291/// or `0` if the H1 has no number prefix.
292fn extract_h1_title(content: &str) -> Option<(u32, String)> {
293    let title_line = content.lines().find(|l| l.starts_with("# "))?;
294    let title_str = title_line.trim_start_matches("# ").trim();
295    if title_str.is_empty() {
296        return None;
297    }
298    if let Some((num, title)) = parse_numbered_title(title_str) {
299        Some((num, title))
300    } else {
301        Some((0, title_str.to_string()))
302    }
303}
304
305/// Extract the date from a Nygard-style `Date: YYYY-MM-DD` line.
306///
307/// Only lines in the preamble before the ADR's first `## ` section heading
308/// are considered, so a `Date:` mentioned later in the document (e.g. in
309/// prose) is not mistaken for the ADR's date. Returns `None` if no such line
310/// exists or its value does not parse as an ISO 8601 date, in which case the
311/// caller should keep the default (today).
312fn extract_legacy_date(content: &str) -> Option<Date> {
313    for line in content.lines() {
314        if line.starts_with("## ") {
315            break;
316        }
317        if let Some(rest) = line.trim().strip_prefix("Date:") {
318            return Date::parse(rest.trim(), &Iso8601::DATE).ok();
319        }
320    }
321    None
322}
323
324/// Parse a numbered title like "1. Use Rust" into (1, "Use Rust").
325fn parse_numbered_title(title: &str) -> Option<(u32, String)> {
326    let parts: Vec<&str> = title.splitn(2, ". ").collect();
327    if parts.len() == 2
328        && let Ok(num) = parts[0].parse::<u32>()
329    {
330        return Some((num, parts[1].to_string()));
331    }
332    None
333}
334
335/// Extract ADR number from a file path.
336fn extract_number_from_path(path: &Path) -> Result<u32> {
337    let filename =
338        path.file_name()
339            .and_then(|n| n.to_str())
340            .ok_or_else(|| Error::InvalidFormat {
341                path: path.to_path_buf(),
342                reason: "Invalid filename".into(),
343            })?;
344
345    NUMBER_REGEX
346        .captures(filename)
347        .and_then(|caps| caps.get(1))
348        .and_then(|m| m.as_str().parse().ok())
349        .ok_or_else(|| Error::InvalidFormat {
350            path: path.to_path_buf(),
351            reason: "Cannot extract ADR number from filename".into(),
352        })
353}
354
355/// Get today's date.
356pub fn today() -> Date {
357    let now = OffsetDateTime::now_utc();
358    Date::from_calendar_date(now.year(), now.month(), now.day()).unwrap_or_else(|_| {
359        // Fallback to a safe default
360        Date::from_calendar_date(2024, Month::January, 1).unwrap()
361    })
362}
363
364/// Format a date as YYYY-MM-DD.
365pub fn format_date(date: Date) -> String {
366    format!(
367        "{:04}-{:02}-{:02}",
368        date.year(),
369        date.month() as u8,
370        date.day()
371    )
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use tempfile::TempDir;
378    use test_case::test_case;
379
380    // ========== Parser Creation ==========
381
382    #[test]
383    fn test_parser_new() {
384        let _parser = Parser::new();
385        // Parser creation succeeds - just confirms it compiles
386    }
387
388    #[test]
389    fn test_parser_default() {
390        let _parser = Parser::default();
391    }
392
393    // ========== Legacy Format Parsing ==========
394
395    #[test]
396    fn test_parse_legacy_format() {
397        let content = r#"# 1. Use Rust
398
399## Status
400
401Accepted
402
403## Context
404
405We need a systems programming language.
406
407## Decision
408
409We will use Rust.
410
411## Consequences
412
413We get memory safety without garbage collection.
414"#;
415
416        let parser = Parser::new();
417        let adr = parser.parse(content).unwrap();
418
419        assert_eq!(adr.number, 1);
420        assert_eq!(adr.title, "Use Rust");
421        assert_eq!(adr.status, AdrStatus::Accepted);
422        assert!(adr.context.contains("systems programming"));
423        assert!(adr.decision.contains("use Rust"));
424        assert!(adr.consequences.contains("memory safety"));
425    }
426
427    #[test]
428    fn test_parse_legacy_minimal() {
429        let content = r#"# 1. Minimal ADR
430
431## Status
432
433Proposed
434
435## Context
436
437Context.
438
439## Decision
440
441Decision.
442
443## Consequences
444
445Consequences.
446"#;
447
448        let parser = Parser::new();
449        let adr = parser.parse(content).unwrap();
450
451        assert_eq!(adr.number, 1);
452        assert_eq!(adr.title, "Minimal ADR");
453        assert_eq!(adr.status, AdrStatus::Proposed);
454        assert_eq!(adr.context, "Context.");
455        assert_eq!(adr.decision, "Decision.");
456        assert_eq!(adr.consequences, "Consequences.");
457    }
458
459    #[test]
460    fn test_parse_legacy_multiline_sections() {
461        let content = r#"# 1. Multiline Test
462
463## Status
464
465Accepted
466
467## Context
468
469This is a context section
470that spans multiple lines.
471
472With paragraphs too.
473
474## Decision
475
476This is the decision.
477Also multiple lines.
478
479## Consequences
480
481- Point 1
482- Point 2
483- Point 3
484"#;
485
486        let parser = Parser::new();
487        let adr = parser.parse(content).unwrap();
488
489        assert!(adr.context.contains("multiple lines"));
490        assert!(adr.context.contains("paragraphs"));
491        assert!(adr.decision.contains("Also multiple lines"));
492        assert!(adr.consequences.contains("Point 1"));
493        assert!(adr.consequences.contains("Point 2"));
494    }
495
496    #[test_case("Proposed" => AdrStatus::Proposed; "proposed")]
497    #[test_case("Accepted" => AdrStatus::Accepted; "accepted")]
498    #[test_case("Deprecated" => AdrStatus::Deprecated; "deprecated")]
499    #[test_case("Superseded" => AdrStatus::Superseded; "superseded")]
500    #[test_case("Draft" => AdrStatus::Custom("Draft".into()); "draft")]
501    #[test_case("Rejected" => AdrStatus::Custom("Rejected".into()); "rejected")]
502    fn test_parse_legacy_status_types(status: &str) -> AdrStatus {
503        let content = format!(
504            r#"# 1. Test
505
506## Status
507
508{status}
509
510## Context
511
512Context.
513
514## Decision
515
516Decision.
517
518## Consequences
519
520Consequences.
521"#
522        );
523
524        let parser = Parser::new();
525        let adr = parser.parse(&content).unwrap();
526        adr.status
527    }
528
529    #[test]
530    fn test_parse_legacy_with_date_line() {
531        let content = r#"# 1. Record architecture decisions
532
533Date: 2024-01-15
534
535## Status
536
537Accepted
538
539## Context
540
541Context.
542
543## Decision
544
545Decision.
546
547## Consequences
548
549Consequences.
550"#;
551
552        let parser = Parser::new();
553        let adr = parser.parse(content).unwrap();
554
555        assert_eq!(adr.number, 1);
556        assert_eq!(adr.title, "Record architecture decisions");
557        assert_eq!(adr.status, AdrStatus::Accepted);
558    }
559
560    #[test]
561    fn test_parse_legacy_title_without_number() {
562        let content = r#"# Use Rust
563
564## Status
565
566Proposed
567
568## Context
569
570Context.
571
572## Decision
573
574Decision.
575
576## Consequences
577
578Consequences.
579"#;
580
581        let parser = Parser::new();
582        let adr = parser.parse(content).unwrap();
583
584        assert_eq!(adr.number, 0);
585        assert_eq!(adr.title, "Use Rust");
586    }
587
588    #[test]
589    fn test_parse_legacy_status_with_links() {
590        let content = r#"# 2. Use PostgreSQL
591
592## Status
593
594Accepted
595
596Supersedes [1. Use MySQL](0001-use-mysql.md)
597
598## Context
599
600Context.
601
602## Decision
603
604Decision.
605
606## Consequences
607
608Consequences.
609"#;
610
611        let parser = Parser::new();
612        let adr = parser.parse(content).unwrap();
613
614        assert_eq!(adr.status, AdrStatus::Accepted);
615        assert_eq!(adr.links.len(), 1);
616        assert_eq!(adr.links[0].target, 1);
617        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
618    }
619
620    #[test]
621    fn test_parse_legacy_multiple_links() {
622        let content = r#"# 5. Combined Decision
623
624## Status
625
626Accepted
627
628Supersedes [1. First](0001-first.md)
629Supersedes [2. Second](0002-second.md)
630Amends [3. Third](0003-third.md)
631
632## Context
633
634Context.
635
636## Decision
637
638Decision.
639
640## Consequences
641
642Consequences.
643"#;
644
645        let parser = Parser::new();
646        let adr = parser.parse(content).unwrap();
647
648        assert_eq!(adr.links.len(), 3);
649        assert_eq!(adr.links[0].target, 1);
650        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
651        assert_eq!(adr.links[1].target, 2);
652        assert_eq!(adr.links[1].kind, LinkKind::Supersedes);
653        assert_eq!(adr.links[2].target, 3);
654        assert_eq!(adr.links[2].kind, LinkKind::Amends);
655    }
656
657    #[test]
658    fn test_parse_superseded_status() {
659        let content = r#"# 1. Record architecture decisions
660
661Date: 2026-01-22
662
663## Status
664
665Superseded
666
667Superseded by [2. ...](0002-....md)
668
669## Context
670
671Some context.
672
673## Decision
674
675Some decision.
676
677## Consequences
678
679Some consequences.
680"#;
681
682        let parser = Parser::new();
683        let adr = parser.parse(content).unwrap();
684
685        assert_eq!(adr.number, 1);
686        assert_eq!(adr.status, AdrStatus::Superseded);
687    }
688
689    // ========== Frontmatter Format Parsing ==========
690
691    #[test]
692    fn test_parse_frontmatter_format() {
693        let content = r#"---
694number: 2
695title: Use PostgreSQL
696date: 2024-01-15
697status: accepted
698links:
699  - target: 1
700    kind: supersedes
701---
702
703## Context
704
705We need a database.
706
707## Decision
708
709We will use PostgreSQL.
710
711## Consequences
712
713We get ACID compliance.
714"#;
715
716        let parser = Parser::new();
717        let adr = parser.parse(content).unwrap();
718
719        assert_eq!(adr.number, 2);
720        assert_eq!(adr.title, "Use PostgreSQL");
721        assert_eq!(adr.status, AdrStatus::Accepted);
722        assert_eq!(adr.links.len(), 1);
723        assert_eq!(adr.links[0].target, 1);
724        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
725    }
726
727    #[test]
728    fn test_parse_frontmatter_minimal() {
729        let content = r#"---
730number: 1
731title: Simple ADR
732date: 2024-01-01
733status: proposed
734---
735
736## Context
737
738Context.
739
740## Decision
741
742Decision.
743
744## Consequences
745
746Consequences.
747"#;
748
749        let parser = Parser::new();
750        let adr = parser.parse(content).unwrap();
751
752        assert_eq!(adr.number, 1);
753        assert_eq!(adr.title, "Simple ADR");
754        assert_eq!(adr.status, AdrStatus::Proposed);
755    }
756
757    #[test]
758    fn test_parse_frontmatter_no_links() {
759        let content = r#"---
760number: 1
761title: Test ADR
762date: 2024-01-01
763status: accepted
764---
765
766## Context
767
768Context.
769
770## Decision
771
772Decision.
773
774## Consequences
775
776Consequences.
777"#;
778
779        let parser = Parser::new();
780        let adr = parser.parse(content).unwrap();
781
782        assert!(adr.links.is_empty());
783    }
784
785    #[test]
786    fn test_parse_frontmatter_multiple_links() {
787        let content = r#"---
788number: 5
789title: Multi Link ADR
790date: 2024-01-01
791status: accepted
792links:
793  - target: 1
794    kind: supersedes
795  - target: 2
796    kind: amends
797  - target: 3
798    kind: relatesto
799---
800
801## Context
802
803Context.
804
805## Decision
806
807Decision.
808
809## Consequences
810
811Consequences.
812"#;
813
814        let parser = Parser::new();
815        let adr = parser.parse(content).unwrap();
816
817        assert_eq!(adr.links.len(), 3);
818        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
819        assert_eq!(adr.links[1].kind, LinkKind::Amends);
820        assert_eq!(adr.links[2].kind, LinkKind::RelatesTo);
821    }
822
823    #[test]
824    fn test_parse_frontmatter_all_statuses() {
825        for (status_str, expected) in [
826            ("proposed", AdrStatus::Proposed),
827            ("accepted", AdrStatus::Accepted),
828            ("deprecated", AdrStatus::Deprecated),
829            ("superseded", AdrStatus::Superseded),
830        ] {
831            let content = format!(
832                r#"---
833number: 1
834title: Test
835date: 2024-01-01
836status: {status_str}
837---
838
839## Context
840
841Context.
842"#
843            );
844
845            let parser = Parser::new();
846            let adr = parser.parse(&content).unwrap();
847            assert_eq!(adr.status, expected, "Failed for status: {status_str}");
848        }
849    }
850
851    #[test]
852    fn test_parse_frontmatter_invalid_format() {
853        let content = r#"---
854not valid yaml {{{{
855---
856
857## Context
858
859Context.
860"#;
861
862        let parser = Parser::new();
863        let result = parser.parse(content);
864        assert!(result.is_err());
865    }
866
867    #[test]
868    fn test_parse_frontmatter_incomplete() {
869        let content = r#"---
870number: 1
871title: Test
872"#;
873
874        let parser = Parser::new();
875        let result = parser.parse(content);
876        assert!(result.is_err());
877    }
878
879    // ========== MADR Format Parsing ==========
880
881    #[test]
882    fn test_parse_madr_format() {
883        // MADR format with number and title in frontmatter
884        let content = r#"---
885number: 2
886title: Use Redis for caching
887status: proposed
888date: 2024-01-15
889---
890
891# Use Redis for caching
892
893## Context and Problem Statement
894
895We need a caching solution.
896
897## Decision Outcome
898
899We will use Redis.
900
901### Consequences
902
903* Good, because fast
904"#;
905
906        let parser = Parser::new();
907        let adr = parser.parse(content).unwrap();
908
909        assert_eq!(adr.number, 2);
910        assert_eq!(adr.title, "Use Redis for caching");
911        assert_eq!(adr.status, AdrStatus::Proposed);
912        assert!(adr.context.contains("caching solution"));
913        assert!(adr.decision.contains("use Redis"));
914    }
915
916    #[test]
917    fn test_parse_madr_with_decision_makers() {
918        let content = r#"---
919number: 1
920title: Use MADR Format
921status: accepted
922date: 2024-01-01
923---
924
925# Use MADR Format
926
927## Context and Problem Statement
928
929Context.
930"#;
931
932        let parser = Parser::new();
933        let adr = parser.parse(content).unwrap();
934
935        assert_eq!(adr.number, 1);
936        assert_eq!(adr.title, "Use MADR Format");
937        assert_eq!(adr.status, AdrStatus::Accepted);
938        assert_eq!(adr.context, "Context.");
939    }
940
941    #[test]
942    fn test_parse_madr_frontmatter_populates_body_sections() {
943        let content = r#"---
944number: 1
945title: Use MADR Format
946date: 2024-09-15
947status: accepted
948---
949
950## Context and Problem Statement
951
952We need a standard format for ADRs.
953
954## Decision Outcome
955
956Chosen option: "MADR 4.0.0", because it provides rich metadata.
957"#;
958
959        let parser = Parser::new();
960        let adr = parser.parse(content).unwrap();
961
962        assert!(adr.context.contains("standard format"));
963        assert!(adr.decision.contains("MADR 4.0.0"));
964    }
965
966    #[test]
967    fn test_parse_madr_missing_number_fails() {
968        // MADR without number field should fail
969        let content = r#"---
970title: Missing Number
971status: proposed
972date: 2024-01-01
973---
974
975# Missing Number
976
977## Context and Problem Statement
978
979Context.
980"#;
981
982        let parser = Parser::new();
983        let result = parser.parse(content);
984        // Should fail because number is required
985        assert!(result.is_err() || result.unwrap().number == 0);
986    }
987
988    // ========== File Parsing ==========
989
990    #[test]
991    fn test_parse_file_legacy() {
992        let temp = TempDir::new().unwrap();
993        let file_path = temp.path().join("0001-use-rust.md");
994
995        std::fs::write(
996            &file_path,
997            r#"# 1. Use Rust
998
999## Status
1000
1001Accepted
1002
1003## Context
1004
1005Context.
1006
1007## Decision
1008
1009Decision.
1010
1011## Consequences
1012
1013Consequences.
1014"#,
1015        )
1016        .unwrap();
1017
1018        let parser = Parser::new();
1019        let adr = parser.parse_file(&file_path).unwrap();
1020
1021        assert_eq!(adr.number, 1);
1022        assert_eq!(adr.title, "Use Rust");
1023        assert_eq!(adr.path, Some(file_path));
1024    }
1025
1026    #[test]
1027    fn test_parse_file_extracts_number_from_filename() {
1028        let temp = TempDir::new().unwrap();
1029        let file_path = temp.path().join("0042-some-decision.md");
1030
1031        // ADR without number in title
1032        std::fs::write(
1033            &file_path,
1034            r#"# Some Decision
1035
1036## Status
1037
1038Proposed
1039
1040## Context
1041
1042Context.
1043
1044## Decision
1045
1046Decision.
1047
1048## Consequences
1049
1050Consequences.
1051"#,
1052        )
1053        .unwrap();
1054
1055        let parser = Parser::new();
1056        let adr = parser.parse_file(&file_path).unwrap();
1057
1058        assert_eq!(adr.number, 42);
1059    }
1060
1061    #[test]
1062    fn test_parse_file_nonexistent() {
1063        let parser = Parser::new();
1064        let result = parser.parse_file(Path::new("/nonexistent/path/0001-test.md"));
1065        assert!(result.is_err());
1066    }
1067
1068    // ========== Helper Function Tests ==========
1069
1070    #[test]
1071    fn test_parse_numbered_title() {
1072        assert_eq!(
1073            parse_numbered_title("1. Use Rust"),
1074            Some((1, "Use Rust".into()))
1075        );
1076        assert_eq!(
1077            parse_numbered_title("42. Complex Decision"),
1078            Some((42, "Complex Decision".into()))
1079        );
1080        assert_eq!(parse_numbered_title("Use Rust"), None);
1081    }
1082
1083    #[test_case("1. Simple" => Some((1, "Simple".into())); "simple")]
1084    #[test_case("123. Large Number" => Some((123, "Large Number".into())); "large number")]
1085    #[test_case("1. With. Dots. In. Title" => Some((1, "With. Dots. In. Title".into())); "dots in title")]
1086    #[test_case("No Number" => None; "no number")]
1087    #[test_case("1 Missing Period" => None; "missing period")]
1088    #[test_case(". Missing Number" => None; "missing number")]
1089    fn test_parse_numbered_title_cases(input: &str) -> Option<(u32, String)> {
1090        parse_numbered_title(input)
1091    }
1092
1093    #[test]
1094    fn test_extract_number_from_path() {
1095        let path = Path::new("doc/adr/0001-use-rust.md");
1096        assert_eq!(extract_number_from_path(path).unwrap(), 1);
1097
1098        let path = Path::new("0042-complex-decision.md");
1099        assert_eq!(extract_number_from_path(path).unwrap(), 42);
1100
1101        let path = Path::new("9999-max-four-digit.md");
1102        assert_eq!(extract_number_from_path(path).unwrap(), 9999);
1103    }
1104
1105    #[test]
1106    fn test_extract_number_from_path_invalid() {
1107        let result = extract_number_from_path(Path::new("not-an-adr.md"));
1108        assert!(result.is_err());
1109
1110        let result = extract_number_from_path(Path::new("1-too-few-digits.md"));
1111        assert!(result.is_err());
1112    }
1113
1114    #[test]
1115    fn test_today() {
1116        let date = today();
1117        assert!(date.year() >= 2024);
1118        assert!(date.month() as u8 >= 1 && date.month() as u8 <= 12);
1119        assert!(date.day() >= 1 && date.day() <= 31);
1120    }
1121
1122    #[test]
1123    fn test_format_date() {
1124        let date = Date::from_calendar_date(2024, Month::March, 5).unwrap();
1125        assert_eq!(format_date(date), "2024-03-05");
1126    }
1127
1128    #[test_case(2024, Month::January, 1 => "2024-01-01"; "new year")]
1129    #[test_case(2024, Month::December, 31 => "2024-12-31"; "end of year")]
1130    #[test_case(2000, Month::February, 29 => "2000-02-29"; "leap day")]
1131    #[test_case(2024, Month::July, 15 => "2024-07-15"; "mid year")]
1132    fn test_format_date_cases(year: i32, month: Month, day: u8) -> String {
1133        let date = Date::from_calendar_date(year, month, day).unwrap();
1134        format_date(date)
1135    }
1136
1137    // ========== Edge Cases ==========
1138
1139    #[test]
1140    fn test_parse_empty_content() {
1141        let parser = Parser::new();
1142        let adr = parser.parse("").unwrap();
1143
1144        assert_eq!(adr.number, 0);
1145        assert!(adr.title.is_empty());
1146    }
1147
1148    #[test]
1149    fn test_parse_only_title() {
1150        let content = "# 1. Just a Title";
1151
1152        let parser = Parser::new();
1153        let adr = parser.parse(content).unwrap();
1154
1155        assert_eq!(adr.number, 1);
1156        assert_eq!(adr.title, "Just a Title");
1157    }
1158
1159    #[test]
1160    fn test_parse_extra_sections_ignored() {
1161        let content = r#"# 1. Test
1162
1163## Status
1164
1165Proposed
1166
1167## Context
1168
1169Context.
1170
1171## Decision
1172
1173Decision.
1174
1175## Consequences
1176
1177Consequences.
1178
1179## Notes
1180
1181These should be ignored.
1182
1183## References
1184
1185- ref1
1186- ref2
1187"#;
1188
1189        let parser = Parser::new();
1190        let adr = parser.parse(content).unwrap();
1191
1192        // Extra sections are ignored, main content is still parsed
1193        assert_eq!(adr.number, 1);
1194        assert_eq!(adr.status, AdrStatus::Proposed);
1195    }
1196
1197    #[test]
1198    fn test_parse_case_insensitive_sections() {
1199        let content = r#"# 1. Case Test
1200
1201## STATUS
1202
1203Accepted
1204
1205## CONTEXT
1206
1207Context.
1208
1209## DECISION
1210
1211Decision.
1212
1213## CONSEQUENCES
1214
1215Consequences.
1216"#;
1217
1218        let parser = Parser::new();
1219        let adr = parser.parse(content).unwrap();
1220
1221        // Sections should be matched case-insensitively
1222        assert_eq!(adr.status, AdrStatus::Accepted);
1223        assert_eq!(adr.context, "Context.");
1224    }
1225
1226    #[test]
1227    fn test_parse_content_with_markdown_formatting() {
1228        let content = r#"# 1. Formatted ADR
1229
1230## Status
1231
1232Accepted
1233
1234## Context
1235
1236We have **bold** and *italic* text.
1237
1238Also `code` and [links](https://example.com).
1239
1240## Decision
1241
1242```rust
1243fn main() {
1244    println!("Hello");
1245}
1246```
1247
1248## Consequences
1249
1250| Column 1 | Column 2 |
1251|----------|----------|
1252| Value 1  | Value 2  |
1253"#;
1254
1255        let parser = Parser::new();
1256        let adr = parser.parse(content).unwrap();
1257
1258        assert!(adr.context.contains("bold"));
1259        assert!(adr.decision.contains("fn main"));
1260        assert!(adr.consequences.contains("Column 1"));
1261    }
1262
1263    // ========== Regex Tests ==========
1264
1265    #[test]
1266    fn test_link_regex_pattern() {
1267        let content = "Supersedes [1. Use MySQL](0001-use-mysql.md)";
1268        let caps = LINK_REGEX.captures(content).unwrap();
1269
1270        assert_eq!(caps.get(1).unwrap().as_str(), "Supersedes");
1271        assert_eq!(caps.get(2).unwrap().as_str(), "1");
1272        assert_eq!(caps.get(3).unwrap().as_str(), "0001");
1273    }
1274
1275    #[test]
1276    fn test_link_regex_amended_by() {
1277        let content = "Amended by [3. Update API](0003-update-api.md)";
1278        let caps = LINK_REGEX.captures(content).unwrap();
1279
1280        assert_eq!(caps.get(1).unwrap().as_str(), "Amended by");
1281        assert_eq!(caps.get(2).unwrap().as_str(), "3");
1282    }
1283
1284    #[test]
1285    fn test_number_regex_pattern() {
1286        let filename = "0042-some-decision.md";
1287        let caps = NUMBER_REGEX.captures(filename).unwrap();
1288
1289        assert_eq!(caps.get(1).unwrap().as_str(), "0042");
1290    }
1291
1292    #[test]
1293    fn test_number_regex_no_match() {
1294        assert!(NUMBER_REGEX.captures("not-an-adr.md").is_none());
1295        assert!(NUMBER_REGEX.captures("01-short.md").is_none());
1296        assert!(NUMBER_REGEX.captures("00001-too-long.md").is_none());
1297    }
1298
1299    // ========== MADR 4.0.0 Frontmatter Tests ==========
1300
1301    #[test]
1302    fn test_parse_madr_frontmatter() {
1303        let content = r#"---
1304number: 1
1305title: Use MADR Format
1306date: 2024-09-15
1307status: accepted
1308decision-makers:
1309  - Alice
1310  - Bob
1311consulted:
1312  - Carol
1313informed:
1314  - Dave
1315  - Eve
1316---
1317
1318## Context and Problem Statement
1319
1320We need a standard format for ADRs.
1321
1322## Decision Outcome
1323
1324Chosen option: "MADR 4.0.0", because it provides rich metadata.
1325"#;
1326
1327        let parser = Parser::new();
1328        let adr = parser.parse(content).unwrap();
1329
1330        assert_eq!(adr.number, 1);
1331        assert_eq!(adr.title, "Use MADR Format");
1332        assert_eq!(adr.status, AdrStatus::Accepted);
1333        assert_eq!(adr.decision_makers, vec!["Alice", "Bob"]);
1334        assert_eq!(adr.consulted, vec!["Carol"]);
1335        assert_eq!(adr.informed, vec!["Dave", "Eve"]);
1336    }
1337
1338    #[test]
1339    fn test_parse_madr_frontmatter_partial_fields() {
1340        let content = r#"---
1341number: 2
1342title: Partial MADR
1343date: 2024-09-15
1344status: proposed
1345decision-makers:
1346  - Alice
1347---
1348
1349## Context
1350
1351Context.
1352"#;
1353
1354        let parser = Parser::new();
1355        let adr = parser.parse(content).unwrap();
1356
1357        assert_eq!(adr.decision_makers, vec!["Alice"]);
1358        assert!(adr.consulted.is_empty());
1359        assert!(adr.informed.is_empty());
1360    }
1361
1362    #[test]
1363    fn test_parse_madr_frontmatter_empty_fields() {
1364        let content = r#"---
1365number: 3
1366title: No MADR Fields
1367date: 2024-09-15
1368status: accepted
1369---
1370
1371## Context
1372
1373Context.
1374"#;
1375
1376        let parser = Parser::new();
1377        let adr = parser.parse(content).unwrap();
1378
1379        assert!(adr.decision_makers.is_empty());
1380        assert!(adr.consulted.is_empty());
1381        assert!(adr.informed.is_empty());
1382    }
1383
1384    #[test]
1385    fn test_parse_madr_with_links() {
1386        let content = r#"---
1387number: 4
1388title: MADR With Links
1389date: 2024-09-15
1390status: accepted
1391decision-makers:
1392  - Alice
1393links:
1394  - target: 1
1395    kind: supersedes
1396  - target: 2
1397    kind: amends
1398---
1399
1400## Context
1401
1402Context.
1403"#;
1404
1405        let parser = Parser::new();
1406        let adr = parser.parse(content).unwrap();
1407
1408        assert_eq!(adr.decision_makers, vec!["Alice"]);
1409        assert_eq!(adr.links.len(), 2);
1410        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
1411        assert_eq!(adr.links[1].kind, LinkKind::Amends);
1412    }
1413
1414    // ========== Frontmatter Title Fallback (#186) ==========
1415
1416    #[test]
1417    fn test_parse_frontmatter_title_from_body_h1() {
1418        let content = r#"---
1419number: 2
1420date: 2024-01-15
1421status: proposed
1422---
1423
1424# My Decision Title
1425
1426## Context
1427
1428Context.
1429
1430## Decision
1431
1432Decision.
1433
1434## Consequences
1435
1436Consequences.
1437"#;
1438
1439        let parser = Parser::new();
1440        let adr = parser.parse(content).unwrap();
1441
1442        assert_eq!(adr.number, 2);
1443        assert_eq!(adr.title, "My Decision Title");
1444        assert_eq!(adr.status, AdrStatus::Proposed);
1445    }
1446
1447    #[test]
1448    fn test_parse_frontmatter_title_from_body_h1_numbered() {
1449        let content = r#"---
1450number: 2
1451date: 2024-01-15
1452status: proposed
1453---
1454
1455# 2. My Numbered Title
1456
1457## Context
1458
1459Context.
1460"#;
1461
1462        let parser = Parser::new();
1463        let adr = parser.parse(content).unwrap();
1464
1465        assert_eq!(adr.number, 2);
1466        assert_eq!(adr.title, "My Numbered Title");
1467    }
1468
1469    #[test]
1470    fn test_parse_frontmatter_title_prefers_frontmatter() {
1471        let content = r#"---
1472number: 2
1473title: Frontmatter Title
1474date: 2024-01-15
1475status: proposed
1476---
1477
1478# Body Title
1479
1480## Context
1481
1482Context.
1483"#;
1484
1485        let parser = Parser::new();
1486        let adr = parser.parse(content).unwrap();
1487
1488        assert_eq!(adr.title, "Frontmatter Title");
1489    }
1490
1491    // ========== CRLF Line Endings (#326) ==========
1492
1493    #[test]
1494    fn test_parse_crlf_frontmatter_matches_lf() {
1495        let lf = r#"---
1496number: 4
1497title: Use MADR format for ADRs
1498date: 2024-02-15
1499status: accepted
1500decision-makers:
1501  - Alice Smith
1502  - Bob Jones
1503consulted:
1504  - Carol White
1505informed:
1506  - David Brown
1507  - Eve Green
1508---
1509
1510## Context
1511
1512We need a richer metadata format.
1513
1514## Decision
1515
1516We will use MADR.
1517
1518## Consequences
1519
1520More structured metadata.
1521"#;
1522        let crlf = lf.replace('\n', "\r\n");
1523
1524        let parser = Parser::new();
1525        let lf_adr = parser.parse(lf).unwrap();
1526        let crlf_adr = parser.parse(&crlf).unwrap();
1527
1528        assert_eq!(crlf_adr.status, AdrStatus::Accepted);
1529        assert_eq!(crlf_adr.status, lf_adr.status);
1530        assert_eq!(crlf_adr.date, lf_adr.date);
1531        assert_eq!(
1532            crlf_adr.decision_makers,
1533            vec!["Alice Smith".to_string(), "Bob Jones".to_string()]
1534        );
1535        assert_eq!(crlf_adr.decision_makers, lf_adr.decision_makers);
1536        assert_eq!(crlf_adr.consulted, lf_adr.consulted);
1537        assert_eq!(crlf_adr.informed, lf_adr.informed);
1538        assert_eq!(crlf_adr.context, lf_adr.context);
1539        assert_eq!(crlf_adr.decision, lf_adr.decision);
1540        assert_eq!(crlf_adr.consequences, lf_adr.consequences);
1541
1542        // No stray `\r` should leak into any parsed string field.
1543        assert!(!crlf_adr.title.contains('\r'));
1544        assert!(!crlf_adr.context.contains('\r'));
1545        assert!(!crlf_adr.decision.contains('\r'));
1546        assert!(!crlf_adr.consequences.contains('\r'));
1547        for person in crlf_adr
1548            .decision_makers
1549            .iter()
1550            .chain(crlf_adr.consulted.iter())
1551            .chain(crlf_adr.informed.iter())
1552        {
1553            assert!(!person.contains('\r'));
1554        }
1555    }
1556
1557    #[test]
1558    fn test_parse_crlf_legacy_format() {
1559        let lf = r#"# 1. Use Rust
1560
1561## Status
1562
1563Accepted
1564
1565## Context
1566
1567We need a systems programming language.
1568
1569## Decision
1570
1571We will use Rust.
1572
1573## Consequences
1574
1575We get memory safety without garbage collection.
1576"#;
1577        let crlf = lf.replace('\n', "\r\n");
1578
1579        let parser = Parser::new();
1580        let adr = parser.parse(&crlf).unwrap();
1581
1582        assert_eq!(adr.number, 1);
1583        assert_eq!(adr.title, "Use Rust");
1584        assert_eq!(adr.status, AdrStatus::Accepted);
1585        assert!(adr.context.contains("systems programming"));
1586        assert!(adr.decision.contains("use Rust"));
1587        assert!(adr.consequences.contains("memory safety"));
1588
1589        assert!(!adr.title.contains('\r'));
1590        assert!(!adr.context.contains('\r'));
1591        assert!(!adr.decision.contains('\r'));
1592        assert!(!adr.consequences.contains('\r'));
1593    }
1594
1595    // ========== Legacy Date Line (#324) ==========
1596
1597    #[test]
1598    fn test_parse_legacy_date_line_is_parsed() {
1599        let content = r#"# 1. Record architecture decisions
1600
1601Date: 2024-01-15
1602
1603## Status
1604
1605Accepted
1606
1607## Context
1608
1609Context.
1610
1611## Decision
1612
1613Decision.
1614
1615## Consequences
1616
1617Consequences.
1618"#;
1619
1620        let parser = Parser::new();
1621        let adr = parser.parse(content).unwrap();
1622
1623        assert_eq!(adr.date.to_string(), "2024-01-15");
1624    }
1625
1626    #[test]
1627    fn test_parse_legacy_no_date_line_falls_back_to_today() {
1628        let content = r#"# 1. Record architecture decisions
1629
1630## Status
1631
1632Accepted
1633
1634## Context
1635
1636Context.
1637
1638## Decision
1639
1640Decision.
1641
1642## Consequences
1643
1644Consequences.
1645"#;
1646
1647        let parser = Parser::new();
1648        let adr = parser.parse(content).unwrap();
1649
1650        assert_eq!(adr.date, today());
1651    }
1652
1653    #[test]
1654    fn test_parse_legacy_unparseable_date_line_falls_back_to_today() {
1655        let content = r#"# 1. Record architecture decisions
1656
1657Date: not-a-date
1658
1659## Status
1660
1661Accepted
1662
1663## Context
1664
1665Context.
1666
1667## Decision
1668
1669Decision.
1670
1671## Consequences
1672
1673Consequences.
1674"#;
1675
1676        let parser = Parser::new();
1677        let adr = parser.parse(content).unwrap();
1678
1679        assert_eq!(adr.date, today());
1680    }
1681}