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