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