Skip to main content

adrs_core/
parse.rs

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