1use serde::{Deserialize, Deserializer, Serialize};
9
10pub use crate::types::MessageCollection as CaseFrontmatter;
11
12fn de_null_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
16where
17 D: Deserializer<'de>,
18{
19 Ok(Option::<Vec<String>>::deserialize(deserializer)?.unwrap_or_default())
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
25pub struct DraftFrontmatter {
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub kind: Option<String>,
28 pub case_uid: String,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub send_intent: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub reply_to_message_id: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub identity: 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, Default)]
59#[serde(deny_unknown_fields)]
60pub struct IdentityFileFrontmatter {
61 pub kind: String,
62 pub identity: String,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub name: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub footer: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
71pub struct TriageFrontmatter {
72 pub kind: String,
73 pub message_id: String,
74 #[serde(
75 default,
76 deserialize_with = "de_null_seq",
77 skip_serializing_if = "Vec::is_empty"
78 )]
79 pub message_ids: Vec<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub generated_rfc3339: Option<String>,
82 #[serde(default)]
83 pub message_count: usize,
84 #[serde(default)]
85 pub attachment_count: usize,
86 #[serde(
87 default,
88 deserialize_with = "de_null_seq",
89 skip_serializing_if = "Vec::is_empty"
90 )]
91 pub suggested_case_uids: Vec<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub suggested_reason: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
98pub struct CaseMessageFrontmatter {
99 pub kind: String,
100 pub case_uid: String,
101 pub message_id: String,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub generated_rfc3339: Option<String>,
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use crate::markdown::{read_doc, render_frontmatter};
110
111 #[test]
112 fn draft_tolerates_empty_arrays() {
113 let text = "---\nkind: draft\ncase_uid: c20260521001\nsubject: Hello\nto:\n - a@example.com\ncc:\nattachments:\n---\nbody";
114 let parsed = read_doc::<DraftFrontmatter>(text);
115 assert!(parsed.is_ok());
116 if let Ok((fm, body)) = parsed {
117 assert_eq!(fm.case_uid, "c20260521001");
118 assert_eq!(fm.subject.as_deref(), Some("Hello"));
119 assert_eq!(fm.to, vec!["a@example.com".to_string()]);
120 assert!(fm.cc.is_empty());
121 assert!(fm.attachments.is_empty());
122 assert_eq!(body.trim(), "body");
123 }
124 }
125
126 #[test]
127 fn case_round_trips_through_render() {
128 let mut fm = CaseFrontmatter::new_case("c20260521001", "Acme", "2026-05-30T00:00:00Z");
129 fm.tags = vec!["legal".to_string()];
130 fm.attachment_count = 1;
131 let body = "\n# Title\n\n<!-- afmail:conversation:start -->\nhi\n<!-- afmail:conversation:end -->\n";
132 let rendered = render_frontmatter(&fm, body);
133 assert!(rendered.is_ok());
134 if let Ok(rendered) = rendered {
135 let reparsed = read_doc::<CaseFrontmatter>(&rendered);
136 assert!(reparsed.is_ok());
137 if let Ok((parsed, parsed_body)) = reparsed {
138 assert_eq!(parsed, fm);
139 assert_eq!(parsed_body, body.trim_start());
140 }
141 }
142 }
143
144 #[test]
145 fn case_collection_uses_shared_schema() {
146 let fm = CaseFrontmatter::new_case("c20260530001", "Bare", "2026-05-30T00:00:00Z");
147 assert_eq!(fm.schema_name, crate::types::CASE_SCHEMA_NAME);
148 assert_eq!(fm.status, "active");
149 }
150}