Skip to main content

agent_first_mail/
markdown.rs

1use crate::error::{AppError, Result};
2use serde::de::DeserializeOwned;
3use serde::Serialize;
4
5pub const CONVERSATION_START: &str = "<!-- afmail:conversation:start -->";
6pub const CONVERSATION_END: &str = "<!-- afmail:conversation:end -->";
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct MarkdownDoc {
10    pub frontmatter: String,
11    pub body: String,
12}
13
14pub fn split_frontmatter(input: &str) -> Result<MarkdownDoc> {
15    let mut lines = input.lines();
16    if lines.next() != Some("---") {
17        return Err(AppError::new(
18            "invalid_request",
19            "markdown file is missing frontmatter",
20        ));
21    }
22    let mut frontmatter_lines = Vec::new();
23    let mut body_lines = Vec::new();
24    let mut in_frontmatter = true;
25    for line in lines {
26        if in_frontmatter && line == "---" {
27            in_frontmatter = false;
28            continue;
29        }
30        if in_frontmatter {
31            frontmatter_lines.push(line);
32        } else {
33            body_lines.push(line);
34        }
35    }
36    if in_frontmatter {
37        return Err(AppError::new(
38            "invalid_request",
39            "markdown frontmatter is not closed",
40        ));
41    }
42    Ok(MarkdownDoc {
43        frontmatter: frontmatter_lines.join("\n"),
44        body: body_lines.join("\n"),
45    })
46}
47
48/// Deserialize a YAML frontmatter block into a typed struct.
49pub fn parse_frontmatter<T: DeserializeOwned>(frontmatter: &str) -> Result<T> {
50    serde_yaml::from_str(frontmatter)
51        .map_err(|e| AppError::new("invalid_request", format!("invalid frontmatter: {e}")))
52}
53
54/// Split a markdown document and deserialize its frontmatter, returning the
55/// parsed struct and the raw body (untouched markdown).
56pub fn read_doc<T: DeserializeOwned>(input: &str) -> Result<(T, String)> {
57    let doc = split_frontmatter(input)?;
58    let parsed = parse_frontmatter::<T>(&doc.frontmatter)?;
59    Ok((parsed, doc.body))
60}
61
62/// Re-attach a serialized frontmatter struct to an untouched markdown body.
63pub fn render_frontmatter<T: Serialize>(frontmatter: &T, body: &str) -> Result<String> {
64    let yaml = serde_yaml::to_string(frontmatter)
65        .map_err(|e| AppError::new("internal", format!("serialize frontmatter: {e}")))?;
66    Ok(format!("---\n{}---\n{}\n", yaml, body.trim_start()))
67}
68
69pub fn extract_conversation(input: &str) -> Result<String> {
70    let start = input.find(CONVERSATION_START).ok_or_else(|| {
71        AppError::new("invalid_request", "conversation start marker was not found")
72    })?;
73    let after_start = start + CONVERSATION_START.len();
74    let end_rel = input[after_start..]
75        .find(CONVERSATION_END)
76        .ok_or_else(|| AppError::new("invalid_request", "conversation end marker was not found"))?;
77    let end = after_start + end_rel;
78    Ok(input[after_start..end].trim_matches('\n').to_string())
79}
80
81pub fn append_conversation(case_md: &str, conversation: &str) -> Result<String> {
82    let end = case_md.find(CONVERSATION_END).ok_or_else(|| {
83        AppError::new(
84            "invalid_request",
85            "case conversation end marker was not found",
86        )
87    })?;
88    let before = case_md[..end].trim_end();
89    let after = &case_md[end..];
90    Ok(format!("{before}\n\n{}\n\n{after}", conversation.trim()))
91}
92
93pub fn replace_conversation(markdown: &str, conversation: &str) -> Result<String> {
94    let start = markdown.find(CONVERSATION_START).ok_or_else(|| {
95        AppError::new("invalid_request", "conversation start marker was not found")
96    })?;
97    let after_start = start + CONVERSATION_START.len();
98    let end_rel = markdown[after_start..]
99        .find(CONVERSATION_END)
100        .ok_or_else(|| AppError::new("invalid_request", "conversation end marker was not found"))?;
101    let end = after_start + end_rel;
102    let before = markdown[..after_start].trim_end();
103    let after = markdown[end..].trim_start_matches('\n');
104    Ok(format!("{before}\n\n{}\n\n{after}", conversation.trim()))
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn extracts_and_appends_conversation() {
113        let triage = format!("a\n{CONVERSATION_START}\nhello\n{CONVERSATION_END}\nz");
114        let conversation = extract_conversation(&triage);
115        assert_eq!(conversation, Ok("hello".to_string()));
116        let case_md = format!("a\n{CONVERSATION_START}\nold\n{CONVERSATION_END}\nz");
117        let updated = append_conversation(&case_md, "new");
118        assert!(updated.is_ok());
119        assert!(updated
120            .as_ref()
121            .map(|s| s.contains("old\n\nnew"))
122            .unwrap_or(false));
123        let replaced = replace_conversation(&case_md, "new");
124        assert_eq!(
125            replaced,
126            Ok(format!(
127                "a\n{CONVERSATION_START}\n\nnew\n\n{CONVERSATION_END}\nz"
128            ))
129        );
130    }
131}