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::{Date, Month, OffsetDateTime};
9
10/// Regex for parsing legacy status links like "Supersedes [1. Title](0001-title.md)".
11static LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
12    Regex::new(r"^([\w\s]+)\s+\[(\d+)\.\s+[^\]]+\]\((\d{4})-[^)]+\.md\)$").unwrap()
13});
14
15/// Regex for extracting ADR number from filename.
16static NUMBER_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\d{4})-.*\.md$").unwrap());
17
18/// Parser for ADR files.
19#[derive(Debug, Default)]
20pub struct Parser {
21    _private: (),
22}
23
24impl Parser {
25    /// Create a new parser.
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    /// Parse an ADR from a file.
31    pub fn parse_file(&self, path: &Path) -> Result<Adr> {
32        let content = std::fs::read_to_string(path)?;
33        let mut adr = self.parse(&content)?;
34
35        // Extract number from filename if not in frontmatter
36        if adr.number == 0 {
37            adr.number = extract_number_from_path(path)?;
38        }
39
40        adr.path = Some(path.to_path_buf());
41        Ok(adr)
42    }
43
44    /// Parse an ADR from a string.
45    pub fn parse(&self, content: &str) -> Result<Adr> {
46        // Check for YAML frontmatter
47        if content.starts_with("---\n") {
48            self.parse_frontmatter(content)
49        } else {
50            self.parse_legacy(content)
51        }
52    }
53
54    /// Parse ADR with YAML frontmatter.
55    fn parse_frontmatter(&self, content: &str) -> Result<Adr> {
56        let parts: Vec<&str> = content.splitn(3, "---\n").collect();
57        if parts.len() < 3 {
58            return Err(Error::InvalidFormat {
59                path: Default::default(),
60                reason: "Invalid frontmatter format".into(),
61            });
62        }
63
64        let yaml = parts[1];
65        let body = parts[2];
66
67        // Parse frontmatter
68        let mut adr: Adr = serde_yaml::from_str(yaml)?;
69
70        // Parse body sections
71        let sections = self.parse_sections(body);
72        if let Some(context) = sections.get("context") {
73            adr.context = context.clone();
74        }
75        if let Some(decision) = sections.get("decision") {
76            adr.decision = decision.clone();
77        }
78        if let Some(consequences) = sections.get("consequences") {
79            adr.consequences = consequences.clone();
80        }
81
82        Ok(adr)
83    }
84
85    /// Parse legacy markdown format (adr-tools compatible).
86    fn parse_legacy(&self, content: &str) -> Result<Adr> {
87        let mut adr = Adr::new(0, "");
88
89        // Use a simpler approach: split by H2 sections and parse each
90        let sections = self.extract_sections_raw(content);
91
92        // Parse H1 title
93        if let Some(title_line) = content.lines().find(|l| l.starts_with("# ")) {
94            let title_str = title_line.trim_start_matches("# ").trim();
95            if let Some((num, title)) = parse_numbered_title(title_str) {
96                adr.number = num;
97                adr.title = title;
98            } else {
99                adr.title = title_str.to_string();
100            }
101        }
102
103        // Apply sections
104        for (name, content) in &sections {
105            self.apply_section(&mut adr, name, content);
106        }
107
108        Ok(adr)
109    }
110
111    /// Extract sections from raw markdown text.
112    fn extract_sections_raw(&self, content: &str) -> Vec<(String, String)> {
113        let mut sections = Vec::new();
114        let mut current_section: Option<String> = None;
115        let mut section_content = String::new();
116
117        for line in content.lines() {
118            if line.starts_with("## ") {
119                // Save previous section
120                if let Some(ref name) = current_section {
121                    sections.push((name.clone(), section_content.trim().to_string()));
122                }
123                current_section = Some(line.trim_start_matches("## ").trim().to_lowercase());
124                section_content.clear();
125            } else if current_section.is_some() {
126                section_content.push_str(line);
127                section_content.push('\n');
128            }
129        }
130
131        // Save final section
132        if let Some(ref name) = current_section {
133            sections.push((name.clone(), section_content.trim().to_string()));
134        }
135
136        sections
137    }
138
139    /// Apply a parsed section to the ADR.
140    fn apply_section(&self, adr: &mut Adr, section: &str, content: &str) {
141        let content = content.trim().to_string();
142        match section {
143            "status" => {
144                self.parse_status_section(adr, &content);
145            }
146            "context" => {
147                adr.context = content;
148            }
149            "decision" => {
150                adr.decision = content;
151            }
152            "consequences" => {
153                adr.consequences = content;
154            }
155            _ => {}
156        }
157    }
158
159    /// Parse the status section for status and links.
160    fn parse_status_section(&self, adr: &mut Adr, content: &str) {
161        for line in content.lines() {
162            let line = line.trim();
163            if line.is_empty() {
164                continue;
165            }
166
167            // Check for link pattern: "Supersedes [1. Title](0001-title.md)"
168            if let Some(caps) = LINK_REGEX.captures(line) {
169                let kind_str = caps.get(1).map(|m| m.as_str()).unwrap_or("");
170                let target: u32 = caps
171                    .get(2)
172                    .and_then(|m| m.as_str().parse().ok())
173                    .unwrap_or(0);
174
175                if target > 0 {
176                    let kind: LinkKind = kind_str.trim().parse().unwrap_or(LinkKind::RelatesTo);
177
178                    // If this is a "Superseded by" link, set status to Superseded
179                    // (adr-tools doesn't always have a separate status line)
180                    if matches!(kind, LinkKind::SupersededBy) {
181                        adr.status = AdrStatus::Superseded;
182                    }
183
184                    adr.links.push(AdrLink::new(target, kind));
185                }
186            } else if !line.contains('[') && !line.contains(']') {
187                // Plain status text (not a link line)
188                // Only set status if it looks like a simple status word
189                let word = line.split_whitespace().next().unwrap_or("");
190                if matches!(
191                    word.to_lowercase().as_str(),
192                    // Include "superceded" for adr-tools compatibility (common typo)
193                    "proposed"
194                        | "accepted"
195                        | "deprecated"
196                        | "superseded"
197                        | "superceded"
198                        | "draft"
199                        | "rejected"
200                ) {
201                    adr.status = word.parse().unwrap_or(AdrStatus::Proposed);
202                }
203            }
204        }
205    }
206
207    /// Parse markdown sections into a map.
208    fn parse_sections(&self, content: &str) -> std::collections::HashMap<String, String> {
209        let mut sections = std::collections::HashMap::new();
210        let mut current_section: Option<String> = None;
211        let mut section_content = String::new();
212
213        let parser = MdParser::new(content);
214        let mut in_heading = false;
215
216        for event in parser {
217            match event {
218                Event::Start(Tag::Heading {
219                    level: HeadingLevel::H2,
220                    ..
221                }) => {
222                    if let Some(ref section) = current_section {
223                        sections.insert(section.clone(), section_content.trim().to_string());
224                    }
225                    in_heading = true;
226                    section_content.clear();
227                }
228                Event::End(TagEnd::Heading(_)) => {
229                    in_heading = false;
230                }
231                Event::Text(text) => {
232                    if in_heading {
233                        current_section = Some(text.to_string().to_lowercase());
234                    } else {
235                        section_content.push_str(&text);
236                    }
237                }
238                Event::SoftBreak | Event::HardBreak => {
239                    if !in_heading {
240                        section_content.push('\n');
241                    }
242                }
243                _ => {}
244            }
245        }
246
247        if let Some(ref section) = current_section {
248            sections.insert(section.clone(), section_content.trim().to_string());
249        }
250
251        sections
252    }
253}
254
255/// Parse a numbered title like "1. Use Rust" into (1, "Use Rust").
256fn parse_numbered_title(title: &str) -> Option<(u32, String)> {
257    let parts: Vec<&str> = title.splitn(2, ". ").collect();
258    if parts.len() == 2
259        && let Ok(num) = parts[0].parse::<u32>()
260    {
261        return Some((num, parts[1].to_string()));
262    }
263    None
264}
265
266/// Extract ADR number from a file path.
267fn extract_number_from_path(path: &Path) -> Result<u32> {
268    let filename =
269        path.file_name()
270            .and_then(|n| n.to_str())
271            .ok_or_else(|| Error::InvalidFormat {
272                path: path.to_path_buf(),
273                reason: "Invalid filename".into(),
274            })?;
275
276    NUMBER_REGEX
277        .captures(filename)
278        .and_then(|caps| caps.get(1))
279        .and_then(|m| m.as_str().parse().ok())
280        .ok_or_else(|| Error::InvalidFormat {
281            path: path.to_path_buf(),
282            reason: "Cannot extract ADR number from filename".into(),
283        })
284}
285
286/// Get today's date.
287pub fn today() -> Date {
288    let now = OffsetDateTime::now_utc();
289    Date::from_calendar_date(now.year(), now.month(), now.day()).unwrap_or_else(|_| {
290        // Fallback to a safe default
291        Date::from_calendar_date(2024, Month::January, 1).unwrap()
292    })
293}
294
295/// Format a date as YYYY-MM-DD.
296pub fn format_date(date: Date) -> String {
297    format!(
298        "{:04}-{:02}-{:02}",
299        date.year(),
300        date.month() as u8,
301        date.day()
302    )
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use tempfile::TempDir;
309    use test_case::test_case;
310
311    // ========== Parser Creation ==========
312
313    #[test]
314    fn test_parser_new() {
315        let _parser = Parser::new();
316        // Parser creation succeeds - just confirms it compiles
317    }
318
319    #[test]
320    fn test_parser_default() {
321        let _parser = Parser::default();
322    }
323
324    // ========== Legacy Format Parsing ==========
325
326    #[test]
327    fn test_parse_legacy_format() {
328        let content = r#"# 1. Use Rust
329
330## Status
331
332Accepted
333
334## Context
335
336We need a systems programming language.
337
338## Decision
339
340We will use Rust.
341
342## Consequences
343
344We get memory safety without garbage collection.
345"#;
346
347        let parser = Parser::new();
348        let adr = parser.parse(content).unwrap();
349
350        assert_eq!(adr.number, 1);
351        assert_eq!(adr.title, "Use Rust");
352        assert_eq!(adr.status, AdrStatus::Accepted);
353        assert!(adr.context.contains("systems programming"));
354        assert!(adr.decision.contains("use Rust"));
355        assert!(adr.consequences.contains("memory safety"));
356    }
357
358    #[test]
359    fn test_parse_legacy_minimal() {
360        let content = r#"# 1. Minimal ADR
361
362## Status
363
364Proposed
365
366## Context
367
368Context.
369
370## Decision
371
372Decision.
373
374## Consequences
375
376Consequences.
377"#;
378
379        let parser = Parser::new();
380        let adr = parser.parse(content).unwrap();
381
382        assert_eq!(adr.number, 1);
383        assert_eq!(adr.title, "Minimal ADR");
384        assert_eq!(adr.status, AdrStatus::Proposed);
385        assert_eq!(adr.context, "Context.");
386        assert_eq!(adr.decision, "Decision.");
387        assert_eq!(adr.consequences, "Consequences.");
388    }
389
390    #[test]
391    fn test_parse_legacy_multiline_sections() {
392        let content = r#"# 1. Multiline Test
393
394## Status
395
396Accepted
397
398## Context
399
400This is a context section
401that spans multiple lines.
402
403With paragraphs too.
404
405## Decision
406
407This is the decision.
408Also multiple lines.
409
410## Consequences
411
412- Point 1
413- Point 2
414- Point 3
415"#;
416
417        let parser = Parser::new();
418        let adr = parser.parse(content).unwrap();
419
420        assert!(adr.context.contains("multiple lines"));
421        assert!(adr.context.contains("paragraphs"));
422        assert!(adr.decision.contains("Also multiple lines"));
423        assert!(adr.consequences.contains("Point 1"));
424        assert!(adr.consequences.contains("Point 2"));
425    }
426
427    #[test_case("Proposed" => AdrStatus::Proposed; "proposed")]
428    #[test_case("Accepted" => AdrStatus::Accepted; "accepted")]
429    #[test_case("Deprecated" => AdrStatus::Deprecated; "deprecated")]
430    #[test_case("Superseded" => AdrStatus::Superseded; "superseded")]
431    #[test_case("Draft" => AdrStatus::Custom("Draft".into()); "draft")]
432    #[test_case("Rejected" => AdrStatus::Custom("Rejected".into()); "rejected")]
433    fn test_parse_legacy_status_types(status: &str) -> AdrStatus {
434        let content = format!(
435            r#"# 1. Test
436
437## Status
438
439{status}
440
441## Context
442
443Context.
444
445## Decision
446
447Decision.
448
449## Consequences
450
451Consequences.
452"#
453        );
454
455        let parser = Parser::new();
456        let adr = parser.parse(&content).unwrap();
457        adr.status
458    }
459
460    #[test]
461    fn test_parse_legacy_with_date_line() {
462        let content = r#"# 1. Record architecture decisions
463
464Date: 2024-01-15
465
466## Status
467
468Accepted
469
470## Context
471
472Context.
473
474## Decision
475
476Decision.
477
478## Consequences
479
480Consequences.
481"#;
482
483        let parser = Parser::new();
484        let adr = parser.parse(content).unwrap();
485
486        assert_eq!(adr.number, 1);
487        assert_eq!(adr.title, "Record architecture decisions");
488        assert_eq!(adr.status, AdrStatus::Accepted);
489    }
490
491    #[test]
492    fn test_parse_legacy_title_without_number() {
493        let content = r#"# Use Rust
494
495## Status
496
497Proposed
498
499## Context
500
501Context.
502
503## Decision
504
505Decision.
506
507## Consequences
508
509Consequences.
510"#;
511
512        let parser = Parser::new();
513        let adr = parser.parse(content).unwrap();
514
515        assert_eq!(adr.number, 0);
516        assert_eq!(adr.title, "Use Rust");
517    }
518
519    #[test]
520    fn test_parse_legacy_status_with_links() {
521        let content = r#"# 2. Use PostgreSQL
522
523## Status
524
525Accepted
526
527Supersedes [1. Use MySQL](0001-use-mysql.md)
528
529## Context
530
531Context.
532
533## Decision
534
535Decision.
536
537## Consequences
538
539Consequences.
540"#;
541
542        let parser = Parser::new();
543        let adr = parser.parse(content).unwrap();
544
545        assert_eq!(adr.status, AdrStatus::Accepted);
546        assert_eq!(adr.links.len(), 1);
547        assert_eq!(adr.links[0].target, 1);
548        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
549    }
550
551    #[test]
552    fn test_parse_legacy_multiple_links() {
553        let content = r#"# 5. Combined Decision
554
555## Status
556
557Accepted
558
559Supersedes [1. First](0001-first.md)
560Supersedes [2. Second](0002-second.md)
561Amends [3. Third](0003-third.md)
562
563## Context
564
565Context.
566
567## Decision
568
569Decision.
570
571## Consequences
572
573Consequences.
574"#;
575
576        let parser = Parser::new();
577        let adr = parser.parse(content).unwrap();
578
579        assert_eq!(adr.links.len(), 3);
580        assert_eq!(adr.links[0].target, 1);
581        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
582        assert_eq!(adr.links[1].target, 2);
583        assert_eq!(adr.links[1].kind, LinkKind::Supersedes);
584        assert_eq!(adr.links[2].target, 3);
585        assert_eq!(adr.links[2].kind, LinkKind::Amends);
586    }
587
588    #[test]
589    fn test_parse_superseded_status() {
590        let content = r#"# 1. Record architecture decisions
591
592Date: 2026-01-22
593
594## Status
595
596Superseded
597
598Superseded by [2. ...](0002-....md)
599
600## Context
601
602Some context.
603
604## Decision
605
606Some decision.
607
608## Consequences
609
610Some consequences.
611"#;
612
613        let parser = Parser::new();
614        let adr = parser.parse(content).unwrap();
615
616        assert_eq!(adr.number, 1);
617        assert_eq!(adr.status, AdrStatus::Superseded);
618    }
619
620    // ========== Frontmatter Format Parsing ==========
621
622    #[test]
623    fn test_parse_frontmatter_format() {
624        let content = r#"---
625number: 2
626title: Use PostgreSQL
627date: 2024-01-15
628status: accepted
629links:
630  - target: 1
631    kind: supersedes
632---
633
634## Context
635
636We need a database.
637
638## Decision
639
640We will use PostgreSQL.
641
642## Consequences
643
644We get ACID compliance.
645"#;
646
647        let parser = Parser::new();
648        let adr = parser.parse(content).unwrap();
649
650        assert_eq!(adr.number, 2);
651        assert_eq!(adr.title, "Use PostgreSQL");
652        assert_eq!(adr.status, AdrStatus::Accepted);
653        assert_eq!(adr.links.len(), 1);
654        assert_eq!(adr.links[0].target, 1);
655        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
656    }
657
658    #[test]
659    fn test_parse_frontmatter_minimal() {
660        let content = r#"---
661number: 1
662title: Simple ADR
663date: 2024-01-01
664status: proposed
665---
666
667## Context
668
669Context.
670
671## Decision
672
673Decision.
674
675## Consequences
676
677Consequences.
678"#;
679
680        let parser = Parser::new();
681        let adr = parser.parse(content).unwrap();
682
683        assert_eq!(adr.number, 1);
684        assert_eq!(adr.title, "Simple ADR");
685        assert_eq!(adr.status, AdrStatus::Proposed);
686    }
687
688    #[test]
689    fn test_parse_frontmatter_no_links() {
690        let content = r#"---
691number: 1
692title: Test ADR
693date: 2024-01-01
694status: accepted
695---
696
697## Context
698
699Context.
700
701## Decision
702
703Decision.
704
705## Consequences
706
707Consequences.
708"#;
709
710        let parser = Parser::new();
711        let adr = parser.parse(content).unwrap();
712
713        assert!(adr.links.is_empty());
714    }
715
716    #[test]
717    fn test_parse_frontmatter_multiple_links() {
718        let content = r#"---
719number: 5
720title: Multi Link ADR
721date: 2024-01-01
722status: accepted
723links:
724  - target: 1
725    kind: supersedes
726  - target: 2
727    kind: amends
728  - target: 3
729    kind: relatesto
730---
731
732## Context
733
734Context.
735
736## Decision
737
738Decision.
739
740## Consequences
741
742Consequences.
743"#;
744
745        let parser = Parser::new();
746        let adr = parser.parse(content).unwrap();
747
748        assert_eq!(adr.links.len(), 3);
749        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
750        assert_eq!(adr.links[1].kind, LinkKind::Amends);
751        assert_eq!(adr.links[2].kind, LinkKind::RelatesTo);
752    }
753
754    #[test]
755    fn test_parse_frontmatter_all_statuses() {
756        for (status_str, expected) in [
757            ("proposed", AdrStatus::Proposed),
758            ("accepted", AdrStatus::Accepted),
759            ("deprecated", AdrStatus::Deprecated),
760            ("superseded", AdrStatus::Superseded),
761        ] {
762            let content = format!(
763                r#"---
764number: 1
765title: Test
766date: 2024-01-01
767status: {status_str}
768---
769
770## Context
771
772Context.
773"#
774            );
775
776            let parser = Parser::new();
777            let adr = parser.parse(&content).unwrap();
778            assert_eq!(adr.status, expected, "Failed for status: {status_str}");
779        }
780    }
781
782    #[test]
783    fn test_parse_frontmatter_invalid_format() {
784        let content = r#"---
785not valid yaml {{{{
786---
787
788## Context
789
790Context.
791"#;
792
793        let parser = Parser::new();
794        let result = parser.parse(content);
795        assert!(result.is_err());
796    }
797
798    #[test]
799    fn test_parse_frontmatter_incomplete() {
800        let content = r#"---
801number: 1
802title: Test
803"#;
804
805        let parser = Parser::new();
806        let result = parser.parse(content);
807        assert!(result.is_err());
808    }
809
810    // ========== File Parsing ==========
811
812    #[test]
813    fn test_parse_file_legacy() {
814        let temp = TempDir::new().unwrap();
815        let file_path = temp.path().join("0001-use-rust.md");
816
817        std::fs::write(
818            &file_path,
819            r#"# 1. Use Rust
820
821## Status
822
823Accepted
824
825## Context
826
827Context.
828
829## Decision
830
831Decision.
832
833## Consequences
834
835Consequences.
836"#,
837        )
838        .unwrap();
839
840        let parser = Parser::new();
841        let adr = parser.parse_file(&file_path).unwrap();
842
843        assert_eq!(adr.number, 1);
844        assert_eq!(adr.title, "Use Rust");
845        assert_eq!(adr.path, Some(file_path));
846    }
847
848    #[test]
849    fn test_parse_file_extracts_number_from_filename() {
850        let temp = TempDir::new().unwrap();
851        let file_path = temp.path().join("0042-some-decision.md");
852
853        // ADR without number in title
854        std::fs::write(
855            &file_path,
856            r#"# Some Decision
857
858## Status
859
860Proposed
861
862## Context
863
864Context.
865
866## Decision
867
868Decision.
869
870## Consequences
871
872Consequences.
873"#,
874        )
875        .unwrap();
876
877        let parser = Parser::new();
878        let adr = parser.parse_file(&file_path).unwrap();
879
880        assert_eq!(adr.number, 42);
881    }
882
883    #[test]
884    fn test_parse_file_nonexistent() {
885        let parser = Parser::new();
886        let result = parser.parse_file(Path::new("/nonexistent/path/0001-test.md"));
887        assert!(result.is_err());
888    }
889
890    // ========== Helper Function Tests ==========
891
892    #[test]
893    fn test_parse_numbered_title() {
894        assert_eq!(
895            parse_numbered_title("1. Use Rust"),
896            Some((1, "Use Rust".into()))
897        );
898        assert_eq!(
899            parse_numbered_title("42. Complex Decision"),
900            Some((42, "Complex Decision".into()))
901        );
902        assert_eq!(parse_numbered_title("Use Rust"), None);
903    }
904
905    #[test_case("1. Simple" => Some((1, "Simple".into())); "simple")]
906    #[test_case("123. Large Number" => Some((123, "Large Number".into())); "large number")]
907    #[test_case("1. With. Dots. In. Title" => Some((1, "With. Dots. In. Title".into())); "dots in title")]
908    #[test_case("No Number" => None; "no number")]
909    #[test_case("1 Missing Period" => None; "missing period")]
910    #[test_case(". Missing Number" => None; "missing number")]
911    fn test_parse_numbered_title_cases(input: &str) -> Option<(u32, String)> {
912        parse_numbered_title(input)
913    }
914
915    #[test]
916    fn test_extract_number_from_path() {
917        let path = Path::new("doc/adr/0001-use-rust.md");
918        assert_eq!(extract_number_from_path(path).unwrap(), 1);
919
920        let path = Path::new("0042-complex-decision.md");
921        assert_eq!(extract_number_from_path(path).unwrap(), 42);
922
923        let path = Path::new("9999-max-four-digit.md");
924        assert_eq!(extract_number_from_path(path).unwrap(), 9999);
925    }
926
927    #[test]
928    fn test_extract_number_from_path_invalid() {
929        let result = extract_number_from_path(Path::new("not-an-adr.md"));
930        assert!(result.is_err());
931
932        let result = extract_number_from_path(Path::new("1-too-few-digits.md"));
933        assert!(result.is_err());
934    }
935
936    #[test]
937    fn test_today() {
938        let date = today();
939        assert!(date.year() >= 2024);
940        assert!(date.month() as u8 >= 1 && date.month() as u8 <= 12);
941        assert!(date.day() >= 1 && date.day() <= 31);
942    }
943
944    #[test]
945    fn test_format_date() {
946        let date = Date::from_calendar_date(2024, Month::March, 5).unwrap();
947        assert_eq!(format_date(date), "2024-03-05");
948    }
949
950    #[test_case(2024, Month::January, 1 => "2024-01-01"; "new year")]
951    #[test_case(2024, Month::December, 31 => "2024-12-31"; "end of year")]
952    #[test_case(2000, Month::February, 29 => "2000-02-29"; "leap day")]
953    #[test_case(2024, Month::July, 15 => "2024-07-15"; "mid year")]
954    fn test_format_date_cases(year: i32, month: Month, day: u8) -> String {
955        let date = Date::from_calendar_date(year, month, day).unwrap();
956        format_date(date)
957    }
958
959    // ========== Edge Cases ==========
960
961    #[test]
962    fn test_parse_empty_content() {
963        let parser = Parser::new();
964        let adr = parser.parse("").unwrap();
965
966        assert_eq!(adr.number, 0);
967        assert!(adr.title.is_empty());
968    }
969
970    #[test]
971    fn test_parse_only_title() {
972        let content = "# 1. Just a Title";
973
974        let parser = Parser::new();
975        let adr = parser.parse(content).unwrap();
976
977        assert_eq!(adr.number, 1);
978        assert_eq!(adr.title, "Just a Title");
979    }
980
981    #[test]
982    fn test_parse_extra_sections_ignored() {
983        let content = r#"# 1. Test
984
985## Status
986
987Proposed
988
989## Context
990
991Context.
992
993## Decision
994
995Decision.
996
997## Consequences
998
999Consequences.
1000
1001## Notes
1002
1003These should be ignored.
1004
1005## References
1006
1007- ref1
1008- ref2
1009"#;
1010
1011        let parser = Parser::new();
1012        let adr = parser.parse(content).unwrap();
1013
1014        // Extra sections are ignored, main content is still parsed
1015        assert_eq!(adr.number, 1);
1016        assert_eq!(adr.status, AdrStatus::Proposed);
1017    }
1018
1019    #[test]
1020    fn test_parse_case_insensitive_sections() {
1021        let content = r#"# 1. Case Test
1022
1023## STATUS
1024
1025Accepted
1026
1027## CONTEXT
1028
1029Context.
1030
1031## DECISION
1032
1033Decision.
1034
1035## CONSEQUENCES
1036
1037Consequences.
1038"#;
1039
1040        let parser = Parser::new();
1041        let adr = parser.parse(content).unwrap();
1042
1043        // Sections should be matched case-insensitively
1044        assert_eq!(adr.status, AdrStatus::Accepted);
1045        assert_eq!(adr.context, "Context.");
1046    }
1047
1048    #[test]
1049    fn test_parse_content_with_markdown_formatting() {
1050        let content = r#"# 1. Formatted ADR
1051
1052## Status
1053
1054Accepted
1055
1056## Context
1057
1058We have **bold** and *italic* text.
1059
1060Also `code` and [links](https://example.com).
1061
1062## Decision
1063
1064```rust
1065fn main() {
1066    println!("Hello");
1067}
1068```
1069
1070## Consequences
1071
1072| Column 1 | Column 2 |
1073|----------|----------|
1074| Value 1  | Value 2  |
1075"#;
1076
1077        let parser = Parser::new();
1078        let adr = parser.parse(content).unwrap();
1079
1080        assert!(adr.context.contains("bold"));
1081        assert!(adr.decision.contains("fn main"));
1082        assert!(adr.consequences.contains("Column 1"));
1083    }
1084
1085    // ========== Regex Tests ==========
1086
1087    #[test]
1088    fn test_link_regex_pattern() {
1089        let content = "Supersedes [1. Use MySQL](0001-use-mysql.md)";
1090        let caps = LINK_REGEX.captures(content).unwrap();
1091
1092        assert_eq!(caps.get(1).unwrap().as_str(), "Supersedes");
1093        assert_eq!(caps.get(2).unwrap().as_str(), "1");
1094        assert_eq!(caps.get(3).unwrap().as_str(), "0001");
1095    }
1096
1097    #[test]
1098    fn test_link_regex_amended_by() {
1099        let content = "Amended by [3. Update API](0003-update-api.md)";
1100        let caps = LINK_REGEX.captures(content).unwrap();
1101
1102        assert_eq!(caps.get(1).unwrap().as_str(), "Amended by");
1103        assert_eq!(caps.get(2).unwrap().as_str(), "3");
1104    }
1105
1106    #[test]
1107    fn test_number_regex_pattern() {
1108        let filename = "0042-some-decision.md";
1109        let caps = NUMBER_REGEX.captures(filename).unwrap();
1110
1111        assert_eq!(caps.get(1).unwrap().as_str(), "0042");
1112    }
1113
1114    #[test]
1115    fn test_number_regex_no_match() {
1116        assert!(NUMBER_REGEX.captures("not-an-adr.md").is_none());
1117        assert!(NUMBER_REGEX.captures("01-short.md").is_none());
1118        assert!(NUMBER_REGEX.captures("00001-too-long.md").is_none());
1119    }
1120
1121    // ========== MADR 4.0.0 Frontmatter Tests ==========
1122
1123    #[test]
1124    fn test_parse_madr_frontmatter() {
1125        let content = r#"---
1126number: 1
1127title: Use MADR Format
1128date: 2024-09-15
1129status: accepted
1130decision-makers:
1131  - Alice
1132  - Bob
1133consulted:
1134  - Carol
1135informed:
1136  - Dave
1137  - Eve
1138---
1139
1140## Context and Problem Statement
1141
1142We need a standard format for ADRs.
1143
1144## Decision Outcome
1145
1146Chosen option: "MADR 4.0.0", because it provides rich metadata.
1147"#;
1148
1149        let parser = Parser::new();
1150        let adr = parser.parse(content).unwrap();
1151
1152        assert_eq!(adr.number, 1);
1153        assert_eq!(adr.title, "Use MADR Format");
1154        assert_eq!(adr.status, AdrStatus::Accepted);
1155        assert_eq!(adr.decision_makers, vec!["Alice", "Bob"]);
1156        assert_eq!(adr.consulted, vec!["Carol"]);
1157        assert_eq!(adr.informed, vec!["Dave", "Eve"]);
1158    }
1159
1160    #[test]
1161    fn test_parse_madr_frontmatter_partial_fields() {
1162        let content = r#"---
1163number: 2
1164title: Partial MADR
1165date: 2024-09-15
1166status: proposed
1167decision-makers:
1168  - Alice
1169---
1170
1171## Context
1172
1173Context.
1174"#;
1175
1176        let parser = Parser::new();
1177        let adr = parser.parse(content).unwrap();
1178
1179        assert_eq!(adr.decision_makers, vec!["Alice"]);
1180        assert!(adr.consulted.is_empty());
1181        assert!(adr.informed.is_empty());
1182    }
1183
1184    #[test]
1185    fn test_parse_madr_frontmatter_empty_fields() {
1186        let content = r#"---
1187number: 3
1188title: No MADR Fields
1189date: 2024-09-15
1190status: accepted
1191---
1192
1193## Context
1194
1195Context.
1196"#;
1197
1198        let parser = Parser::new();
1199        let adr = parser.parse(content).unwrap();
1200
1201        assert!(adr.decision_makers.is_empty());
1202        assert!(adr.consulted.is_empty());
1203        assert!(adr.informed.is_empty());
1204    }
1205
1206    #[test]
1207    fn test_parse_madr_with_links() {
1208        let content = r#"---
1209number: 4
1210title: MADR With Links
1211date: 2024-09-15
1212status: accepted
1213decision-makers:
1214  - Alice
1215links:
1216  - target: 1
1217    kind: supersedes
1218  - target: 2
1219    kind: amends
1220---
1221
1222## Context
1223
1224Context.
1225"#;
1226
1227        let parser = Parser::new();
1228        let adr = parser.parse(content).unwrap();
1229
1230        assert_eq!(adr.decision_makers, vec!["Alice"]);
1231        assert_eq!(adr.links.len(), 2);
1232        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
1233        assert_eq!(adr.links[1].kind, LinkKind::Amends);
1234    }
1235}