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