1use chrono::{DateTime, NaiveDate, Utc};
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct FlexibleDate(pub DateTime<Utc>);
13
14impl FlexibleDate {
15 #[must_use]
17 pub const fn datetime(&self) -> DateTime<Utc> {
18 self.0
19 }
20}
21
22impl From<DateTime<Utc>> for FlexibleDate {
23 fn from(dt: DateTime<Utc>) -> Self {
24 Self(dt)
25 }
26}
27
28impl Serialize for FlexibleDate {
29 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
30 where
31 S: Serializer,
32 {
33 serializer.serialize_str(&self.0.format("%Y-%m-%d").to_string())
35 }
36}
37
38impl<'de> Deserialize<'de> for FlexibleDate {
39 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
40 where
41 D: Deserializer<'de>,
42 {
43 let s = String::deserialize(deserializer)?;
44
45 if let Ok(dt) = DateTime::parse_from_rfc3339(&s) {
47 return Ok(Self(dt.with_timezone(&Utc)));
48 }
49
50 if let Ok(date) = NaiveDate::parse_from_str(&s, "%Y-%m-%d") {
52 if let Some(datetime) = date.and_hms_opt(0, 0, 0) {
53 return Ok(Self(datetime.and_utc()));
54 }
55 }
56
57 Err(serde::de::Error::custom(format!(
58 "invalid date format: {}. Expected YYYY-MM-DD or RFC3339.",
59 s
60 )))
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
66#[serde(rename_all = "lowercase")]
67pub enum AdrStatus {
68 #[default]
70 Proposed,
71 Accepted,
73 Deprecated,
75 Superseded,
77 Rejected,
79}
80
81impl std::fmt::Display for AdrStatus {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 Self::Proposed => write!(f, "proposed"),
85 Self::Accepted => write!(f, "accepted"),
86 Self::Deprecated => write!(f, "deprecated"),
87 Self::Superseded => write!(f, "superseded"),
88 Self::Rejected => write!(f, "rejected"),
89 }
90 }
91}
92
93impl std::str::FromStr for AdrStatus {
94 type Err = crate::Error;
95
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
97 match s.to_lowercase().as_str() {
98 "proposed" => Ok(Self::Proposed),
99 "accepted" => Ok(Self::Accepted),
100 "deprecated" => Ok(Self::Deprecated),
101 "superseded" => Ok(Self::Superseded),
102 "rejected" => Ok(Self::Rejected),
103 _ => Err(crate::Error::InvalidStatus {
104 status: s.to_string(),
105 valid: vec![
106 "proposed".to_string(),
107 "accepted".to_string(),
108 "deprecated".to_string(),
109 "superseded".to_string(),
110 "rejected".to_string(),
111 ],
112 }),
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct AdrLink {
120 pub rel: String,
122 pub target: String,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct AdrFrontmatter {
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub id: Option<String>,
132 pub title: String,
134 #[serde(default)]
136 pub status: AdrStatus,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub date: Option<FlexibleDate>,
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
142 pub tags: Vec<String>,
143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
145 pub authors: Vec<String>,
146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub deciders: Vec<String>,
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 pub links: Vec<AdrLink>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub format: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub supersedes: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub superseded_by: Option<String>,
161 #[serde(flatten)]
163 pub custom: HashMap<String, serde_yaml::Value>,
164}
165
166impl Default for AdrFrontmatter {
167 fn default() -> Self {
168 Self {
169 id: None,
170 title: String::new(),
171 status: AdrStatus::default(),
172 date: Some(FlexibleDate(Utc::now())),
173 tags: Vec::new(),
174 authors: Vec::new(),
175 deciders: Vec::new(),
176 links: Vec::new(),
177 format: None,
178 supersedes: None,
179 superseded_by: None,
180 custom: HashMap::new(),
181 }
182 }
183}
184
185#[derive(Debug, Clone)]
187pub struct Adr {
188 pub id: String,
190 pub commit: String,
192 pub frontmatter: AdrFrontmatter,
194 pub body: String,
196}
197
198impl Adr {
199 #[must_use]
201 pub fn new(id: String, title: String) -> Self {
202 Self {
203 id: id.clone(),
204 commit: String::new(),
205 frontmatter: AdrFrontmatter {
206 id: Some(id),
207 title,
208 ..Default::default()
209 },
210 body: String::new(),
211 }
212 }
213
214 pub fn from_markdown(id: String, commit: String, content: &str) -> Result<Self, crate::Error> {
220 let (frontmatter, body) = Self::parse_frontmatter(content)?;
221 Ok(Self {
222 id,
223 commit,
224 frontmatter,
225 body,
226 })
227 }
228
229 fn parse_frontmatter(content: &str) -> Result<(AdrFrontmatter, String), crate::Error> {
231 let content = content.trim();
232
233 if !content.starts_with("---") {
234 return Err(crate::Error::ParseError {
235 message: "ADR must start with YAML frontmatter (---)".to_string(),
236 });
237 }
238
239 let rest = &content[3..];
240 let end_marker = rest.find("\n---");
241
242 match end_marker {
243 Some(pos) => {
244 let yaml_content = &rest[..pos];
245 let body = rest[pos + 4..].trim().to_string();
246
247 let frontmatter: AdrFrontmatter =
248 serde_yaml::from_str(yaml_content).map_err(|e| crate::Error::ParseError {
249 message: format!("Invalid YAML frontmatter: {e}"),
250 })?;
251
252 Ok((frontmatter, body))
253 },
254 None => Err(crate::Error::ParseError {
255 message: "YAML frontmatter must be closed with ---".to_string(),
256 }),
257 }
258 }
259
260 pub fn to_markdown(&self) -> Result<String, crate::Error> {
266 let yaml =
267 serde_yaml::to_string(&self.frontmatter).map_err(|e| crate::Error::ParseError {
268 message: format!("Failed to serialize frontmatter: {e}"),
269 })?;
270
271 Ok(format!("---\n{}---\n\n{}", yaml, self.body))
272 }
273
274 #[must_use]
276 pub fn title(&self) -> &str {
277 &self.frontmatter.title
278 }
279
280 #[must_use]
282 pub const fn status(&self) -> &AdrStatus {
283 &self.frontmatter.status
284 }
285
286 #[must_use]
288 pub fn has_tag(&self, tag: &str) -> bool {
289 self.frontmatter
290 .tags
291 .iter()
292 .any(|t| t.eq_ignore_ascii_case(tag))
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use chrono::Datelike;
300
301 #[test]
302 fn test_status_display() {
303 assert_eq!(AdrStatus::Proposed.to_string(), "proposed");
304 assert_eq!(AdrStatus::Accepted.to_string(), "accepted");
305 assert_eq!(AdrStatus::Deprecated.to_string(), "deprecated");
306 assert_eq!(AdrStatus::Superseded.to_string(), "superseded");
307 assert_eq!(AdrStatus::Rejected.to_string(), "rejected");
308 }
309
310 #[test]
311 fn test_status_parse() {
312 assert_eq!(
313 "proposed".parse::<AdrStatus>().unwrap(),
314 AdrStatus::Proposed
315 );
316 assert_eq!(
317 "ACCEPTED".parse::<AdrStatus>().unwrap(),
318 AdrStatus::Accepted
319 );
320 assert_eq!(
321 "Deprecated".parse::<AdrStatus>().unwrap(),
322 AdrStatus::Deprecated
323 );
324 assert_eq!(
325 "SUPERSEDED".parse::<AdrStatus>().unwrap(),
326 AdrStatus::Superseded
327 );
328 assert_eq!(
329 "rejected".parse::<AdrStatus>().unwrap(),
330 AdrStatus::Rejected
331 );
332 }
333
334 #[test]
335 fn test_status_parse_invalid() {
336 let result = "invalid".parse::<AdrStatus>();
337 assert!(result.is_err());
338 }
339
340 #[test]
341 fn test_status_default() {
342 let status = AdrStatus::default();
343 assert_eq!(status, AdrStatus::Proposed);
344 }
345
346 #[test]
347 fn test_adr_new() {
348 let adr = Adr::new("ADR-0001".to_string(), "Test Title".to_string());
349 assert_eq!(adr.id, "ADR-0001");
350 assert_eq!(adr.title(), "Test Title");
351 assert_eq!(*adr.status(), AdrStatus::Proposed);
352 assert!(adr.commit.is_empty());
353 assert!(adr.body.is_empty());
354 }
355
356 #[test]
357 fn test_adr_from_markdown() {
358 let content = r#"---
359title: Use Rust for CLI
360status: proposed
361tags:
362 - architecture
363 - rust
364---
365
366## Context
367
368We need to decide on a language for the CLI.
369"#;
370
371 let adr =
372 Adr::from_markdown("ADR-0001".to_string(), "abc123".to_string(), content).unwrap();
373 assert_eq!(adr.title(), "Use Rust for CLI");
374 assert_eq!(*adr.status(), AdrStatus::Proposed);
375 assert!(adr.has_tag("rust"));
376 assert!(adr.has_tag("architecture"));
377 assert!(!adr.has_tag("python"));
378 }
379
380 #[test]
381 fn test_adr_from_markdown_with_date() {
382 let content = r#"---
384date: '2025-12-15'
385format: nygard
386id: 00000000-use-adrs
387status: accepted
388tags:
389- documentation
390- process
391title: Use Architecture Decision Records
392---
393
394# Use Architecture Decision Records
395
396## Status
397
398accepted
399"#;
400
401 let adr = Adr::from_markdown("ADR-0000".to_string(), "abc123".to_string(), content)
402 .expect("Failed to parse ADR");
403 assert_eq!(adr.title(), "Use Architecture Decision Records");
404 assert_eq!(*adr.status(), AdrStatus::Accepted);
405 }
406
407 #[test]
408 fn test_adr_from_markdown_no_frontmatter() {
409 let content = "No frontmatter here";
410 let result = Adr::from_markdown("ADR-0001".to_string(), "abc123".to_string(), content);
411 assert!(result.is_err());
412 }
413
414 #[test]
415 fn test_adr_from_markdown_unclosed_frontmatter() {
416 let content = r#"---
417title: Unclosed
418status: proposed
419No closing marker
420"#;
421 let result = Adr::from_markdown("ADR-0001".to_string(), "abc123".to_string(), content);
422 assert!(result.is_err());
423 }
424
425 #[test]
426 fn test_adr_to_markdown() {
427 let mut adr = Adr::new("ADR-0001".to_string(), "Test Title".to_string());
428 adr.body = "Body content here.".to_string();
429
430 let markdown = adr.to_markdown().expect("Should serialize");
431 assert!(markdown.contains("---"));
432 assert!(markdown.contains("title: Test Title"));
433 assert!(markdown.contains("Body content here."));
434 }
435
436 #[test]
437 fn test_adr_has_tag_case_insensitive() {
438 let mut adr = Adr::new("ADR-0001".to_string(), "Test".to_string());
439 adr.frontmatter.tags = vec!["Architecture".to_string()];
440
441 assert!(adr.has_tag("architecture"));
442 assert!(adr.has_tag("ARCHITECTURE"));
443 assert!(adr.has_tag("Architecture"));
444 }
445
446 #[test]
447 fn test_flexible_date_from_datetime() {
448 let now = Utc::now();
449 let flexible = FlexibleDate::from(now);
450 assert_eq!(flexible.datetime(), now);
451 }
452
453 #[test]
454 fn test_adr_frontmatter_default() {
455 let fm = AdrFrontmatter::default();
456 assert!(fm.id.is_none());
457 assert!(fm.title.is_empty());
458 assert_eq!(fm.status, AdrStatus::Proposed);
459 assert!(fm.tags.is_empty());
460 assert!(fm.authors.is_empty());
461 assert!(fm.deciders.is_empty());
462 assert!(fm.links.is_empty());
463 }
464
465 #[test]
466 fn test_adr_link() {
467 let link = AdrLink {
468 rel: "supersedes".to_string(),
469 target: "ADR-0001".to_string(),
470 };
471 assert_eq!(link.rel, "supersedes");
472 assert_eq!(link.target, "ADR-0001");
473 }
474
475 #[test]
476 fn test_flexible_date_serialize() {
477 use chrono::TimeZone;
478 let date = chrono::Utc.with_ymd_and_hms(2025, 12, 15, 0, 0, 0).unwrap();
479 let flexible = FlexibleDate(date);
480 let serialized = serde_yaml::to_string(&flexible).unwrap();
481 assert!(serialized.contains("2025-12-15"));
482 }
483
484 #[test]
485 fn test_flexible_date_deserialize_rfc3339() {
486 let yaml = "2025-12-15T00:00:00Z";
487 let result: FlexibleDate = serde_yaml::from_str(yaml).unwrap();
488 assert_eq!(result.0.year(), 2025);
489 assert_eq!(result.0.month(), 12);
490 assert_eq!(result.0.day(), 15);
491 }
492
493 #[test]
494 fn test_flexible_date_deserialize_date_only() {
495 let yaml = "2025-12-15";
496 let result: FlexibleDate = serde_yaml::from_str(yaml).unwrap();
497 assert_eq!(result.0.year(), 2025);
498 assert_eq!(result.0.month(), 12);
499 assert_eq!(result.0.day(), 15);
500 }
501
502 #[test]
503 fn test_flexible_date_deserialize_invalid() {
504 let yaml = "invalid-date-format";
505 let result: Result<FlexibleDate, _> = serde_yaml::from_str(yaml);
506 assert!(result.is_err());
507 }
508
509 #[test]
510 fn test_adr_from_markdown_invalid_yaml() {
511 let content = r#"---
512title: [invalid yaml
513status: proposed
514---
515
516Body
517"#;
518 let result = Adr::from_markdown("ADR-0001".to_string(), "abc123".to_string(), content);
519 assert!(result.is_err());
520 }
521
522 #[test]
523 fn test_adr_frontmatter_with_all_fields() {
524 let content = r#"---
525id: ADR-0001
526title: Test ADR
527status: accepted
528date: 2025-12-15
529tags:
530 - test
531 - example
532authors:
533 - Alice
534deciders:
535 - Bob
536links:
537 - rel: supersedes
538 target: ADR-0000
539format: nygard
540supersedes: ADR-0000
541superseded_by: ADR-0002
542custom_field: custom_value
543---
544
545Body content
546"#;
547 let adr = Adr::from_markdown("ADR-0001".to_string(), "abc123".to_string(), content)
548 .expect("Should parse");
549 assert_eq!(adr.frontmatter.id, Some("ADR-0001".to_string()));
550 assert_eq!(adr.frontmatter.authors, vec!["Alice"]);
551 assert_eq!(adr.frontmatter.deciders, vec!["Bob"]);
552 assert_eq!(adr.frontmatter.format, Some("nygard".to_string()));
553 assert_eq!(adr.frontmatter.supersedes, Some("ADR-0000".to_string()));
554 assert_eq!(adr.frontmatter.superseded_by, Some("ADR-0002".to_string()));
555 assert!(adr.frontmatter.custom.contains_key("custom_field"));
556 }
557
558 #[test]
559 fn test_status_hash() {
560 use std::collections::HashSet;
561 let mut set = HashSet::new();
562 set.insert(AdrStatus::Proposed);
563 set.insert(AdrStatus::Accepted);
564 assert_eq!(set.len(), 2);
565 set.insert(AdrStatus::Proposed);
566 assert_eq!(set.len(), 2); }
568}