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> {
33 match section.trim().to_lowercase().as_str() {
34 "context" | "context and problem statement" => Some("context"),
35 "decision" | "decision outcome" => Some("decision"),
36 "consequences" => Some("consequences"),
37 _ => None,
38 }
39}
40
41#[derive(Debug, Default)]
43pub struct Parser {
44 _private: (),
45}
46
47impl Parser {
48 pub fn new() -> Self {
50 Self::default()
51 }
52
53 pub fn parse_file(&self, path: &Path) -> Result<Adr> {
55 let content = std::fs::read_to_string(path)?;
56 let mut adr = self.parse(&content)?;
57
58 if adr.number == 0 {
60 adr.number = extract_number_from_path(path)?;
61 }
62
63 adr.path = Some(path.to_path_buf());
64 Ok(adr)
65 }
66
67 pub fn parse(&self, content: &str) -> Result<Adr> {
69 let normalized;
74 let content: &str = if content.contains("\r\n") {
75 normalized = content.replace("\r\n", "\n");
76 &normalized
77 } else {
78 content
79 };
80
81 if content.starts_with("---\n") {
83 self.parse_frontmatter(content)
84 } else {
85 self.parse_legacy(content)
86 }
87 }
88
89 fn parse_frontmatter(&self, content: &str) -> Result<Adr> {
91 let parts: Vec<&str> = content.splitn(3, "---\n").collect();
92 if parts.len() < 3 {
93 return Err(Error::InvalidFormat {
94 path: Default::default(),
95 reason: "Invalid frontmatter format".into(),
96 });
97 }
98
99 let yaml = parts[1];
100 let body = parts[2];
101
102 let mut adr: Adr = serde_yaml_neo::from_str(yaml)?;
104
105 if adr.title.is_empty()
107 && let Some((num, title)) = extract_h1_title(body)
108 {
109 adr.title = title;
110 if adr.number == 0 {
111 adr.number = num;
112 }
113 }
114
115 let sections = self.parse_sections(body);
117 for (key, value) in §ions {
118 match canonical_section_field(key) {
119 Some("context") => adr.context = value.clone(),
120 Some("decision") => adr.decision = value.clone(),
121 Some("consequences") => adr.consequences = value.clone(),
122 _ => {}
123 }
124 }
125
126 Ok(adr)
127 }
128
129 fn parse_legacy(&self, content: &str) -> Result<Adr> {
131 let mut adr = Adr::new(0, "");
132
133 let sections = self.extract_sections_raw(content);
135
136 if let Some((num, title)) = extract_h1_title(content) {
138 adr.number = num;
139 adr.title = title;
140 }
141
142 if let Some(date) = extract_legacy_date(content) {
146 adr.date = date;
147 }
148
149 for (name, content) in §ions {
151 self.apply_section(&mut adr, name, content);
152 }
153
154 Ok(adr)
155 }
156
157 fn extract_sections_raw(&self, content: &str) -> Vec<(String, String)> {
169 let mut sections = Vec::new();
170 let mut current_section: Option<String> = None;
171 let mut current_field: Option<&'static str> = None;
172 let mut section_content = String::new();
173
174 let mut consequences_content = String::new();
176 let mut has_consequences_subsection = false;
177 let mut in_consequences = false;
178
179 for line in content.lines() {
180 if line.starts_with("## ") {
181 if let Some(ref name) = current_section {
183 sections.push((name.clone(), section_content.trim().to_string()));
184 }
185 if has_consequences_subsection {
186 sections.push((
187 "consequences".to_string(),
188 consequences_content.trim().to_string(),
189 ));
190 }
191 current_section = Some(line.trim_start_matches("## ").trim().to_lowercase());
192 current_field = canonical_section_field(current_section.as_deref().unwrap_or(""));
193 section_content.clear();
194 consequences_content.clear();
195 has_consequences_subsection = false;
196 in_consequences = false;
197 } else if line.starts_with("### ") {
198 let heading = line.trim_start_matches("### ").trim();
199 in_consequences = current_field == Some("decision")
202 && canonical_section_field(heading) == Some("consequences");
203 if in_consequences {
204 has_consequences_subsection = true;
205 } else if current_section.is_some() {
206 section_content.push_str(line);
207 section_content.push('\n');
208 }
209 } else if in_consequences {
210 consequences_content.push_str(line);
211 consequences_content.push('\n');
212 } else if current_section.is_some() {
213 section_content.push_str(line);
214 section_content.push('\n');
215 }
216 }
217
218 if let Some(ref name) = current_section {
220 sections.push((name.clone(), section_content.trim().to_string()));
221 }
222 if has_consequences_subsection {
223 sections.push((
224 "consequences".to_string(),
225 consequences_content.trim().to_string(),
226 ));
227 }
228
229 sections
230 }
231
232 fn apply_section(&self, adr: &mut Adr, section: &str, content: &str) {
234 let content = content.trim().to_string();
235 if section == "status" {
236 self.parse_status_section(adr, &content);
237 return;
238 }
239 match canonical_section_field(section) {
240 Some("context") => adr.context = content,
241 Some("decision") => adr.decision = content,
242 Some("consequences") => adr.consequences = content,
243 _ => {}
244 }
245 }
246
247 fn parse_status_section(&self, adr: &mut Adr, content: &str) {
249 for line in content.lines() {
250 let line = line.trim();
251 if line.is_empty() {
252 continue;
253 }
254
255 if let Some(caps) = LINK_REGEX.captures(line) {
257 let kind_str = caps.get(1).map(|m| m.as_str()).unwrap_or("");
258 let target: u32 = caps
259 .get(2)
260 .and_then(|m| m.as_str().parse().ok())
261 .unwrap_or(0);
262
263 if target > 0 {
264 let kind: LinkKind = kind_str.trim().parse().unwrap_or(LinkKind::RelatesTo);
265
266 if matches!(kind, LinkKind::SupersededBy) {
269 adr.status = AdrStatus::Superseded;
270 }
271
272 adr.links.push(AdrLink::new(target, kind));
273 }
274 } else if !line.contains('[') && !line.contains(']') {
275 let word = line.split_whitespace().next().unwrap_or("");
278 if matches!(
279 word.to_lowercase().as_str(),
280 "proposed"
282 | "accepted"
283 | "deprecated"
284 | "superseded"
285 | "superceded"
286 | "draft"
287 | "rejected"
288 ) {
289 adr.status = word.parse().unwrap_or(AdrStatus::Proposed);
290 }
291 }
292 }
293 }
294
295 fn parse_sections(&self, content: &str) -> std::collections::HashMap<String, String> {
310 let mut sections = std::collections::HashMap::new();
311 let mut current_section: Option<String> = None;
312 let mut current_field: Option<&'static str> = None;
313 let mut section_content = String::new();
314
315 let mut consequences_content: Option<String> = None;
319 let mut in_consequences = false;
320
321 let mut heading_level: Option<HeadingLevel> = None;
326 let mut heading_text = String::new();
327
328 let mut list_stack: Vec<Option<u64>> = Vec::new();
334
335 let parser = MdParser::new(content);
336
337 for event in parser {
338 match event {
339 Event::Start(Tag::Heading { level, .. }) => match level {
340 HeadingLevel::H2 => {
341 if let Some(text) = consequences_content.take() {
342 sections.insert("consequences".to_string(), text.trim().to_string());
343 }
344 if let Some(ref section) = current_section {
345 sections.insert(section.clone(), section_content.trim().to_string());
346 }
347 section_content.clear();
348 current_field = None;
349 in_consequences = false;
350 heading_level = Some(HeadingLevel::H2);
351 heading_text.clear();
352 }
353 HeadingLevel::H3 => {
354 in_consequences = false;
358 heading_level = Some(HeadingLevel::H3);
359 heading_text.clear();
360 }
361 _ => {}
362 },
363 Event::End(TagEnd::Heading(level)) => match level {
364 HeadingLevel::H2 => {
365 current_section = Some(heading_text.trim().to_lowercase());
366 current_field = canonical_section_field(&heading_text);
367 heading_level = None;
368 }
369 HeadingLevel::H3 => {
370 if current_field == Some("decision")
371 && canonical_section_field(&heading_text) == Some("consequences")
372 {
373 in_consequences = true;
374 consequences_content.get_or_insert_with(String::new);
375 } else {
376 section_content.push_str(&heading_text);
377 }
378 heading_level = None;
379 }
380 _ => {
381 heading_level = None;
382 }
383 },
384 Event::Text(text) => {
385 if heading_level.is_some() {
386 heading_text.push_str(&text);
387 } else if in_consequences {
388 consequences_content
389 .get_or_insert_with(String::new)
390 .push_str(&text);
391 } else {
392 section_content.push_str(&text);
393 }
394 }
395 Event::SoftBreak | Event::HardBreak if heading_level.is_none() => {
396 if in_consequences {
397 consequences_content
398 .get_or_insert_with(String::new)
399 .push('\n');
400 } else {
401 section_content.push('\n');
402 }
403 }
404 Event::Start(Tag::List(first)) => list_stack.push(first),
405 Event::End(TagEnd::List(_)) => {
406 list_stack.pop();
407 }
408 Event::Start(Tag::Item) if heading_level.is_none() => {
409 let marker = match list_stack.last_mut() {
410 Some(Some(n)) => {
411 let m = format!("{n}. ");
412 *n += 1;
413 m
414 }
415 _ => "- ".to_string(),
416 };
417 let buf = if in_consequences {
418 consequences_content.get_or_insert_with(String::new)
419 } else {
420 &mut section_content
421 };
422 if !buf.is_empty() && !buf.ends_with('\n') {
423 buf.push('\n');
424 }
425 buf.push_str(&marker);
426 }
427 _ => {}
428 }
429 }
430
431 if let Some(text) = consequences_content.take() {
432 sections.insert("consequences".to_string(), text.trim().to_string());
433 }
434 if let Some(ref section) = current_section {
435 sections.insert(section.clone(), section_content.trim().to_string());
436 }
437
438 sections
439 }
440}
441
442fn extract_h1_title(content: &str) -> Option<(u32, String)> {
447 let title_line = content.lines().find(|l| l.starts_with("# "))?;
448 let title_str = title_line.trim_start_matches("# ").trim();
449 if title_str.is_empty() {
450 return None;
451 }
452 if let Some((num, title)) = parse_numbered_title(title_str) {
453 Some((num, title))
454 } else {
455 Some((0, title_str.to_string()))
456 }
457}
458
459fn extract_legacy_date(content: &str) -> Option<Date> {
467 for line in content.lines() {
468 if line.starts_with("## ") {
469 break;
470 }
471 if let Some(rest) = line.trim().strip_prefix("Date:") {
472 return Date::parse(rest.trim(), &Iso8601::DATE).ok();
473 }
474 }
475 None
476}
477
478fn parse_numbered_title(title: &str) -> Option<(u32, String)> {
480 let parts: Vec<&str> = title.splitn(2, ". ").collect();
481 if parts.len() == 2
482 && let Ok(num) = parts[0].parse::<u32>()
483 {
484 return Some((num, parts[1].to_string()));
485 }
486 None
487}
488
489fn extract_number_from_path(path: &Path) -> Result<u32> {
491 let filename =
492 path.file_name()
493 .and_then(|n| n.to_str())
494 .ok_or_else(|| Error::InvalidFormat {
495 path: path.to_path_buf(),
496 reason: "Invalid filename".into(),
497 })?;
498
499 NUMBER_REGEX
500 .captures(filename)
501 .and_then(|caps| caps.get(1))
502 .and_then(|m| m.as_str().parse().ok())
503 .ok_or_else(|| Error::InvalidFormat {
504 path: path.to_path_buf(),
505 reason: "Cannot extract ADR number from filename".into(),
506 })
507}
508
509pub fn today() -> Date {
511 let now = OffsetDateTime::now_utc();
512 Date::from_calendar_date(now.year(), now.month(), now.day()).unwrap_or_else(|_| {
513 Date::from_calendar_date(2024, Month::January, 1).unwrap()
515 })
516}
517
518pub fn format_date(date: Date) -> String {
520 format!(
521 "{:04}-{:02}-{:02}",
522 date.year(),
523 date.month() as u8,
524 date.day()
525 )
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use tempfile::TempDir;
532 use test_case::test_case;
533
534 #[test]
537 fn test_parser_new() {
538 let _parser = Parser::new();
539 }
541
542 #[test]
543 fn test_parser_default() {
544 let _parser = Parser::default();
545 }
546
547 #[test]
550 fn test_parse_legacy_format() {
551 let content = r#"# 1. Use Rust
552
553## Status
554
555Accepted
556
557## Context
558
559We need a systems programming language.
560
561## Decision
562
563We will use Rust.
564
565## Consequences
566
567We get memory safety without garbage collection.
568"#;
569
570 let parser = Parser::new();
571 let adr = parser.parse(content).unwrap();
572
573 assert_eq!(adr.number, 1);
574 assert_eq!(adr.title, "Use Rust");
575 assert_eq!(adr.status, AdrStatus::Accepted);
576 assert!(adr.context.contains("systems programming"));
577 assert!(adr.decision.contains("use Rust"));
578 assert!(adr.consequences.contains("memory safety"));
579 }
580
581 #[test]
582 fn test_parse_legacy_minimal() {
583 let content = r#"# 1. Minimal ADR
584
585## Status
586
587Proposed
588
589## Context
590
591Context.
592
593## Decision
594
595Decision.
596
597## Consequences
598
599Consequences.
600"#;
601
602 let parser = Parser::new();
603 let adr = parser.parse(content).unwrap();
604
605 assert_eq!(adr.number, 1);
606 assert_eq!(adr.title, "Minimal ADR");
607 assert_eq!(adr.status, AdrStatus::Proposed);
608 assert_eq!(adr.context, "Context.");
609 assert_eq!(adr.decision, "Decision.");
610 assert_eq!(adr.consequences, "Consequences.");
611 }
612
613 #[test]
614 fn test_parse_legacy_multiline_sections() {
615 let content = r#"# 1. Multiline Test
616
617## Status
618
619Accepted
620
621## Context
622
623This is a context section
624that spans multiple lines.
625
626With paragraphs too.
627
628## Decision
629
630This is the decision.
631Also multiple lines.
632
633## Consequences
634
635- Point 1
636- Point 2
637- Point 3
638"#;
639
640 let parser = Parser::new();
641 let adr = parser.parse(content).unwrap();
642
643 assert!(adr.context.contains("multiple lines"));
644 assert!(adr.context.contains("paragraphs"));
645 assert!(adr.decision.contains("Also multiple lines"));
646 assert!(adr.consequences.contains("Point 1"));
647 assert!(adr.consequences.contains("Point 2"));
648 }
649
650 #[test_case("Proposed" => AdrStatus::Proposed; "proposed")]
651 #[test_case("Accepted" => AdrStatus::Accepted; "accepted")]
652 #[test_case("Deprecated" => AdrStatus::Deprecated; "deprecated")]
653 #[test_case("Superseded" => AdrStatus::Superseded; "superseded")]
654 #[test_case("Draft" => AdrStatus::Custom("Draft".into()); "draft")]
655 #[test_case("Rejected" => AdrStatus::Custom("Rejected".into()); "rejected")]
656 fn test_parse_legacy_status_types(status: &str) -> AdrStatus {
657 let content = format!(
658 r#"# 1. Test
659
660## Status
661
662{status}
663
664## Context
665
666Context.
667
668## Decision
669
670Decision.
671
672## Consequences
673
674Consequences.
675"#
676 );
677
678 let parser = Parser::new();
679 let adr = parser.parse(&content).unwrap();
680 adr.status
681 }
682
683 #[test]
684 fn test_parse_legacy_with_date_line() {
685 let content = r#"# 1. Record architecture decisions
686
687Date: 2024-01-15
688
689## Status
690
691Accepted
692
693## Context
694
695Context.
696
697## Decision
698
699Decision.
700
701## Consequences
702
703Consequences.
704"#;
705
706 let parser = Parser::new();
707 let adr = parser.parse(content).unwrap();
708
709 assert_eq!(adr.number, 1);
710 assert_eq!(adr.title, "Record architecture decisions");
711 assert_eq!(adr.status, AdrStatus::Accepted);
712 }
713
714 #[test]
715 fn test_parse_legacy_title_without_number() {
716 let content = r#"# Use Rust
717
718## Status
719
720Proposed
721
722## Context
723
724Context.
725
726## Decision
727
728Decision.
729
730## Consequences
731
732Consequences.
733"#;
734
735 let parser = Parser::new();
736 let adr = parser.parse(content).unwrap();
737
738 assert_eq!(adr.number, 0);
739 assert_eq!(adr.title, "Use Rust");
740 }
741
742 #[test]
743 fn test_parse_legacy_status_with_links() {
744 let content = r#"# 2. Use PostgreSQL
745
746## Status
747
748Accepted
749
750Supersedes [1. Use MySQL](0001-use-mysql.md)
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.status, AdrStatus::Accepted);
769 assert_eq!(adr.links.len(), 1);
770 assert_eq!(adr.links[0].target, 1);
771 assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
772 }
773
774 #[test]
775 fn test_parse_legacy_multiple_links() {
776 let content = r#"# 5. Combined Decision
777
778## Status
779
780Accepted
781
782Supersedes [1. First](0001-first.md)
783Supersedes [2. Second](0002-second.md)
784Amends [3. Third](0003-third.md)
785
786## Context
787
788Context.
789
790## Decision
791
792Decision.
793
794## Consequences
795
796Consequences.
797"#;
798
799 let parser = Parser::new();
800 let adr = parser.parse(content).unwrap();
801
802 assert_eq!(adr.links.len(), 3);
803 assert_eq!(adr.links[0].target, 1);
804 assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
805 assert_eq!(adr.links[1].target, 2);
806 assert_eq!(adr.links[1].kind, LinkKind::Supersedes);
807 assert_eq!(adr.links[2].target, 3);
808 assert_eq!(adr.links[2].kind, LinkKind::Amends);
809 }
810
811 #[test]
812 fn test_parse_superseded_status() {
813 let content = r#"# 1. Record architecture decisions
814
815Date: 2026-01-22
816
817## Status
818
819Superseded
820
821Superseded by [2. ...](0002-....md)
822
823## Context
824
825Some context.
826
827## Decision
828
829Some decision.
830
831## Consequences
832
833Some consequences.
834"#;
835
836 let parser = Parser::new();
837 let adr = parser.parse(content).unwrap();
838
839 assert_eq!(adr.number, 1);
840 assert_eq!(adr.status, AdrStatus::Superseded);
841 }
842
843 #[test]
846 fn test_parse_frontmatter_format() {
847 let content = r#"---
848number: 2
849title: Use PostgreSQL
850date: 2024-01-15
851status: accepted
852links:
853 - target: 1
854 kind: supersedes
855---
856
857## Context
858
859We need a database.
860
861## Decision
862
863We will use PostgreSQL.
864
865## Consequences
866
867We get ACID compliance.
868"#;
869
870 let parser = Parser::new();
871 let adr = parser.parse(content).unwrap();
872
873 assert_eq!(adr.number, 2);
874 assert_eq!(adr.title, "Use PostgreSQL");
875 assert_eq!(adr.status, AdrStatus::Accepted);
876 assert_eq!(adr.links.len(), 1);
877 assert_eq!(adr.links[0].target, 1);
878 assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
879 }
880
881 #[test]
882 fn test_parse_frontmatter_minimal() {
883 let content = r#"---
884number: 1
885title: Simple ADR
886date: 2024-01-01
887status: proposed
888---
889
890## Context
891
892Context.
893
894## Decision
895
896Decision.
897
898## Consequences
899
900Consequences.
901"#;
902
903 let parser = Parser::new();
904 let adr = parser.parse(content).unwrap();
905
906 assert_eq!(adr.number, 1);
907 assert_eq!(adr.title, "Simple ADR");
908 assert_eq!(adr.status, AdrStatus::Proposed);
909 }
910
911 #[test]
912 fn test_parse_frontmatter_no_links() {
913 let content = r#"---
914number: 1
915title: Test ADR
916date: 2024-01-01
917status: accepted
918---
919
920## Context
921
922Context.
923
924## Decision
925
926Decision.
927
928## Consequences
929
930Consequences.
931"#;
932
933 let parser = Parser::new();
934 let adr = parser.parse(content).unwrap();
935
936 assert!(adr.links.is_empty());
937 }
938
939 #[test]
940 fn test_parse_frontmatter_multiple_links() {
941 let content = r#"---
942number: 5
943title: Multi Link ADR
944date: 2024-01-01
945status: accepted
946links:
947 - target: 1
948 kind: supersedes
949 - target: 2
950 kind: amends
951 - target: 3
952 kind: relatesto
953---
954
955## Context
956
957Context.
958
959## Decision
960
961Decision.
962
963## Consequences
964
965Consequences.
966"#;
967
968 let parser = Parser::new();
969 let adr = parser.parse(content).unwrap();
970
971 assert_eq!(adr.links.len(), 3);
972 assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
973 assert_eq!(adr.links[1].kind, LinkKind::Amends);
974 assert_eq!(adr.links[2].kind, LinkKind::RelatesTo);
975 }
976
977 #[test]
978 fn test_parse_frontmatter_all_statuses() {
979 for (status_str, expected) in [
980 ("proposed", AdrStatus::Proposed),
981 ("accepted", AdrStatus::Accepted),
982 ("deprecated", AdrStatus::Deprecated),
983 ("superseded", AdrStatus::Superseded),
984 ] {
985 let content = format!(
986 r#"---
987number: 1
988title: Test
989date: 2024-01-01
990status: {status_str}
991---
992
993## Context
994
995Context.
996"#
997 );
998
999 let parser = Parser::new();
1000 let adr = parser.parse(&content).unwrap();
1001 assert_eq!(adr.status, expected, "Failed for status: {status_str}");
1002 }
1003 }
1004
1005 #[test]
1006 fn test_parse_frontmatter_invalid_format() {
1007 let content = r#"---
1008not valid yaml {{{{
1009---
1010
1011## Context
1012
1013Context.
1014"#;
1015
1016 let parser = Parser::new();
1017 let result = parser.parse(content);
1018 assert!(result.is_err());
1019 }
1020
1021 #[test]
1022 fn test_parse_frontmatter_incomplete() {
1023 let content = r#"---
1024number: 1
1025title: Test
1026"#;
1027
1028 let parser = Parser::new();
1029 let result = parser.parse(content);
1030 assert!(result.is_err());
1031 }
1032
1033 #[test]
1036 fn test_parse_madr_format() {
1037 let content = r#"---
1039number: 2
1040title: Use Redis for caching
1041status: proposed
1042date: 2024-01-15
1043---
1044
1045# Use Redis for caching
1046
1047## Context and Problem Statement
1048
1049We need a caching solution.
1050
1051## Decision Outcome
1052
1053We will use Redis.
1054
1055### Consequences
1056
1057* Good, because fast
1058"#;
1059
1060 let parser = Parser::new();
1061 let adr = parser.parse(content).unwrap();
1062
1063 assert_eq!(adr.number, 2);
1064 assert_eq!(adr.title, "Use Redis for caching");
1065 assert_eq!(adr.status, AdrStatus::Proposed);
1066 assert!(adr.context.contains("caching solution"));
1067 assert!(adr.decision.contains("use Redis"));
1068 }
1069
1070 #[test]
1071 fn test_parse_madr_with_decision_makers() {
1072 let content = r#"---
1073number: 1
1074title: Use MADR Format
1075status: accepted
1076date: 2024-01-01
1077---
1078
1079# Use MADR Format
1080
1081## Context and Problem Statement
1082
1083Context.
1084"#;
1085
1086 let parser = Parser::new();
1087 let adr = parser.parse(content).unwrap();
1088
1089 assert_eq!(adr.number, 1);
1090 assert_eq!(adr.title, "Use MADR Format");
1091 assert_eq!(adr.status, AdrStatus::Accepted);
1092 assert_eq!(adr.context, "Context.");
1093 }
1094
1095 #[test]
1096 fn test_parse_madr_frontmatter_populates_body_sections() {
1097 let content = r#"---
1098number: 1
1099title: Use MADR Format
1100date: 2024-09-15
1101status: accepted
1102---
1103
1104## Context and Problem Statement
1105
1106We need a standard format for ADRs.
1107
1108## Decision Outcome
1109
1110Chosen option: "MADR 4.0.0", because it provides rich metadata.
1111"#;
1112
1113 let parser = Parser::new();
1114 let adr = parser.parse(content).unwrap();
1115
1116 assert!(adr.context.contains("standard format"));
1117 assert!(adr.decision.contains("MADR 4.0.0"));
1118 }
1119
1120 #[test]
1123 fn test_parse_sections_bullet_items_keep_separator() {
1124 let content = r#"---
1128number: 4
1129title: Use MADR format for ADRs
1130status: accepted
1131date: 2024-02-15
1132---
1133
1134## Consequences
1135
1136- Better tooling support with structured metadata
1137- Clear tracking of decision-makers and stakeholders
1138"#;
1139
1140 let parser = Parser::new();
1141 let adr = parser.parse(content).unwrap();
1142
1143 assert!(
1144 !adr.consequences.contains("metadataClear"),
1145 "list items concatenated without a separator:\n{}",
1146 adr.consequences
1147 );
1148 assert_eq!(
1149 adr.consequences,
1150 "- Better tooling support with structured metadata\n\
1151 - Clear tracking of decision-makers and stakeholders"
1152 );
1153 }
1154
1155 #[test]
1156 fn test_parse_sections_h3_consequences_bullet_items_keep_separator() {
1157 let content = r#"---
1160number: 2
1161title: Use Redis for caching
1162status: proposed
1163date: 2024-01-15
1164---
1165
1166## Decision Outcome
1167
1168Chosen option: "Redis".
1169
1170### Consequences
1171
1172- Stateless authentication reduces database load
1173- Refresh token rotation improves security
1174"#;
1175
1176 let parser = Parser::new();
1177 let adr = parser.parse(content).unwrap();
1178
1179 assert!(
1180 !adr.consequences.contains("loadRefresh"),
1181 "H3 list items concatenated without a separator:\n{}",
1182 adr.consequences
1183 );
1184 assert_eq!(
1185 adr.consequences,
1186 "- Stateless authentication reduces database load\n\
1187 - Refresh token rotation improves security"
1188 );
1189 }
1190
1191 #[test]
1192 fn test_parse_sections_ordered_list_reemits_numbers() {
1193 let content = r#"---
1195number: 5
1196title: Ordered steps
1197status: accepted
1198date: 2024-02-15
1199---
1200
1201## Context
1202
12031. First step
12042. Second step
12053. Third step
1206"#;
1207
1208 let parser = Parser::new();
1209 let adr = parser.parse(content).unwrap();
1210
1211 assert_eq!(adr.context, "1. First step\n2. Second step\n3. Third step");
1212 }
1213
1214 #[test]
1217 fn test_parse_madr_h3_consequences_excluded_from_decision() {
1218 let content = r#"---
1221number: 2
1222title: Use Redis for caching
1223date: 2024-01-15
1224status: proposed
1225---
1226
1227## Context and Problem Statement
1228
1229We need a caching solution.
1230
1231## Decision Outcome
1232
1233Chosen option: "Redis", because it is fast.
1234
1235### Consequences
1236
1237* Good, because it reduces database load
1238* Bad, because it adds operational complexity
1239"#;
1240
1241 let parser = Parser::new();
1242 let adr = parser.parse(content).unwrap();
1243
1244 assert_eq!(
1245 adr.decision,
1246 "Chosen option: \"Redis\", because it is fast."
1247 );
1248 assert!(
1249 !adr.decision.contains("Good, because it reduces"),
1250 "consequences text leaked into decision:\n{}",
1251 adr.decision
1252 );
1253 assert!(
1254 !adr.decision.to_lowercase().contains("consequences"),
1255 "Consequences heading text leaked into decision:\n{}",
1256 adr.decision
1257 );
1258 assert!(
1259 adr.consequences
1260 .contains("Good, because it reduces database load")
1261 );
1262 assert!(
1263 adr.consequences
1264 .contains("Bad, because it adds operational complexity")
1265 );
1266 }
1267
1268 #[test]
1269 fn test_parse_madr_h3_confirmation_stays_in_decision() {
1270 let content = r#"---
1274number: 2
1275title: Use Redis for caching
1276date: 2024-01-15
1277status: proposed
1278---
1279
1280## Decision Outcome
1281
1282Chosen option: "Redis", because it is fast.
1283
1284### Consequences
1285
1286* Good, because it reduces database load
1287
1288### Confirmation
1289
1290We will confirm via load tests.
1291"#;
1292
1293 let parser = Parser::new();
1294 let adr = parser.parse(content).unwrap();
1295
1296 assert!(adr.decision.contains("Chosen option: \"Redis\""));
1297 assert!(
1298 adr.decision.contains("We will confirm via load tests."),
1299 "Confirmation subsection should stay part of decision:\n{}",
1300 adr.decision
1301 );
1302 assert!(
1303 !adr.decision.contains("Good, because it reduces"),
1304 "consequences text must not appear in decision:\n{}",
1305 adr.decision
1306 );
1307 assert_eq!(adr.consequences, "- Good, because it reduces database load");
1310 }
1311
1312 #[test]
1313 fn test_parse_nygard_consequences_h2_and_no_h3_still_works() {
1314 let content = r#"---
1317number: 2
1318title: Use PostgreSQL
1319date: 2024-01-15
1320status: accepted
1321---
1322
1323## Context
1324
1325We need a database.
1326
1327## Decision
1328
1329We will use PostgreSQL.
1330
1331## Consequences
1332
1333We get ACID compliance.
1334"#;
1335
1336 let parser = Parser::new();
1337 let adr = parser.parse(content).unwrap();
1338
1339 assert_eq!(adr.decision, "We will use PostgreSQL.");
1340 assert_eq!(adr.consequences, "We get ACID compliance.");
1341 }
1342
1343 #[test]
1344 fn test_parse_legacy_madr_h3_consequences_excluded_from_decision() {
1345 let content = r#"# Use Redis for caching
1349
1350## Context and Problem Statement
1351
1352We need a caching solution.
1353
1354## Decision Outcome
1355
1356Chosen option: "Redis", because it is fast.
1357
1358### Consequences
1359
1360* Good, because it reduces database load
1361
1362### Confirmation
1363
1364We will confirm via load tests.
1365"#;
1366
1367 let parser = Parser::new();
1368 let adr = parser.parse(content).unwrap();
1369
1370 assert!(
1371 adr.decision
1372 .contains("Chosen option: \"Redis\", because it is fast.")
1373 );
1374 assert!(
1378 adr.decision.contains("### Confirmation"),
1379 "Confirmation subsection should stay part of decision:\n{}",
1380 adr.decision
1381 );
1382 assert!(adr.decision.contains("We will confirm via load tests."));
1383 assert!(
1384 !adr.decision.contains("Good, because it reduces"),
1385 "consequences text must not leak into legacy-path decision:\n{}",
1386 adr.decision
1387 );
1388 assert!(
1389 adr.consequences
1390 .contains("Good, because it reduces database load")
1391 );
1392 }
1393
1394 #[test]
1395 fn test_parse_madr_missing_number_fails() {
1396 let content = r#"---
1398title: Missing Number
1399status: proposed
1400date: 2024-01-01
1401---
1402
1403# Missing Number
1404
1405## Context and Problem Statement
1406
1407Context.
1408"#;
1409
1410 let parser = Parser::new();
1411 let result = parser.parse(content);
1412 assert!(result.is_err() || result.unwrap().number == 0);
1414 }
1415
1416 #[test]
1419 fn test_parse_file_legacy() {
1420 let temp = TempDir::new().unwrap();
1421 let file_path = temp.path().join("0001-use-rust.md");
1422
1423 std::fs::write(
1424 &file_path,
1425 r#"# 1. Use Rust
1426
1427## Status
1428
1429Accepted
1430
1431## Context
1432
1433Context.
1434
1435## Decision
1436
1437Decision.
1438
1439## Consequences
1440
1441Consequences.
1442"#,
1443 )
1444 .unwrap();
1445
1446 let parser = Parser::new();
1447 let adr = parser.parse_file(&file_path).unwrap();
1448
1449 assert_eq!(adr.number, 1);
1450 assert_eq!(adr.title, "Use Rust");
1451 assert_eq!(adr.path, Some(file_path));
1452 }
1453
1454 #[test]
1455 fn test_parse_file_extracts_number_from_filename() {
1456 let temp = TempDir::new().unwrap();
1457 let file_path = temp.path().join("0042-some-decision.md");
1458
1459 std::fs::write(
1461 &file_path,
1462 r#"# Some Decision
1463
1464## Status
1465
1466Proposed
1467
1468## Context
1469
1470Context.
1471
1472## Decision
1473
1474Decision.
1475
1476## Consequences
1477
1478Consequences.
1479"#,
1480 )
1481 .unwrap();
1482
1483 let parser = Parser::new();
1484 let adr = parser.parse_file(&file_path).unwrap();
1485
1486 assert_eq!(adr.number, 42);
1487 }
1488
1489 #[test]
1490 fn test_parse_file_nonexistent() {
1491 let parser = Parser::new();
1492 let result = parser.parse_file(Path::new("/nonexistent/path/0001-test.md"));
1493 assert!(result.is_err());
1494 }
1495
1496 #[test]
1499 fn test_parse_numbered_title() {
1500 assert_eq!(
1501 parse_numbered_title("1. Use Rust"),
1502 Some((1, "Use Rust".into()))
1503 );
1504 assert_eq!(
1505 parse_numbered_title("42. Complex Decision"),
1506 Some((42, "Complex Decision".into()))
1507 );
1508 assert_eq!(parse_numbered_title("Use Rust"), None);
1509 }
1510
1511 #[test_case("1. Simple" => Some((1, "Simple".into())); "simple")]
1512 #[test_case("123. Large Number" => Some((123, "Large Number".into())); "large number")]
1513 #[test_case("1. With. Dots. In. Title" => Some((1, "With. Dots. In. Title".into())); "dots in title")]
1514 #[test_case("No Number" => None; "no number")]
1515 #[test_case("1 Missing Period" => None; "missing period")]
1516 #[test_case(". Missing Number" => None; "missing number")]
1517 fn test_parse_numbered_title_cases(input: &str) -> Option<(u32, String)> {
1518 parse_numbered_title(input)
1519 }
1520
1521 #[test]
1522 fn test_extract_number_from_path() {
1523 let path = Path::new("doc/adr/0001-use-rust.md");
1524 assert_eq!(extract_number_from_path(path).unwrap(), 1);
1525
1526 let path = Path::new("0042-complex-decision.md");
1527 assert_eq!(extract_number_from_path(path).unwrap(), 42);
1528
1529 let path = Path::new("9999-max-four-digit.md");
1530 assert_eq!(extract_number_from_path(path).unwrap(), 9999);
1531 }
1532
1533 #[test]
1534 fn test_extract_number_from_path_invalid() {
1535 let result = extract_number_from_path(Path::new("not-an-adr.md"));
1536 assert!(result.is_err());
1537
1538 let result = extract_number_from_path(Path::new("1-too-few-digits.md"));
1539 assert!(result.is_err());
1540 }
1541
1542 #[test]
1543 fn test_today() {
1544 let date = today();
1545 assert!(date.year() >= 2024);
1546 assert!(date.month() as u8 >= 1 && date.month() as u8 <= 12);
1547 assert!(date.day() >= 1 && date.day() <= 31);
1548 }
1549
1550 #[test]
1551 fn test_format_date() {
1552 let date = Date::from_calendar_date(2024, Month::March, 5).unwrap();
1553 assert_eq!(format_date(date), "2024-03-05");
1554 }
1555
1556 #[test_case(2024, Month::January, 1 => "2024-01-01"; "new year")]
1557 #[test_case(2024, Month::December, 31 => "2024-12-31"; "end of year")]
1558 #[test_case(2000, Month::February, 29 => "2000-02-29"; "leap day")]
1559 #[test_case(2024, Month::July, 15 => "2024-07-15"; "mid year")]
1560 fn test_format_date_cases(year: i32, month: Month, day: u8) -> String {
1561 let date = Date::from_calendar_date(year, month, day).unwrap();
1562 format_date(date)
1563 }
1564
1565 #[test]
1568 fn test_parse_empty_content() {
1569 let parser = Parser::new();
1570 let adr = parser.parse("").unwrap();
1571
1572 assert_eq!(adr.number, 0);
1573 assert!(adr.title.is_empty());
1574 }
1575
1576 #[test]
1577 fn test_parse_only_title() {
1578 let content = "# 1. Just a Title";
1579
1580 let parser = Parser::new();
1581 let adr = parser.parse(content).unwrap();
1582
1583 assert_eq!(adr.number, 1);
1584 assert_eq!(adr.title, "Just a Title");
1585 }
1586
1587 #[test]
1588 fn test_parse_extra_sections_ignored() {
1589 let content = r#"# 1. Test
1590
1591## Status
1592
1593Proposed
1594
1595## Context
1596
1597Context.
1598
1599## Decision
1600
1601Decision.
1602
1603## Consequences
1604
1605Consequences.
1606
1607## Notes
1608
1609These should be ignored.
1610
1611## References
1612
1613- ref1
1614- ref2
1615"#;
1616
1617 let parser = Parser::new();
1618 let adr = parser.parse(content).unwrap();
1619
1620 assert_eq!(adr.number, 1);
1622 assert_eq!(adr.status, AdrStatus::Proposed);
1623 }
1624
1625 #[test]
1626 fn test_parse_case_insensitive_sections() {
1627 let content = r#"# 1. Case Test
1628
1629## STATUS
1630
1631Accepted
1632
1633## CONTEXT
1634
1635Context.
1636
1637## DECISION
1638
1639Decision.
1640
1641## CONSEQUENCES
1642
1643Consequences.
1644"#;
1645
1646 let parser = Parser::new();
1647 let adr = parser.parse(content).unwrap();
1648
1649 assert_eq!(adr.status, AdrStatus::Accepted);
1651 assert_eq!(adr.context, "Context.");
1652 }
1653
1654 #[test]
1655 fn test_parse_content_with_markdown_formatting() {
1656 let content = r#"# 1. Formatted ADR
1657
1658## Status
1659
1660Accepted
1661
1662## Context
1663
1664We have **bold** and *italic* text.
1665
1666Also `code` and [links](https://example.com).
1667
1668## Decision
1669
1670```rust
1671fn main() {
1672 println!("Hello");
1673}
1674```
1675
1676## Consequences
1677
1678| Column 1 | Column 2 |
1679|----------|----------|
1680| Value 1 | Value 2 |
1681"#;
1682
1683 let parser = Parser::new();
1684 let adr = parser.parse(content).unwrap();
1685
1686 assert!(adr.context.contains("bold"));
1687 assert!(adr.decision.contains("fn main"));
1688 assert!(adr.consequences.contains("Column 1"));
1689 }
1690
1691 #[test]
1694 fn test_link_regex_pattern() {
1695 let content = "Supersedes [1. Use MySQL](0001-use-mysql.md)";
1696 let caps = LINK_REGEX.captures(content).unwrap();
1697
1698 assert_eq!(caps.get(1).unwrap().as_str(), "Supersedes");
1699 assert_eq!(caps.get(2).unwrap().as_str(), "1");
1700 assert_eq!(caps.get(3).unwrap().as_str(), "0001");
1701 }
1702
1703 #[test]
1704 fn test_link_regex_amended_by() {
1705 let content = "Amended by [3. Update API](0003-update-api.md)";
1706 let caps = LINK_REGEX.captures(content).unwrap();
1707
1708 assert_eq!(caps.get(1).unwrap().as_str(), "Amended by");
1709 assert_eq!(caps.get(2).unwrap().as_str(), "3");
1710 }
1711
1712 #[test]
1713 fn test_number_regex_pattern() {
1714 let filename = "0042-some-decision.md";
1715 let caps = NUMBER_REGEX.captures(filename).unwrap();
1716
1717 assert_eq!(caps.get(1).unwrap().as_str(), "0042");
1718 }
1719
1720 #[test]
1721 fn test_number_regex_no_match() {
1722 assert!(NUMBER_REGEX.captures("not-an-adr.md").is_none());
1723 assert!(NUMBER_REGEX.captures("01-short.md").is_none());
1724 assert!(NUMBER_REGEX.captures("00001-too-long.md").is_none());
1725 }
1726
1727 #[test]
1730 fn test_parse_madr_frontmatter() {
1731 let content = r#"---
1732number: 1
1733title: Use MADR Format
1734date: 2024-09-15
1735status: accepted
1736decision-makers:
1737 - Alice
1738 - Bob
1739consulted:
1740 - Carol
1741informed:
1742 - Dave
1743 - Eve
1744---
1745
1746## Context and Problem Statement
1747
1748We need a standard format for ADRs.
1749
1750## Decision Outcome
1751
1752Chosen option: "MADR 4.0.0", because it provides rich metadata.
1753"#;
1754
1755 let parser = Parser::new();
1756 let adr = parser.parse(content).unwrap();
1757
1758 assert_eq!(adr.number, 1);
1759 assert_eq!(adr.title, "Use MADR Format");
1760 assert_eq!(adr.status, AdrStatus::Accepted);
1761 assert_eq!(adr.decision_makers, vec!["Alice", "Bob"]);
1762 assert_eq!(adr.consulted, vec!["Carol"]);
1763 assert_eq!(adr.informed, vec!["Dave", "Eve"]);
1764 }
1765
1766 #[test]
1767 fn test_parse_madr_frontmatter_partial_fields() {
1768 let content = r#"---
1769number: 2
1770title: Partial MADR
1771date: 2024-09-15
1772status: proposed
1773decision-makers:
1774 - Alice
1775---
1776
1777## Context
1778
1779Context.
1780"#;
1781
1782 let parser = Parser::new();
1783 let adr = parser.parse(content).unwrap();
1784
1785 assert_eq!(adr.decision_makers, vec!["Alice"]);
1786 assert!(adr.consulted.is_empty());
1787 assert!(adr.informed.is_empty());
1788 }
1789
1790 #[test]
1791 fn test_parse_madr_frontmatter_empty_fields() {
1792 let content = r#"---
1793number: 3
1794title: No MADR Fields
1795date: 2024-09-15
1796status: accepted
1797---
1798
1799## Context
1800
1801Context.
1802"#;
1803
1804 let parser = Parser::new();
1805 let adr = parser.parse(content).unwrap();
1806
1807 assert!(adr.decision_makers.is_empty());
1808 assert!(adr.consulted.is_empty());
1809 assert!(adr.informed.is_empty());
1810 }
1811
1812 #[test]
1813 fn test_parse_madr_with_links() {
1814 let content = r#"---
1815number: 4
1816title: MADR With Links
1817date: 2024-09-15
1818status: accepted
1819decision-makers:
1820 - Alice
1821links:
1822 - target: 1
1823 kind: supersedes
1824 - target: 2
1825 kind: amends
1826---
1827
1828## Context
1829
1830Context.
1831"#;
1832
1833 let parser = Parser::new();
1834 let adr = parser.parse(content).unwrap();
1835
1836 assert_eq!(adr.decision_makers, vec!["Alice"]);
1837 assert_eq!(adr.links.len(), 2);
1838 assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
1839 assert_eq!(adr.links[1].kind, LinkKind::Amends);
1840 }
1841
1842 #[test]
1845 fn test_parse_frontmatter_title_from_body_h1() {
1846 let content = r#"---
1847number: 2
1848date: 2024-01-15
1849status: proposed
1850---
1851
1852# My Decision Title
1853
1854## Context
1855
1856Context.
1857
1858## Decision
1859
1860Decision.
1861
1862## Consequences
1863
1864Consequences.
1865"#;
1866
1867 let parser = Parser::new();
1868 let adr = parser.parse(content).unwrap();
1869
1870 assert_eq!(adr.number, 2);
1871 assert_eq!(adr.title, "My Decision Title");
1872 assert_eq!(adr.status, AdrStatus::Proposed);
1873 }
1874
1875 #[test]
1876 fn test_parse_frontmatter_title_from_body_h1_numbered() {
1877 let content = r#"---
1878number: 2
1879date: 2024-01-15
1880status: proposed
1881---
1882
1883# 2. My Numbered Title
1884
1885## Context
1886
1887Context.
1888"#;
1889
1890 let parser = Parser::new();
1891 let adr = parser.parse(content).unwrap();
1892
1893 assert_eq!(adr.number, 2);
1894 assert_eq!(adr.title, "My Numbered Title");
1895 }
1896
1897 #[test]
1898 fn test_parse_frontmatter_title_prefers_frontmatter() {
1899 let content = r#"---
1900number: 2
1901title: Frontmatter Title
1902date: 2024-01-15
1903status: proposed
1904---
1905
1906# Body Title
1907
1908## Context
1909
1910Context.
1911"#;
1912
1913 let parser = Parser::new();
1914 let adr = parser.parse(content).unwrap();
1915
1916 assert_eq!(adr.title, "Frontmatter Title");
1917 }
1918
1919 #[test]
1922 fn test_parse_crlf_frontmatter_matches_lf() {
1923 let lf = r#"---
1924number: 4
1925title: Use MADR format for ADRs
1926date: 2024-02-15
1927status: accepted
1928decision-makers:
1929 - Alice Smith
1930 - Bob Jones
1931consulted:
1932 - Carol White
1933informed:
1934 - David Brown
1935 - Eve Green
1936---
1937
1938## Context
1939
1940We need a richer metadata format.
1941
1942## Decision
1943
1944We will use MADR.
1945
1946## Consequences
1947
1948More structured metadata.
1949"#;
1950 let crlf = lf.replace('\n', "\r\n");
1951
1952 let parser = Parser::new();
1953 let lf_adr = parser.parse(lf).unwrap();
1954 let crlf_adr = parser.parse(&crlf).unwrap();
1955
1956 assert_eq!(crlf_adr.status, AdrStatus::Accepted);
1957 assert_eq!(crlf_adr.status, lf_adr.status);
1958 assert_eq!(crlf_adr.date, lf_adr.date);
1959 assert_eq!(
1960 crlf_adr.decision_makers,
1961 vec!["Alice Smith".to_string(), "Bob Jones".to_string()]
1962 );
1963 assert_eq!(crlf_adr.decision_makers, lf_adr.decision_makers);
1964 assert_eq!(crlf_adr.consulted, lf_adr.consulted);
1965 assert_eq!(crlf_adr.informed, lf_adr.informed);
1966 assert_eq!(crlf_adr.context, lf_adr.context);
1967 assert_eq!(crlf_adr.decision, lf_adr.decision);
1968 assert_eq!(crlf_adr.consequences, lf_adr.consequences);
1969
1970 assert!(!crlf_adr.title.contains('\r'));
1972 assert!(!crlf_adr.context.contains('\r'));
1973 assert!(!crlf_adr.decision.contains('\r'));
1974 assert!(!crlf_adr.consequences.contains('\r'));
1975 for person in crlf_adr
1976 .decision_makers
1977 .iter()
1978 .chain(crlf_adr.consulted.iter())
1979 .chain(crlf_adr.informed.iter())
1980 {
1981 assert!(!person.contains('\r'));
1982 }
1983 }
1984
1985 #[test]
1986 fn test_parse_crlf_legacy_format() {
1987 let lf = r#"# 1. Use Rust
1988
1989## Status
1990
1991Accepted
1992
1993## Context
1994
1995We need a systems programming language.
1996
1997## Decision
1998
1999We will use Rust.
2000
2001## Consequences
2002
2003We get memory safety without garbage collection.
2004"#;
2005 let crlf = lf.replace('\n', "\r\n");
2006
2007 let parser = Parser::new();
2008 let adr = parser.parse(&crlf).unwrap();
2009
2010 assert_eq!(adr.number, 1);
2011 assert_eq!(adr.title, "Use Rust");
2012 assert_eq!(adr.status, AdrStatus::Accepted);
2013 assert!(adr.context.contains("systems programming"));
2014 assert!(adr.decision.contains("use Rust"));
2015 assert!(adr.consequences.contains("memory safety"));
2016
2017 assert!(!adr.title.contains('\r'));
2018 assert!(!adr.context.contains('\r'));
2019 assert!(!adr.decision.contains('\r'));
2020 assert!(!adr.consequences.contains('\r'));
2021 }
2022
2023 #[test]
2026 fn test_parse_legacy_date_line_is_parsed() {
2027 let content = r#"# 1. Record architecture decisions
2028
2029Date: 2024-01-15
2030
2031## Status
2032
2033Accepted
2034
2035## Context
2036
2037Context.
2038
2039## Decision
2040
2041Decision.
2042
2043## Consequences
2044
2045Consequences.
2046"#;
2047
2048 let parser = Parser::new();
2049 let adr = parser.parse(content).unwrap();
2050
2051 assert_eq!(adr.date.to_string(), "2024-01-15");
2052 }
2053
2054 #[test]
2055 fn test_parse_legacy_no_date_line_falls_back_to_today() {
2056 let content = r#"# 1. Record architecture decisions
2057
2058## Status
2059
2060Accepted
2061
2062## Context
2063
2064Context.
2065
2066## Decision
2067
2068Decision.
2069
2070## Consequences
2071
2072Consequences.
2073"#;
2074
2075 let parser = Parser::new();
2076 let adr = parser.parse(content).unwrap();
2077
2078 assert_eq!(adr.date, today());
2079 }
2080
2081 #[test]
2082 fn test_parse_legacy_unparseable_date_line_falls_back_to_today() {
2083 let content = r#"# 1. Record architecture decisions
2084
2085Date: not-a-date
2086
2087## Status
2088
2089Accepted
2090
2091## Context
2092
2093Context.
2094
2095## Decision
2096
2097Decision.
2098
2099## Consequences
2100
2101Consequences.
2102"#;
2103
2104 let parser = Parser::new();
2105 let adr = parser.parse(content).unwrap();
2106
2107 assert_eq!(adr.date, today());
2108 }
2109}