agent_first_mail/
frontmatter.rs1use 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 subject: Option<String>,
35 #[serde(
36 default,
37 deserialize_with = "de_null_seq",
38 skip_serializing_if = "Vec::is_empty"
39 )]
40 pub to: Vec<String>,
41 #[serde(
42 default,
43 deserialize_with = "de_null_seq",
44 skip_serializing_if = "Vec::is_empty"
45 )]
46 pub cc: Vec<String>,
47 #[serde(
48 default,
49 deserialize_with = "de_null_seq",
50 skip_serializing_if = "Vec::is_empty"
51 )]
52 pub attachments: Vec<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
57pub struct TriageFrontmatter {
58 pub kind: String,
59 pub message_id: String,
60 #[serde(
61 default,
62 deserialize_with = "de_null_seq",
63 skip_serializing_if = "Vec::is_empty"
64 )]
65 pub message_ids: Vec<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub generated_rfc3339: Option<String>,
68 #[serde(default)]
69 pub message_count: usize,
70 #[serde(default)]
71 pub attachment_count: usize,
72 #[serde(
73 default,
74 deserialize_with = "de_null_seq",
75 skip_serializing_if = "Vec::is_empty"
76 )]
77 pub suggested_case_uids: Vec<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub suggested_reason: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
84pub struct CaseMessageFrontmatter {
85 pub kind: String,
86 pub case_uid: String,
87 pub message_id: String,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub generated_rfc3339: Option<String>,
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use crate::markdown::{read_doc, render_frontmatter};
96
97 #[test]
98 fn draft_tolerates_empty_arrays() {
99 let text = "---\nkind: draft\ncase_uid: c20260521001\nsubject: Hello\nto:\n - a@example.com\ncc:\nattachments:\n---\nbody";
100 let parsed = read_doc::<DraftFrontmatter>(text);
101 assert!(parsed.is_ok());
102 if let Ok((fm, body)) = parsed {
103 assert_eq!(fm.case_uid, "c20260521001");
104 assert_eq!(fm.subject.as_deref(), Some("Hello"));
105 assert_eq!(fm.to, vec!["a@example.com".to_string()]);
106 assert!(fm.cc.is_empty());
107 assert!(fm.attachments.is_empty());
108 assert_eq!(body.trim(), "body");
109 }
110 }
111
112 #[test]
113 fn case_round_trips_through_render() {
114 let mut fm = CaseFrontmatter::new_case("c20260521001", "Acme", "2026-05-30T00:00:00Z");
115 fm.tags = vec!["legal".to_string()];
116 fm.attachment_count = 1;
117 let body = "\n# Title\n\n<!-- afmail:conversation:start -->\nhi\n<!-- afmail:conversation:end -->\n";
118 let rendered = render_frontmatter(&fm, body);
119 assert!(rendered.is_ok());
120 if let Ok(rendered) = rendered {
121 let reparsed = read_doc::<CaseFrontmatter>(&rendered);
122 assert!(reparsed.is_ok());
123 if let Ok((parsed, parsed_body)) = reparsed {
124 assert_eq!(parsed, fm);
125 assert_eq!(parsed_body, body.trim_start());
126 }
127 }
128 }
129
130 #[test]
131 fn case_collection_uses_shared_schema() {
132 let fm = CaseFrontmatter::new_case("c20260530001", "Bare", "2026-05-30T00:00:00Z");
133 assert_eq!(fm.schema_name, crate::types::CASE_SCHEMA_NAME);
134 assert_eq!(fm.status, "active");
135 }
136}