1use serde::{Deserialize, Serialize};
7use time::Date;
8
9use super::Status;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Frontmatter {
14 pub title: String,
16
17 #[serde(default)]
19 pub description: String,
20
21 #[serde(rename = "type", default = "default_type")]
23 pub doc_type: String,
24
25 #[serde(default)]
27 pub category: String,
28
29 #[serde(default)]
31 pub tags: Vec<String>,
32
33 #[serde(default, deserialize_with = "lenient_status::deserialize")]
35 pub status: Status,
36
37 #[serde(default, with = "optional_date")]
39 pub created: Option<Date>,
40
41 #[serde(default, with = "optional_date")]
43 pub updated: Option<Date>,
44
45 #[serde(default)]
47 pub author: String,
48
49 #[serde(default)]
51 pub project: String,
52
53 #[serde(default)]
55 pub technologies: Vec<String>,
56
57 #[serde(default)]
59 pub audience: Vec<String>,
60
61 #[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 #[must_use]
93 pub fn new(title: impl Into<String>) -> Self {
94 Self {
95 title: title.into(),
96 ..Self::default()
97 }
98 }
99
100 #[must_use]
102 pub fn with_description(mut self, description: impl Into<String>) -> Self {
103 self.description = description.into();
104 self
105 }
106
107 #[must_use]
109 pub const fn with_status(mut self, status: Status) -> Self {
110 self.status = status;
111 self
112 }
113
114 #[must_use]
116 pub fn with_category(mut self, category: impl Into<String>) -> Self {
117 self.category = category.into();
118 self
119 }
120
121 #[must_use]
123 pub fn with_author(mut self, author: impl Into<String>) -> Self {
124 self.author = author.into();
125 self
126 }
127
128 #[must_use]
130 pub fn with_project(mut self, project: impl Into<String>) -> Self {
131 self.project = project.into();
132 self
133 }
134
135 #[must_use]
137 pub const fn with_created(mut self, date: Date) -> Self {
138 self.created = Some(date);
139 self
140 }
141
142 #[must_use]
144 pub const fn with_updated(mut self, date: Date) -> Self {
145 self.updated = Some(date);
146 self
147 }
148
149 #[must_use]
151 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
152 self.tags = tags;
153 self
154 }
155
156 #[must_use]
158 pub fn with_technologies(mut self, technologies: Vec<String>) -> Self {
159 self.technologies = technologies;
160 self
161 }
162
163 #[must_use]
165 pub fn with_related(mut self, related: Vec<String>) -> Self {
166 self.related = related;
167 self
168 }
169}
170
171mod 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 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 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
213mod 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 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 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}