mxr_compose/
frontmatter.rs1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct ComposeFrontmatter {
6 pub to: String,
7 #[serde(default)]
8 pub cc: String,
9 #[serde(default)]
10 pub bcc: String,
11 pub subject: String,
12 pub from: String,
13 #[serde(
14 default,
15 rename = "in-reply-to",
16 skip_serializing_if = "Option::is_none"
17 )]
18 pub in_reply_to: Option<String>,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
20 pub references: Vec<String>,
21 #[serde(default)]
22 pub attach: Vec<String>,
23}
24
25const FRONTMATTER_DELIMITER: &str = "---";
26const CONTEXT_MARKER: &str = "# --- context (stripped before sending) ---";
27
28pub fn parse_compose_file(content: &str) -> Result<(ComposeFrontmatter, String), ComposeError> {
31 let content = content.trim_start();
32
33 if !content.starts_with(FRONTMATTER_DELIMITER) {
34 return Err(ComposeError::MissingFrontmatter);
35 }
36
37 let after_first = &content[FRONTMATTER_DELIMITER.len()..];
38 let end_pos = after_first
39 .find(&format!("\n{FRONTMATTER_DELIMITER}"))
40 .ok_or(ComposeError::MissingFrontmatter)?;
41
42 let yaml_str = &after_first[..end_pos];
43 let rest = &after_first[end_pos + 1 + FRONTMATTER_DELIMITER.len()..];
44
45 let frontmatter: ComposeFrontmatter = serde_yaml::from_str(yaml_str)
46 .map_err(|e| ComposeError::InvalidFrontmatter(e.to_string()))?;
47
48 let body = if let Some(ctx_pos) = rest.find(CONTEXT_MARKER) {
50 rest[..ctx_pos].to_string()
51 } else {
52 rest.to_string()
53 };
54
55 let body = body.trim().to_string();
56
57 Ok((frontmatter, body))
58}
59
60pub fn render_compose_file(
62 frontmatter: &ComposeFrontmatter,
63 body: &str,
64 context: Option<&str>,
65) -> Result<String, ComposeError> {
66 let yaml = serde_yaml::to_string(frontmatter)
67 .map_err(|e| ComposeError::InvalidFrontmatter(e.to_string()))?;
68
69 let mut output = format!("---\n{yaml}---\n\n{body}");
70
71 if let Some(ctx) = context {
72 output.push_str("\n\n");
73 output.push_str(CONTEXT_MARKER);
74 output.push('\n');
75 for line in ctx.lines() {
76 output.push_str(&format!("# {line}\n"));
77 }
78 }
79
80 Ok(output)
81}
82
83#[derive(Debug, thiserror::Error)]
84pub enum ComposeError {
85 #[error("Missing YAML frontmatter delimiters (---)")]
86 MissingFrontmatter,
87 #[error("Invalid frontmatter: {0}")]
88 InvalidFrontmatter(String),
89 #[error("Attachment not found: {0}")]
90 AttachmentNotFound(String),
91 #[error("No recipients specified")]
92 NoRecipients,
93 #[error("Editor failed: {0}")]
94 EditorFailed(String),
95 #[error("IO error: {0}")]
96 Io(#[from] std::io::Error),
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn parse_basic_frontmatter() {
105 let content =
106 "---\nto: alice@example.com\nsubject: Hello\nfrom: me@example.com\n---\n\nBody here.";
107 let (fm, body) = parse_compose_file(content).unwrap();
108 assert_eq!(fm.to, "alice@example.com");
109 assert_eq!(fm.subject, "Hello");
110 assert_eq!(fm.from, "me@example.com");
111 assert_eq!(body, "Body here.");
112 }
113
114 #[test]
115 fn context_block_stripped() {
116 let content = "---\nto: alice@example.com\nsubject: test\nfrom: me@example.com\n---\n\nHello!\n\n# --- context (stripped before sending) ---\n# Some context here\n# More context";
117 let (_, body) = parse_compose_file(content).unwrap();
118 assert_eq!(body, "Hello!");
119 assert!(!body.contains("context"));
120 }
121
122 #[test]
123 fn missing_frontmatter_errors() {
124 let content = "No frontmatter here.";
125 assert!(parse_compose_file(content).is_err());
126 }
127
128 #[test]
129 fn roundtrip_frontmatter() {
130 let fm = ComposeFrontmatter {
131 to: "alice@example.com".into(),
132 cc: String::new(),
133 bcc: String::new(),
134 subject: "Test Subject".into(),
135 from: "me@example.com".into(),
136 in_reply_to: None,
137 references: Vec::new(),
138 attach: Vec::new(),
139 };
140 let rendered = render_compose_file(&fm, "Hello!", None).unwrap();
141 let (parsed_fm, parsed_body) = parse_compose_file(&rendered).unwrap();
142 assert_eq!(parsed_fm.to, "alice@example.com");
143 assert_eq!(parsed_fm.subject, "Test Subject");
144 assert_eq!(parsed_body, "Hello!");
145 }
146
147 #[test]
148 fn roundtrip_with_context() {
149 let fm = ComposeFrontmatter {
150 to: "alice@example.com".into(),
151 cc: String::new(),
152 bcc: String::new(),
153 subject: "Re: Meeting".into(),
154 from: "me@example.com".into(),
155 in_reply_to: Some("<msg-123@example.com>".into()),
156 references: vec!["<root@example.com>".into(), "<msg-123@example.com>".into()],
157 attach: Vec::new(),
158 };
159 let context = "From: alice@example.com\nDate: 2026-03-15\n\nOriginal message.";
160 let rendered = render_compose_file(&fm, "My reply.", Some(context)).unwrap();
161 let (parsed_fm, parsed_body) = parse_compose_file(&rendered).unwrap();
162 assert_eq!(parsed_fm.subject, "Re: Meeting");
163 assert_eq!(
164 parsed_fm.references,
165 vec!["<root@example.com>".to_string(), "<msg-123@example.com>".to_string()]
166 );
167 assert_eq!(parsed_body, "My reply.");
168 assert!(!parsed_body.contains("Original message"));
169 }
170}