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