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