Skip to main content

mxr_compose/
frontmatter.rs

1use serde::{Deserialize, Serialize};
2
3/// YAML frontmatter for compose files.
4#[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
28/// Parse a compose file into frontmatter + body.
29/// Strips the context block (everything after the context marker).
30pub 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    // Strip context block
49    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
60/// Generate a compose file string from frontmatter + body + optional context.
61pub 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}