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