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    // ========== MADR Format Parsing ==========
811
812    #[test]
813    fn test_parse_madr_format() {
814        // MADR format with number and title in frontmatter
815        let content = r#"---
816number: 2
817title: Use Redis for caching
818status: proposed
819date: 2024-01-15
820---
821
822# Use Redis for caching
823
824## Context and Problem Statement
825
826We need a caching solution.
827
828## Decision Outcome
829
830We will use Redis.
831
832### Consequences
833
834* Good, because fast
835"#;
836
837        let parser = Parser::new();
838        let adr = parser.parse(content).unwrap();
839
840        assert_eq!(adr.number, 2);
841        assert_eq!(adr.title, "Use Redis for caching");
842        assert_eq!(adr.status, AdrStatus::Proposed);
843    }
844
845    #[test]
846    fn test_parse_madr_with_decision_makers() {
847        let content = r#"---
848number: 1
849title: Use MADR Format
850status: accepted
851date: 2024-01-01
852---
853
854# Use MADR Format
855
856## Context and Problem Statement
857
858Context.
859"#;
860
861        let parser = Parser::new();
862        let adr = parser.parse(content).unwrap();
863
864        assert_eq!(adr.number, 1);
865        assert_eq!(adr.title, "Use MADR Format");
866        assert_eq!(adr.status, AdrStatus::Accepted);
867    }
868
869    #[test]
870    fn test_parse_madr_missing_number_fails() {
871        // MADR without number field should fail
872        let content = r#"---
873title: Missing Number
874status: proposed
875date: 2024-01-01
876---
877
878# Missing Number
879
880## Context and Problem Statement
881
882Context.
883"#;
884
885        let parser = Parser::new();
886        let result = parser.parse(content);
887        // Should fail because number is required
888        assert!(result.is_err() || result.unwrap().number == 0);
889    }
890
891    // ========== File Parsing ==========
892
893    #[test]
894    fn test_parse_file_legacy() {
895        let temp = TempDir::new().unwrap();
896        let file_path = temp.path().join("0001-use-rust.md");
897
898        std::fs::write(
899            &file_path,
900            r#"# 1. Use Rust
901
902## Status
903
904Accepted
905
906## Context
907
908Context.
909
910## Decision
911
912Decision.
913
914## Consequences
915
916Consequences.
917"#,
918        )
919        .unwrap();
920
921        let parser = Parser::new();
922        let adr = parser.parse_file(&file_path).unwrap();
923
924        assert_eq!(adr.number, 1);
925        assert_eq!(adr.title, "Use Rust");
926        assert_eq!(adr.path, Some(file_path));
927    }
928
929    #[test]
930    fn test_parse_file_extracts_number_from_filename() {
931        let temp = TempDir::new().unwrap();
932        let file_path = temp.path().join("0042-some-decision.md");
933
934        // ADR without number in title
935        std::fs::write(
936            &file_path,
937            r#"# Some Decision
938
939## Status
940
941Proposed
942
943## Context
944
945Context.
946
947## Decision
948
949Decision.
950
951## Consequences
952
953Consequences.
954"#,
955        )
956        .unwrap();
957
958        let parser = Parser::new();
959        let adr = parser.parse_file(&file_path).unwrap();
960
961        assert_eq!(adr.number, 42);
962    }
963
964    #[test]
965    fn test_parse_file_nonexistent() {
966        let parser = Parser::new();
967        let result = parser.parse_file(Path::new("/nonexistent/path/0001-test.md"));
968        assert!(result.is_err());
969    }
970
971    // ========== Helper Function Tests ==========
972
973    #[test]
974    fn test_parse_numbered_title() {
975        assert_eq!(
976            parse_numbered_title("1. Use Rust"),
977            Some((1, "Use Rust".into()))
978        );
979        assert_eq!(
980            parse_numbered_title("42. Complex Decision"),
981            Some((42, "Complex Decision".into()))
982        );
983        assert_eq!(parse_numbered_title("Use Rust"), None);
984    }
985
986    #[test_case("1. Simple" => Some((1, "Simple".into())); "simple")]
987    #[test_case("123. Large Number" => Some((123, "Large Number".into())); "large number")]
988    #[test_case("1. With. Dots. In. Title" => Some((1, "With. Dots. In. Title".into())); "dots in title")]
989    #[test_case("No Number" => None; "no number")]
990    #[test_case("1 Missing Period" => None; "missing period")]
991    #[test_case(". Missing Number" => None; "missing number")]
992    fn test_parse_numbered_title_cases(input: &str) -> Option<(u32, String)> {
993        parse_numbered_title(input)
994    }
995
996    #[test]
997    fn test_extract_number_from_path() {
998        let path = Path::new("doc/adr/0001-use-rust.md");
999        assert_eq!(extract_number_from_path(path).unwrap(), 1);
1000
1001        let path = Path::new("0042-complex-decision.md");
1002        assert_eq!(extract_number_from_path(path).unwrap(), 42);
1003
1004        let path = Path::new("9999-max-four-digit.md");
1005        assert_eq!(extract_number_from_path(path).unwrap(), 9999);
1006    }
1007
1008    #[test]
1009    fn test_extract_number_from_path_invalid() {
1010        let result = extract_number_from_path(Path::new("not-an-adr.md"));
1011        assert!(result.is_err());
1012
1013        let result = extract_number_from_path(Path::new("1-too-few-digits.md"));
1014        assert!(result.is_err());
1015    }
1016
1017    #[test]
1018    fn test_today() {
1019        let date = today();
1020        assert!(date.year() >= 2024);
1021        assert!(date.month() as u8 >= 1 && date.month() as u8 <= 12);
1022        assert!(date.day() >= 1 && date.day() <= 31);
1023    }
1024
1025    #[test]
1026    fn test_format_date() {
1027        let date = Date::from_calendar_date(2024, Month::March, 5).unwrap();
1028        assert_eq!(format_date(date), "2024-03-05");
1029    }
1030
1031    #[test_case(2024, Month::January, 1 => "2024-01-01"; "new year")]
1032    #[test_case(2024, Month::December, 31 => "2024-12-31"; "end of year")]
1033    #[test_case(2000, Month::February, 29 => "2000-02-29"; "leap day")]
1034    #[test_case(2024, Month::July, 15 => "2024-07-15"; "mid year")]
1035    fn test_format_date_cases(year: i32, month: Month, day: u8) -> String {
1036        let date = Date::from_calendar_date(year, month, day).unwrap();
1037        format_date(date)
1038    }
1039
1040    // ========== Edge Cases ==========
1041
1042    #[test]
1043    fn test_parse_empty_content() {
1044        let parser = Parser::new();
1045        let adr = parser.parse("").unwrap();
1046
1047        assert_eq!(adr.number, 0);
1048        assert!(adr.title.is_empty());
1049    }
1050
1051    #[test]
1052    fn test_parse_only_title() {
1053        let content = "# 1. Just a Title";
1054
1055        let parser = Parser::new();
1056        let adr = parser.parse(content).unwrap();
1057
1058        assert_eq!(adr.number, 1);
1059        assert_eq!(adr.title, "Just a Title");
1060    }
1061
1062    #[test]
1063    fn test_parse_extra_sections_ignored() {
1064        let content = r#"# 1. Test
1065
1066## Status
1067
1068Proposed
1069
1070## Context
1071
1072Context.
1073
1074## Decision
1075
1076Decision.
1077
1078## Consequences
1079
1080Consequences.
1081
1082## Notes
1083
1084These should be ignored.
1085
1086## References
1087
1088- ref1
1089- ref2
1090"#;
1091
1092        let parser = Parser::new();
1093        let adr = parser.parse(content).unwrap();
1094
1095        // Extra sections are ignored, main content is still parsed
1096        assert_eq!(adr.number, 1);
1097        assert_eq!(adr.status, AdrStatus::Proposed);
1098    }
1099
1100    #[test]
1101    fn test_parse_case_insensitive_sections() {
1102        let content = r#"# 1. Case Test
1103
1104## STATUS
1105
1106Accepted
1107
1108## CONTEXT
1109
1110Context.
1111
1112## DECISION
1113
1114Decision.
1115
1116## CONSEQUENCES
1117
1118Consequences.
1119"#;
1120
1121        let parser = Parser::new();
1122        let adr = parser.parse(content).unwrap();
1123
1124        // Sections should be matched case-insensitively
1125        assert_eq!(adr.status, AdrStatus::Accepted);
1126        assert_eq!(adr.context, "Context.");
1127    }
1128
1129    #[test]
1130    fn test_parse_content_with_markdown_formatting() {
1131        let content = r#"# 1. Formatted ADR
1132
1133## Status
1134
1135Accepted
1136
1137## Context
1138
1139We have **bold** and *italic* text.
1140
1141Also `code` and [links](https://example.com).
1142
1143## Decision
1144
1145```rust
1146fn main() {
1147    println!("Hello");
1148}
1149```
1150
1151## Consequences
1152
1153| Column 1 | Column 2 |
1154|----------|----------|
1155| Value 1  | Value 2  |
1156"#;
1157
1158        let parser = Parser::new();
1159        let adr = parser.parse(content).unwrap();
1160
1161        assert!(adr.context.contains("bold"));
1162        assert!(adr.decision.contains("fn main"));
1163        assert!(adr.consequences.contains("Column 1"));
1164    }
1165
1166    // ========== Regex Tests ==========
1167
1168    #[test]
1169    fn test_link_regex_pattern() {
1170        let content = "Supersedes [1. Use MySQL](0001-use-mysql.md)";
1171        let caps = LINK_REGEX.captures(content).unwrap();
1172
1173        assert_eq!(caps.get(1).unwrap().as_str(), "Supersedes");
1174        assert_eq!(caps.get(2).unwrap().as_str(), "1");
1175        assert_eq!(caps.get(3).unwrap().as_str(), "0001");
1176    }
1177
1178    #[test]
1179    fn test_link_regex_amended_by() {
1180        let content = "Amended by [3. Update API](0003-update-api.md)";
1181        let caps = LINK_REGEX.captures(content).unwrap();
1182
1183        assert_eq!(caps.get(1).unwrap().as_str(), "Amended by");
1184        assert_eq!(caps.get(2).unwrap().as_str(), "3");
1185    }
1186
1187    #[test]
1188    fn test_number_regex_pattern() {
1189        let filename = "0042-some-decision.md";
1190        let caps = NUMBER_REGEX.captures(filename).unwrap();
1191
1192        assert_eq!(caps.get(1).unwrap().as_str(), "0042");
1193    }
1194
1195    #[test]
1196    fn test_number_regex_no_match() {
1197        assert!(NUMBER_REGEX.captures("not-an-adr.md").is_none());
1198        assert!(NUMBER_REGEX.captures("01-short.md").is_none());
1199        assert!(NUMBER_REGEX.captures("00001-too-long.md").is_none());
1200    }
1201
1202    // ========== MADR 4.0.0 Frontmatter Tests ==========
1203
1204    #[test]
1205    fn test_parse_madr_frontmatter() {
1206        let content = r#"---
1207number: 1
1208title: Use MADR Format
1209date: 2024-09-15
1210status: accepted
1211decision-makers:
1212  - Alice
1213  - Bob
1214consulted:
1215  - Carol
1216informed:
1217  - Dave
1218  - Eve
1219---
1220
1221## Context and Problem Statement
1222
1223We need a standard format for ADRs.
1224
1225## Decision Outcome
1226
1227Chosen option: "MADR 4.0.0", because it provides rich metadata.
1228"#;
1229
1230        let parser = Parser::new();
1231        let adr = parser.parse(content).unwrap();
1232
1233        assert_eq!(adr.number, 1);
1234        assert_eq!(adr.title, "Use MADR Format");
1235        assert_eq!(adr.status, AdrStatus::Accepted);
1236        assert_eq!(adr.decision_makers, vec!["Alice", "Bob"]);
1237        assert_eq!(adr.consulted, vec!["Carol"]);
1238        assert_eq!(adr.informed, vec!["Dave", "Eve"]);
1239    }
1240
1241    #[test]
1242    fn test_parse_madr_frontmatter_partial_fields() {
1243        let content = r#"---
1244number: 2
1245title: Partial MADR
1246date: 2024-09-15
1247status: proposed
1248decision-makers:
1249  - Alice
1250---
1251
1252## Context
1253
1254Context.
1255"#;
1256
1257        let parser = Parser::new();
1258        let adr = parser.parse(content).unwrap();
1259
1260        assert_eq!(adr.decision_makers, vec!["Alice"]);
1261        assert!(adr.consulted.is_empty());
1262        assert!(adr.informed.is_empty());
1263    }
1264
1265    #[test]
1266    fn test_parse_madr_frontmatter_empty_fields() {
1267        let content = r#"---
1268number: 3
1269title: No MADR Fields
1270date: 2024-09-15
1271status: accepted
1272---
1273
1274## Context
1275
1276Context.
1277"#;
1278
1279        let parser = Parser::new();
1280        let adr = parser.parse(content).unwrap();
1281
1282        assert!(adr.decision_makers.is_empty());
1283        assert!(adr.consulted.is_empty());
1284        assert!(adr.informed.is_empty());
1285    }
1286
1287    #[test]
1288    fn test_parse_madr_with_links() {
1289        let content = r#"---
1290number: 4
1291title: MADR With Links
1292date: 2024-09-15
1293status: accepted
1294decision-makers:
1295  - Alice
1296links:
1297  - target: 1
1298    kind: supersedes
1299  - target: 2
1300    kind: amends
1301---
1302
1303## Context
1304
1305Context.
1306"#;
1307
1308        let parser = Parser::new();
1309        let adr = parser.parse(content).unwrap();
1310
1311        assert_eq!(adr.decision_makers, vec!["Alice"]);
1312        assert_eq!(adr.links.len(), 2);
1313        assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
1314        assert_eq!(adr.links[1].kind, LinkKind::Amends);
1315    }
1316}