adrscope/domain/
frontmatter.rs

1//! YAML frontmatter data structure.
2//!
3//! This module defines the structured-madr frontmatter schema that ADRScope
4//! expects in ADR files.
5
6use serde::{Deserialize, Serialize};
7use time::Date;
8
9use super::Status;
10
11/// Parsed YAML frontmatter from an ADR file following the structured-madr schema.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Frontmatter {
14    /// Short descriptive title (1-100 chars).
15    pub title: String,
16
17    /// One-sentence summary (1-300 chars).
18    #[serde(default)]
19    pub description: String,
20
21    /// Document type identifier (const: "adr").
22    #[serde(rename = "type", default = "default_type")]
23    pub doc_type: String,
24
25    /// Decision category (e.g., architecture, api, security).
26    #[serde(default)]
27    pub category: String,
28
29    /// Keywords for categorization (kebab-case).
30    #[serde(default)]
31    pub tags: Vec<String>,
32
33    /// Current status in the lifecycle.
34    #[serde(default, deserialize_with = "lenient_status::deserialize")]
35    pub status: Status,
36
37    /// ISO 8601 date created.
38    #[serde(default, with = "optional_date")]
39    pub created: Option<Date>,
40
41    /// ISO 8601 date last modified.
42    #[serde(default, with = "optional_date")]
43    pub updated: Option<Date>,
44
45    /// Author or team responsible.
46    #[serde(default)]
47    pub author: String,
48
49    /// Project this decision applies to.
50    #[serde(default)]
51    pub project: String,
52
53    /// Technologies affected by decision.
54    #[serde(default)]
55    pub technologies: Vec<String>,
56
57    /// Intended readers.
58    #[serde(default)]
59    pub audience: Vec<String>,
60
61    /// Filenames of related ADRs.
62    #[serde(default)]
63    pub related: Vec<String>,
64}
65
66fn default_type() -> String {
67    "adr".to_string()
68}
69
70impl Default for Frontmatter {
71    fn default() -> Self {
72        Self {
73            title: String::new(),
74            description: String::new(),
75            doc_type: default_type(),
76            category: String::new(),
77            tags: Vec::new(),
78            status: Status::default(),
79            created: None,
80            updated: None,
81            author: String::new(),
82            project: String::new(),
83            technologies: Vec::new(),
84            audience: Vec::new(),
85            related: Vec::new(),
86        }
87    }
88}
89
90impl Frontmatter {
91    /// Creates a new frontmatter with the given title.
92    #[must_use]
93    pub fn new(title: impl Into<String>) -> Self {
94        Self {
95            title: title.into(),
96            ..Self::default()
97        }
98    }
99
100    /// Sets the description.
101    #[must_use]
102    pub fn with_description(mut self, description: impl Into<String>) -> Self {
103        self.description = description.into();
104        self
105    }
106
107    /// Sets the status.
108    #[must_use]
109    pub const fn with_status(mut self, status: Status) -> Self {
110        self.status = status;
111        self
112    }
113
114    /// Sets the category.
115    #[must_use]
116    pub fn with_category(mut self, category: impl Into<String>) -> Self {
117        self.category = category.into();
118        self
119    }
120
121    /// Sets the author.
122    #[must_use]
123    pub fn with_author(mut self, author: impl Into<String>) -> Self {
124        self.author = author.into();
125        self
126    }
127
128    /// Sets the project.
129    #[must_use]
130    pub fn with_project(mut self, project: impl Into<String>) -> Self {
131        self.project = project.into();
132        self
133    }
134
135    /// Sets the created date.
136    #[must_use]
137    pub const fn with_created(mut self, date: Date) -> Self {
138        self.created = Some(date);
139        self
140    }
141
142    /// Sets the updated date.
143    #[must_use]
144    pub const fn with_updated(mut self, date: Date) -> Self {
145        self.updated = Some(date);
146        self
147    }
148
149    /// Adds tags.
150    #[must_use]
151    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
152        self.tags = tags;
153        self
154    }
155
156    /// Adds technologies.
157    #[must_use]
158    pub fn with_technologies(mut self, technologies: Vec<String>) -> Self {
159        self.technologies = technologies;
160        self
161    }
162
163    /// Adds related ADRs.
164    #[must_use]
165    pub fn with_related(mut self, related: Vec<String>) -> Self {
166        self.related = related;
167        self
168    }
169}
170
171/// Lenient deserialization for Status that warns once per unknown value.
172mod lenient_status {
173    use std::cell::RefCell;
174    use std::collections::HashSet;
175
176    use serde::{Deserialize, Deserializer};
177
178    use super::Status;
179
180    thread_local! {
181        /// Track unknown statuses we've already warned about (warn once per unique value per thread).
182        static WARNED_STATUSES: RefCell<HashSet<String>> = RefCell::new(HashSet::new());
183    }
184
185    pub fn deserialize<'de, D>(deserializer: D) -> Result<Status, D::Error>
186    where
187        D: Deserializer<'de>,
188    {
189        let opt: Option<String> = Option::deserialize(deserializer)?;
190        match opt {
191            Some(s) if !s.is_empty() => match s.to_lowercase().as_str() {
192                "proposed" => Ok(Status::Proposed),
193                "accepted" => Ok(Status::Accepted),
194                "deprecated" => Ok(Status::Deprecated),
195                "superseded" => Ok(Status::Superseded),
196                unknown => {
197                    // Only warn once per unique unknown status value per thread
198                    WARNED_STATUSES.with(|set| {
199                        if set.borrow_mut().insert(unknown.to_string()) {
200                            eprintln!(
201                                "Warning: Unknown ADR status '{unknown}', defaulting to 'proposed'"
202                            );
203                        }
204                    });
205                    Ok(Status::Proposed)
206                },
207            },
208            _ => Ok(Status::default()),
209        }
210    }
211}
212
213/// Custom serialization for optional dates in ISO 8601 format.
214mod optional_date {
215    use serde::{self, Deserialize, Deserializer, Serializer};
216    use time::{Date, format_description::well_known::Iso8601};
217
218    #[allow(clippy::ref_option)]
219    pub fn serialize<S>(date: &Option<Date>, serializer: S) -> Result<S::Ok, S::Error>
220    where
221        S: Serializer,
222    {
223        match date {
224            Some(d) => {
225                let s = d
226                    .format(&Iso8601::DATE)
227                    .map_err(serde::ser::Error::custom)?;
228                serializer.serialize_str(&s)
229            },
230            None => serializer.serialize_none(),
231        }
232    }
233
234    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Date>, D::Error>
235    where
236        D: Deserializer<'de>,
237    {
238        let opt: Option<String> = Option::deserialize(deserializer)?;
239        match opt {
240            Some(s) if !s.is_empty() => Date::parse(&s, &Iso8601::DATE)
241                .map(Some)
242                .map_err(|e| serde::de::Error::custom(format!("invalid date format '{s}': {e}"))),
243            _ => Ok(None),
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_frontmatter_default() {
254        let fm = Frontmatter::default();
255        assert!(fm.title.is_empty());
256        assert_eq!(fm.doc_type, "adr");
257        assert_eq!(fm.status, Status::Proposed);
258    }
259
260    #[test]
261    fn test_frontmatter_builder() {
262        let fm = Frontmatter::new("Test ADR")
263            .with_description("A test decision")
264            .with_status(Status::Accepted)
265            .with_category("architecture")
266            .with_author("Test Team");
267
268        assert_eq!(fm.title, "Test ADR");
269        assert_eq!(fm.description, "A test decision");
270        assert_eq!(fm.status, Status::Accepted);
271        assert_eq!(fm.category, "architecture");
272        assert_eq!(fm.author, "Test Team");
273    }
274
275    #[test]
276    fn test_frontmatter_deserialization() {
277        let yaml = r#"
278title: Use PostgreSQL
279description: Decision to use PostgreSQL for storage
280status: accepted
281category: architecture
282tags:
283  - database
284  - postgresql
285author: Architecture Team
286created: "2025-01-15"
287"#;
288        let fm: Frontmatter = serde_yaml::from_str(yaml).expect("should parse");
289        assert_eq!(fm.title, "Use PostgreSQL");
290        assert_eq!(fm.status, Status::Accepted);
291        assert_eq!(fm.tags, vec!["database", "postgresql"]);
292        assert!(fm.created.is_some());
293    }
294
295    #[test]
296    fn test_frontmatter_serialization() {
297        let fm = Frontmatter::new("Test").with_status(Status::Accepted);
298
299        let json = serde_json::to_string(&fm).expect("should serialize");
300        assert!(json.contains("\"title\":\"Test\""));
301        assert!(json.contains("\"status\":\"accepted\""));
302    }
303
304    #[test]
305    fn test_frontmatter_builder_all_fields() {
306        use time::macros::date;
307
308        let fm = Frontmatter::new("Complete ADR")
309            .with_description("Full description")
310            .with_status(Status::Deprecated)
311            .with_category("security")
312            .with_author("Security Team")
313            .with_project("my-project")
314            .with_created(date!(2025 - 01 - 10))
315            .with_updated(date!(2025 - 01 - 15))
316            .with_tags(vec!["security".to_string(), "auth".to_string()])
317            .with_technologies(vec!["rust".to_string(), "wasm".to_string()])
318            .with_related(vec!["adr-001.md".to_string(), "adr-002.md".to_string()]);
319
320        assert_eq!(fm.title, "Complete ADR");
321        assert_eq!(fm.description, "Full description");
322        assert_eq!(fm.status, Status::Deprecated);
323        assert_eq!(fm.category, "security");
324        assert_eq!(fm.author, "Security Team");
325        assert_eq!(fm.project, "my-project");
326        assert_eq!(fm.created, Some(date!(2025 - 01 - 10)));
327        assert_eq!(fm.updated, Some(date!(2025 - 01 - 15)));
328        assert_eq!(fm.tags, vec!["security", "auth"]);
329        assert_eq!(fm.technologies, vec!["rust", "wasm"]);
330        assert_eq!(fm.related, vec!["adr-001.md", "adr-002.md"]);
331    }
332
333    #[test]
334    fn test_frontmatter_date_serialization_roundtrip() {
335        use time::macros::date;
336
337        let fm = Frontmatter::new("Date Test")
338            .with_created(date!(2025 - 06 - 15))
339            .with_updated(date!(2025 - 12 - 25));
340
341        let json = serde_json::to_string(&fm).expect("should serialize");
342        assert!(json.contains("2025-06-15"));
343        assert!(json.contains("2025-12-25"));
344
345        let roundtrip: Frontmatter = serde_json::from_str(&json).expect("should deserialize");
346        assert_eq!(roundtrip.created, fm.created);
347        assert_eq!(roundtrip.updated, fm.updated);
348    }
349
350    #[test]
351    fn test_frontmatter_unknown_status_defaults_to_proposed() {
352        // Unknown status values should parse successfully with default status
353        let yaml = r#"
354title: ADR with unknown status
355description: This ADR has a non-standard status
356status: published
357category: architecture
358"#;
359        let fm: Frontmatter =
360            serde_yaml::from_str(yaml).expect("should parse even with unknown status");
361        assert_eq!(fm.title, "ADR with unknown status");
362        // Unknown status "published" should default to Proposed
363        assert_eq!(fm.status, Status::Proposed);
364    }
365
366    #[test]
367    fn test_frontmatter_missing_status_defaults_to_proposed() {
368        let yaml = r#"
369title: ADR without status
370description: This ADR has no status field
371"#;
372        let fm: Frontmatter = serde_yaml::from_str(yaml).expect("should parse");
373        assert_eq!(fm.status, Status::Proposed);
374    }
375}