use crate::config::Config;
pub(crate) fn md_to_html(s: &str) -> String {
fn esc(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '`' {
let code: String = chars.by_ref().take_while(|&ch| ch != '`').collect();
out.push_str("<code>");
out.push_str(&esc(&code));
out.push_str("</code>");
} else if c == '*' {
let bold: String = chars.by_ref().take_while(|&ch| ch != '*').collect();
out.push_str("<b>");
out.push_str(&esc(&bold));
out.push_str("</b>");
} else {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(c),
}
}
}
out
}
pub(crate) fn command_md_to_html(s: &str) -> String {
if Config::current().channels.telegram.rich_messages {
super::rich::markdown_to_html(s)
} else {
md_to_html(s)
}
}
pub(crate) fn strip_html_tags(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
for c in html.chars() {
if c == '<' {
in_tag = true;
} else if c == '>' && in_tag {
in_tag = false;
} else if !in_tag {
result.push(c);
}
}
result
.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace(""", "\"")
}
pub(crate) fn markdown_to_telegram_html(text: &str) -> String {
if super::rich::prefers_rich_render(text) {
return super::rich::markdown_to_html(text);
}
let mut result = String::with_capacity(text.len() + 256);
let mut in_code_block = false;
let mut in_plan_block = false;
let mut code_lang;
for line in text.lines() {
if !in_code_block && !in_plan_block && line.trim_start().starts_with("📊 Plan Summary") {
result.push_str("<pre>");
result.push_str(&escape_html(line));
result.push('\n');
in_plan_block = true;
continue;
}
if in_plan_block {
result.push_str(&escape_html(line));
result.push('\n');
if line.trim_start().starts_with("Success Rate:") {
result.push_str("</pre>\n");
in_plan_block = false;
}
continue;
}
if line.starts_with("```") {
if in_code_block {
result.push_str("</code></pre>\n");
in_code_block = false;
} else {
code_lang = line.trim_start_matches('`').trim().to_string();
if code_lang.is_empty() {
result.push_str("<pre><code>");
} else {
result.push_str(&format!(
"<pre><code class=\"language-{}\">",
escape_html(&code_lang)
));
}
in_code_block = true;
}
continue;
}
if in_code_block {
result.push_str(&escape_html(line));
result.push('\n');
continue;
}
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
let content = trimmed.trim_start_matches('#').trim();
let escaped = escape_html(content);
result.push_str(&format!("<b>{}</b>\n", format_inline(&escaped)));
continue;
}
if (trimmed.starts_with("- ") || trimmed.starts_with("* ")) && trimmed.len() > 2 {
let content = &trimmed[2..];
let escaped = escape_html(content);
let indent = line.len() - trimmed.len();
let spaces = &line[..indent];
result.push_str(&format!(
"{}• {}\n",
escape_html(spaces),
format_inline(&escaped)
));
continue;
}
let escaped = escape_html(line);
let formatted = format_inline(&escaped);
result.push_str(&formatted);
result.push('\n');
}
if in_code_block {
result.push_str("</code></pre>\n");
}
if in_plan_block {
result.push_str("</pre>\n");
}
result.trim_end().to_string()
}
pub(crate) fn escape_html(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
pub(crate) fn format_inline(text: &str) -> String {
let text = convert_links(text);
let mut result = String::new();
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '`' {
if let Some(end) = chars[i + 1..].iter().position(|&c| c == '`') {
let code: String = chars[i + 1..i + 1 + end].iter().collect();
result.push_str(&format!("<code>{}</code>", code));
i += end + 2;
continue;
}
} else if chars[i] == '~' && i + 1 < chars.len() && chars[i + 1] == '~' {
if let Some(end) = find_closing_marker(&chars[i + 2..], &['~', '~']) {
let inner: String = chars[i + 2..i + 2 + end].iter().collect();
result.push_str(&format!("<s>{}</s>", inner));
i += end + 4;
continue;
}
} else if chars[i] == '*' && i + 1 < chars.len() && chars[i + 1] == '*' {
if let Some(end) = find_closing_marker(&chars[i + 2..], &['*', '*']) {
let inner: String = chars[i + 2..i + 2 + end].iter().collect();
result.push_str(&format!("<b>{}</b>", inner));
i += end + 4;
continue;
}
} else if chars[i] == '_' && i + 1 < chars.len() && chars[i + 1] == '_' {
if let Some(end) = find_closing_marker(&chars[i + 2..], &['_', '_']) {
let inner: String = chars[i + 2..i + 2 + end].iter().collect();
result.push_str(&format!("<b>{}</b>", inner));
i += end + 4;
continue;
}
} else if chars[i] == '*' {
if let Some(end) = chars[i + 1..].iter().position(|&c| c == '*') {
let inner: String = chars[i + 1..i + 1 + end].iter().collect();
result.push_str(&format!("<i>{}</i>", inner));
i += end + 2;
continue;
}
} else if chars[i] == '_' {
let prev_alnum = i > 0 && chars[i - 1].is_alphanumeric();
if !prev_alnum && let Some(end) = chars[i + 1..].iter().position(|&c| c == '_') {
let next_alnum =
i + 1 + end + 1 < chars.len() && chars[i + 1 + end + 1].is_alphanumeric();
if !next_alnum && end > 0 {
let inner: String = chars[i + 1..i + 1 + end].iter().collect();
result.push_str(&format!("<i>{}</i>", inner));
i += end + 2;
continue;
}
}
}
result.push(chars[i]);
i += 1;
}
result
}
pub(crate) fn convert_links(text: &str) -> String {
let mut result = String::new();
let mut rest = text;
while let Some(open) = rest.find('[') {
result.push_str(&rest[..open]);
let after_open = &rest[open + 1..];
if let Some(close) = after_open.find("](") {
let link_text = &after_open[..close];
let after_paren = &after_open[close + 2..];
if let Some(end_paren) = after_paren.find(')') {
let url = &after_paren[..end_paren];
let clean_url = url
.replace("&", "&")
.replace("<", "<")
.replace(">", ">");
let safe_url = escape_html(&clean_url);
result.push_str(&format!("<a href=\"{}\">{}</a>", safe_url, link_text));
rest = &after_paren[end_paren + 1..];
continue;
}
}
result.push('[');
rest = after_open;
}
result.push_str(rest);
result
}
pub(crate) fn find_closing_marker(chars: &[char], marker: &[char]) -> Option<usize> {
if marker.len() != 2 {
return None;
}
(0..chars.len().saturating_sub(1)).find(|&i| chars[i] == marker[0] && chars[i + 1] == marker[1])
}
pub(crate) fn split_message(text: &str, max_len: usize) -> Vec<&str> {
if text.chars().count() <= max_len {
return vec![text];
}
let mut chunks = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
if remaining.chars().count() <= max_len {
chunks.push(remaining);
break;
}
let byte_offset = remaining
.char_indices()
.nth(max_len)
.map(|(i, _)| i)
.unwrap_or(remaining.len());
let chunk = &remaining[..byte_offset];
let break_at = chunk
.rfind('\n')
.filter(|&pos| {
let chars_after_newline = chunk[pos + 1..].chars().count();
chars_after_newline <= 200
})
.map(|pos| pos + 1)
.unwrap_or(byte_offset);
chunks.push(&remaining[..break_at]);
remaining = &remaining[break_at..];
}
chunks
}