agent_first_mail/
markdown.rs1use 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
48pub 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
54pub 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
62pub 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}