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