Skip to main content

agent_first_mail/
frontmatter.rs

1//! Typed metadata for markdown frontmatter and adjacent JSON state.
2//!
3//! Draft and triage markdown use `markdown::read_doc::<T>` and
4//! `markdown::render_frontmatter`; cases store the same typed metadata in
5//! `data/case.json`. Body content (conversation blocks, notes sections) is never
6//! modeled here; it stays raw markdown.
7
8use serde::{Deserialize, Deserializer, Serialize};
9
10/// Deserialize a sequence that may be written as YAML `null` (a bare `key:`
11/// with no value) into an empty `Vec`. Agents hand-write drafts and frequently
12/// leave `attachments:` / `cc:` empty.
13fn 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/// Frontmatter of an agent-authored `drafts/*.md`. Shared by draft validation
25/// and outbound message building so both agree on one schema.
26#[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/// Canonical case metadata stored in `data/case.json`.
58#[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/// Frontmatter of a generated `triage/message_*.md` view.
88#[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/// Frontmatter of generated `cases/<group>/<case-uid>/views/messages/<message-id>.md`.
115#[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}