Skip to main content

apollo/channels/
formatting.rs

1//! Outgoing message formatting per channel.
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum FormatTarget {
5    Telegram,
6    Discord,
7    WhatsApp,
8    Plain,
9}
10
11pub fn format_outgoing_text(target: FormatTarget, text: &str) -> String {
12    match target {
13        FormatTarget::Telegram => sanitize_telegram_markdown(text),
14        FormatTarget::Discord | FormatTarget::Plain => text.to_string(),
15        FormatTarget::WhatsApp => sanitize_whatsapp_text(text),
16    }
17}
18
19pub fn chunk_outgoing_text(target: FormatTarget, text: &str, max_len: usize) -> Vec<String> {
20    match target {
21        FormatTarget::Telegram => chunk_telegram_message(text, max_len),
22        _ => vec![text.to_string()],
23    }
24}
25
26fn sanitize_telegram_markdown(text: &str) -> String {
27    let mut result = String::new();
28    let lines: Vec<&str> = text.lines().collect();
29    let mut in_code_block = false;
30    let mut in_table = false;
31
32    for line in lines {
33        let trimmed = line.trim();
34
35        if trimmed.starts_with("```") {
36            in_code_block = !in_code_block;
37            if in_code_block && trimmed == "```" {
38                result.push_str("```text\n");
39                continue;
40            }
41        }
42
43        if in_code_block {
44            result.push_str(line);
45            result.push('\n');
46            continue;
47        }
48
49        if trimmed.starts_with('#') {
50            let header_text = trimmed.trim_start_matches('#').trim();
51            if !header_text.is_empty() {
52                result.push_str(&format!("*{}*\n", header_text));
53            }
54            continue;
55        }
56
57        if trimmed.contains('|') && !trimmed.is_empty() {
58            let cells: Vec<&str> = trimmed
59                .split('|')
60                .map(|s| s.trim())
61                .filter(|s| !s.is_empty())
62                .collect();
63
64            if cells
65                .iter()
66                .all(|c| c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '))
67            {
68                continue;
69            }
70
71            if !cells.is_empty() {
72                in_table = true;
73                result.push_str(&format!("• {}\n", cells.join(" | ")));
74                continue;
75            }
76        } else if in_table && !trimmed.is_empty() {
77            in_table = false;
78        }
79
80        result.push_str(line);
81        result.push('\n');
82    }
83
84    result.trim_end().to_string()
85}
86
87fn sanitize_whatsapp_text(text: &str) -> String {
88    let mut result = String::new();
89    let mut in_code_block = false;
90
91    for line in text.lines() {
92        let trimmed = line.trim();
93
94        if trimmed.starts_with("```") {
95            in_code_block = !in_code_block;
96            continue;
97        }
98
99        if in_code_block {
100            result.push_str(line);
101            result.push('\n');
102            continue;
103        }
104
105        if trimmed.starts_with('#') {
106            let header_text = trimmed.trim_start_matches('#').trim();
107            if !header_text.is_empty() {
108                result.push_str(header_text);
109                result.push('\n');
110            }
111            continue;
112        }
113
114        if trimmed.contains('|') && !trimmed.is_empty() {
115            let cells: Vec<&str> = trimmed
116                .split('|')
117                .map(|s| s.trim())
118                .filter(|s| !s.is_empty())
119                .collect();
120
121            if cells
122                .iter()
123                .all(|c| c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '))
124            {
125                continue;
126            }
127
128            if !cells.is_empty() {
129                result.push_str("- ");
130                result.push_str(&strip_inline_markdown(&cells.join(" - ")));
131                result.push('\n');
132                continue;
133            }
134        }
135
136        result.push_str(&strip_inline_markdown(line));
137        result.push('\n');
138    }
139
140    result.trim_end().to_string()
141}
142
143fn strip_inline_markdown(line: &str) -> String {
144    line.replace("**", "")
145        .replace("__", "")
146        .replace(['`', '*', '_'], "")
147}
148
149fn chunk_telegram_message(text: &str, max_len: usize) -> Vec<String> {
150    if text.len() <= max_len {
151        return vec![text.to_string()];
152    }
153
154    let mut chunks = Vec::new();
155    for block in split_blocks(text) {
156        if block.trim().is_empty() {
157            continue;
158        }
159
160        if block.starts_with("```") {
161            chunks.extend(chunk_code_block(&block, max_len));
162        } else {
163            chunks.extend(chunk_prose_block(&block, max_len));
164        }
165    }
166
167    chunks
168}
169
170fn split_blocks(text: &str) -> Vec<String> {
171    let mut blocks = Vec::new();
172    let mut current = String::new();
173    let mut in_code_block = false;
174
175    for line in text.lines() {
176        let trimmed = line.trim_start();
177        if trimmed.starts_with("```") {
178            if !current.trim().is_empty() && !in_code_block {
179                blocks.push(current.trim_end().to_string());
180                current.clear();
181            }
182
183            current.push_str(line);
184            current.push('\n');
185            in_code_block = !in_code_block;
186
187            if !in_code_block {
188                blocks.push(current.trim_end().to_string());
189                current.clear();
190            }
191            continue;
192        }
193
194        current.push_str(line);
195        current.push('\n');
196    }
197
198    if !current.trim().is_empty() {
199        blocks.push(current.trim_end().to_string());
200    }
201
202    blocks
203}
204
205fn chunk_prose_block(text: &str, max_len: usize) -> Vec<String> {
206    if text.len() <= max_len {
207        return vec![text.to_string()];
208    }
209
210    let mut chunks = Vec::new();
211    let mut current = String::new();
212
213    for para in text.split("\n\n") {
214        if para.is_empty() {
215            continue;
216        }
217
218        if current.len() + para.len() + 2 > max_len {
219            if !current.is_empty() {
220                chunks.push(current.clone());
221                current.clear();
222            }
223
224            if para.len() > max_len {
225                for sentence in split_sentences(para) {
226                    if current.len() + sentence.len() > max_len {
227                        if !current.is_empty() {
228                            chunks.push(current.clone());
229                            current.clear();
230                        }
231                        if sentence.len() > max_len {
232                            chunks.extend(hard_split_text(&sentence, max_len));
233                        } else {
234                            current = sentence;
235                        }
236                    } else {
237                        current.push_str(&sentence);
238                    }
239                }
240            } else {
241                current = para.to_string();
242            }
243        } else {
244            if !current.is_empty() {
245                current.push_str("\n\n");
246            }
247            current.push_str(para);
248        }
249    }
250
251    if !current.is_empty() {
252        chunks.push(current);
253    }
254
255    chunks
256}
257
258fn split_sentences(para: &str) -> Vec<String> {
259    let mut parts = Vec::new();
260    for (index, sentence) in para.split(". ").enumerate() {
261        if sentence.is_empty() {
262            continue;
263        }
264        if index < para.matches(". ").count() {
265            parts.push(format!("{}. ", sentence));
266        } else {
267            parts.push(sentence.to_string());
268        }
269    }
270    if parts.is_empty() {
271        vec![para.to_string()]
272    } else {
273        parts
274    }
275}
276
277fn hard_split_text(text: &str, max_len: usize) -> Vec<String> {
278    let mut chunks = Vec::new();
279    let mut current = String::new();
280
281    for ch in text.chars() {
282        if current.len() + ch.len_utf8() > max_len && !current.is_empty() {
283            chunks.push(current);
284            current = String::new();
285        }
286        current.push(ch);
287    }
288
289    if !current.is_empty() {
290        chunks.push(current);
291    }
292
293    chunks
294}
295
296fn chunk_code_block(block: &str, max_len: usize) -> Vec<String> {
297    if block.len() <= max_len {
298        return vec![block.to_string()];
299    }
300
301    let mut lines = block.lines();
302    let opening = lines.next().unwrap_or("```text");
303    let language = opening.trim_start_matches("```");
304    let closing = "```";
305    let content_lines: Vec<&str> = lines.filter(|line| *line != closing).collect();
306
307    let wrapper_len = opening.len() + closing.len() + 2;
308    let available = max_len.saturating_sub(wrapper_len).max(1);
309
310    let mut chunks = Vec::new();
311    let mut current = String::new();
312
313    for line in content_lines {
314        let candidate_len = if current.is_empty() {
315            line.len()
316        } else {
317            current.len() + 1 + line.len()
318        };
319
320        if candidate_len > available && !current.is_empty() {
321            chunks.push(wrap_code_chunk(language, &current));
322            current.clear();
323        }
324
325        if line.len() > available {
326            for part in hard_split_text(line, available) {
327                if !current.is_empty() {
328                    chunks.push(wrap_code_chunk(language, &current));
329                    current.clear();
330                }
331                chunks.push(wrap_code_chunk(language, &part));
332            }
333            continue;
334        }
335
336        if !current.is_empty() {
337            current.push('\n');
338        }
339        current.push_str(line);
340    }
341
342    if !current.is_empty() {
343        chunks.push(wrap_code_chunk(language, &current));
344    }
345
346    if chunks.is_empty() {
347        chunks.push(wrap_code_chunk(language, ""));
348    }
349
350    chunks
351}
352
353fn wrap_code_chunk(language: &str, body: &str) -> String {
354    if language.is_empty() {
355        format!("```\n{}\n```", body)
356    } else {
357        format!("```{}\n{}\n```", language, body)
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn telegram_tables_become_bullets() {
367        let input = "| Name | Value |\n| --- | --- |\n| A | B |";
368        let output = format_outgoing_text(FormatTarget::Telegram, input);
369        assert!(output.contains("• Name | Value"));
370        assert!(output.contains("• A | B"));
371    }
372
373    #[test]
374    fn whatsapp_strips_markdown_syntax() {
375        let input = "# Header\n**bold** and `code`\n| A | B |";
376        let output = format_outgoing_text(FormatTarget::WhatsApp, input);
377        assert!(output.contains("Header"));
378        assert!(output.contains("bold and code"));
379        assert!(output.contains("- A - B"));
380        assert!(!output.contains("**"));
381        assert!(!output.contains("```"));
382    }
383
384    #[test]
385    fn telegram_chunker_rewraps_code_blocks() {
386        let input =
387            "```rust\nfn main() {\n    println!(\"hello\");\n    println!(\"world\");\n}\n```";
388        let chunks = chunk_outgoing_text(FormatTarget::Telegram, input, 30);
389        assert!(chunks.len() > 1);
390        assert!(chunks.iter().all(|chunk| chunk.starts_with("```rust\n")));
391        assert!(chunks.iter().all(|chunk| chunk.ends_with("\n```")));
392    }
393}