git_adr/core/
adr.rs

1//! ADR data model.
2//!
3//! This module defines the core ADR structure that represents an
4//! Architecture Decision Record with its metadata and content.
5
6use chrono::{DateTime, NaiveDate, Utc};
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8use std::collections::HashMap;
9
10/// Flexible date type that accepts both full datetime and date-only formats.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct FlexibleDate(pub DateTime<Utc>);
13
14impl FlexibleDate {
15    /// Get the inner `DateTime<Utc>` value.
16    #[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        // Serialize as YYYY-MM-DD format
34        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        // Try RFC3339 format first (e.g., "2025-12-15T00:00:00Z")
46        if let Ok(dt) = DateTime::parse_from_rfc3339(&s) {
47            return Ok(Self(dt.with_timezone(&Utc)));
48        }
49
50        // Try YYYY-MM-DD format (e.g., "2025-12-15")
51        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/// Status of an ADR.
65#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
66#[serde(rename_all = "lowercase")]
67pub enum AdrStatus {
68    /// ADR is proposed but not yet accepted.
69    #[default]
70    Proposed,
71    /// ADR has been accepted.
72    Accepted,
73    /// ADR has been deprecated.
74    Deprecated,
75    /// ADR has been superseded by another ADR.
76    Superseded,
77    /// ADR has been rejected.
78    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/// Link to another ADR.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct AdrLink {
120    /// The type of link relationship.
121    pub rel: String,
122    /// The target ADR ID.
123    pub target: String,
124}
125
126/// YAML frontmatter metadata for an ADR.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct AdrFrontmatter {
129    /// The ADR ID (optional in frontmatter, may be stored separately).
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub id: Option<String>,
132    /// The ADR title.
133    pub title: String,
134    /// The current status.
135    #[serde(default)]
136    pub status: AdrStatus,
137    /// Date when the ADR was created.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub date: Option<FlexibleDate>,
140    /// Tags for categorization.
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub tags: Vec<String>,
143    /// Authors of the ADR.
144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
145    pub authors: Vec<String>,
146    /// Decision makers/reviewers.
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub deciders: Vec<String>,
149    /// Links to other ADRs.
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub links: Vec<AdrLink>,
152    /// ADR format type.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub format: Option<String>,
155    /// ID of ADR that this one supersedes.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub supersedes: Option<String>,
158    /// ID of ADR that superseded this one.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub superseded_by: Option<String>,
161    /// Custom fields.
162    #[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/// An Architecture Decision Record.
186#[derive(Debug, Clone)]
187pub struct Adr {
188    /// Unique identifier (typically ADR-NNNN format).
189    pub id: String,
190    /// The git commit this ADR is attached to.
191    pub commit: String,
192    /// Frontmatter metadata.
193    pub frontmatter: AdrFrontmatter,
194    /// Markdown body content.
195    pub body: String,
196}
197
198impl Adr {
199    /// Create a new ADR with the given ID and title.
200    #[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    /// Parse an ADR from markdown content with YAML frontmatter.
215    ///
216    /// # Errors
217    ///
218    /// Returns an error if the frontmatter is invalid or missing.
219    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    /// Parse YAML frontmatter from markdown content.
230    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    /// Render the ADR as markdown with YAML frontmatter.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if serialization fails.
265    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    /// Get the ADR title.
275    #[must_use]
276    pub fn title(&self) -> &str {
277        &self.frontmatter.title
278    }
279
280    /// Get the ADR status.
281    #[must_use]
282    pub const fn status(&self) -> &AdrStatus {
283        &self.frontmatter.status
284    }
285
286    /// Check if this ADR has the given tag.
287    #[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        // Test the actual format used in git notes
383        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); // Same status, no increase
567    }
568}