1use serde::{Deserialize, Deserializer, Serialize};
9
10fn de_null_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
14where
15 D: Deserializer<'de>,
16{
17 Ok(Option::<Vec<String>>::deserialize(deserializer)?.unwrap_or_default())
18}
19
20fn default_active() -> String {
21 "active".to_string()
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
27pub struct DraftFrontmatter {
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub kind: Option<String>,
30 pub case_uid: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub send_intent: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub reply_to_message_id: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub subject: Option<String>,
37 #[serde(
38 default,
39 deserialize_with = "de_null_seq",
40 skip_serializing_if = "Vec::is_empty"
41 )]
42 pub to: Vec<String>,
43 #[serde(
44 default,
45 deserialize_with = "de_null_seq",
46 skip_serializing_if = "Vec::is_empty"
47 )]
48 pub cc: Vec<String>,
49 #[serde(
50 default,
51 deserialize_with = "de_null_seq",
52 skip_serializing_if = "Vec::is_empty"
53 )]
54 pub attachments: Vec<String>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct CaseFrontmatter {
60 pub kind: String,
61 pub case_uid: String,
62 pub case_name: String,
63 #[serde(default = "default_active")]
64 pub status: String,
65 #[serde(
66 default,
67 deserialize_with = "de_null_seq",
68 skip_serializing_if = "Vec::is_empty"
69 )]
70 pub tags: Vec<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub created_rfc3339: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub updated_rfc3339: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub archived_rfc3339: Option<String>,
77 #[serde(default)]
78 pub message_count: usize,
79 #[serde(default)]
80 pub thread_count: usize,
81 #[serde(default)]
82 pub attachment_count: usize,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub last_message_rfc3339: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
89pub struct TriageFrontmatter {
90 pub kind: String,
91 pub message_id: String,
92 #[serde(
93 default,
94 deserialize_with = "de_null_seq",
95 skip_serializing_if = "Vec::is_empty"
96 )]
97 pub message_ids: Vec<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub generated_rfc3339: Option<String>,
100 #[serde(default)]
101 pub message_count: usize,
102 #[serde(default)]
103 pub attachment_count: usize,
104 #[serde(
105 default,
106 deserialize_with = "de_null_seq",
107 skip_serializing_if = "Vec::is_empty"
108 )]
109 pub suggested_case_uids: Vec<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub suggested_reason: Option<String>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
116pub struct CaseMessageFrontmatter {
117 pub kind: String,
118 pub case_uid: String,
119 pub message_id: String,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub generated_rfc3339: Option<String>,
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::markdown::{read_doc, render_frontmatter};
128
129 #[test]
130 fn draft_tolerates_empty_arrays() {
131 let text = "---\nkind: draft\ncase_uid: c20260521001\nsubject: Hello\nto:\n - a@example.com\ncc:\nattachments:\n---\nbody";
132 let parsed = read_doc::<DraftFrontmatter>(text);
133 assert!(parsed.is_ok());
134 if let Ok((fm, body)) = parsed {
135 assert_eq!(fm.case_uid, "c20260521001");
136 assert_eq!(fm.subject.as_deref(), Some("Hello"));
137 assert_eq!(fm.to, vec!["a@example.com".to_string()]);
138 assert!(fm.cc.is_empty());
139 assert!(fm.attachments.is_empty());
140 assert_eq!(body.trim(), "body");
141 }
142 }
143
144 #[test]
145 fn case_round_trips_through_render() {
146 let fm = CaseFrontmatter {
147 kind: "case".to_string(),
148 case_uid: "c20260521001".to_string(),
149 case_name: "Acme".to_string(),
150 status: "active".to_string(),
151 tags: vec!["legal".to_string()],
152 created_rfc3339: Some("2026-05-30T00:00:00Z".to_string()),
153 updated_rfc3339: Some("2026-05-30T00:00:00Z".to_string()),
154 archived_rfc3339: None,
155 message_count: 2,
156 thread_count: 0,
157 attachment_count: 1,
158 last_message_rfc3339: None,
159 };
160 let body = "\n# Title\n\n<!-- afmail:conversation:start -->\nhi\n<!-- afmail:conversation:end -->\n";
161 let rendered = render_frontmatter(&fm, body);
162 assert!(rendered.is_ok());
163 if let Ok(rendered) = rendered {
164 let reparsed = read_doc::<CaseFrontmatter>(&rendered);
165 assert!(reparsed.is_ok());
166 if let Ok((parsed, parsed_body)) = reparsed {
167 assert_eq!(parsed, fm);
168 assert_eq!(parsed_body, body.trim_start());
169 }
170 }
171 }
172
173 #[test]
174 fn case_defaults_status_active_when_missing() {
175 let text = "---\nkind: case\ncase_uid: c20260530001\ncase_name: Bare\n---\n";
176 let parsed = read_doc::<CaseFrontmatter>(text);
177 assert!(parsed.is_ok());
178 if let Ok((fm, _)) = parsed {
179 assert_eq!(fm.status, "active");
180 }
181 }
182}