use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct CommentBlock {
pub text: String,
#[serde(skip_serializing_if = "CommentAttributes::is_empty")]
pub attributes: CommentAttributes,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct CommentAttributes {
#[serde(skip_serializing_if = "is_false")]
pub bold: bool,
#[serde(skip_serializing_if = "is_false")]
pub italic: bool,
#[serde(skip_serializing_if = "is_false")]
pub code: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub link: Option<String>,
#[serde(rename = "code-block", skip_serializing_if = "Option::is_none")]
pub code_block: Option<CodeBlockAttr>,
#[serde(skip_serializing_if = "Option::is_none")]
pub list: Option<ListAttr>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct CodeBlockAttr {
#[serde(rename = "code-block")]
pub code_block: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ListAttr {
pub list: String,
}
impl CommentAttributes {
fn is_empty(&self) -> bool {
!self.bold
&& !self.italic
&& !self.code
&& self.link.is_none()
&& self.code_block.is_none()
&& self.list.is_none()
}
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool {
!*b
}
fn newline(attributes: CommentAttributes) -> CommentBlock {
CommentBlock {
text: "\n".to_string(),
attributes,
}
}
pub fn markdown_to_comment_blocks(body: &str) -> Vec<CommentBlock> {
if body.is_empty() {
return Vec::new();
}
let mut blocks: Vec<CommentBlock> = Vec::new();
let mut in_code_fence = false;
let lines: Vec<&str> = body.split('\n').collect();
let mut idx = 0;
while idx < lines.len() {
let line = lines[idx];
if line.trim_start().starts_with("```") {
in_code_fence = !in_code_fence;
idx += 1;
continue;
}
if in_code_fence {
if !line.is_empty() {
blocks.push(CommentBlock {
text: line.to_string(),
attributes: CommentAttributes::default(),
});
}
blocks.push(newline(CommentAttributes {
code_block: Some(CodeBlockAttr {
code_block: "plain".to_string(),
}),
..Default::default()
}));
idx += 1;
continue;
}
if is_table_row(line)
&& idx + 1 < lines.len()
&& is_separator_row(&split_table_row(lines[idx + 1]))
{
let mut rows = vec![line.to_string()];
let mut j = idx + 1;
while j < lines.len() && is_table_row(lines[j]) {
rows.push(lines[j].to_string());
j += 1;
}
for rendered in render_table(&rows) {
blocks.push(CommentBlock {
text: rendered,
attributes: CommentAttributes::default(),
});
blocks.push(newline(CommentAttributes {
code_block: Some(CodeBlockAttr {
code_block: "plain".to_string(),
}),
..Default::default()
}));
}
idx = j;
continue;
}
if let Some((checked, rest)) = strip_task_item(line) {
push_inline_runs(&mut blocks, rest);
blocks.push(newline(CommentAttributes {
list: Some(ListAttr {
list: if checked { "checked" } else { "unchecked" }.to_string(),
}),
..Default::default()
}));
idx += 1;
continue;
}
if let Some(rest) = strip_bullet(line) {
push_inline_runs(&mut blocks, rest);
blocks.push(newline(CommentAttributes {
list: Some(ListAttr {
list: "bullet".to_string(),
}),
..Default::default()
}));
idx += 1;
continue;
}
if let Some(rest) = strip_ordered(line) {
push_inline_runs(&mut blocks, rest);
blocks.push(newline(CommentAttributes {
list: Some(ListAttr {
list: "ordered".to_string(),
}),
..Default::default()
}));
idx += 1;
continue;
}
if let Some((level, rest)) = strip_heading(line) {
let prefix = heading_prefix(level);
if !prefix.is_empty() {
blocks.push(CommentBlock {
text: prefix.to_string(),
attributes: CommentAttributes {
bold: true,
..Default::default()
},
});
}
push_bold_run(&mut blocks, rest);
blocks.push(newline(CommentAttributes::default()));
idx += 1;
continue;
}
if matches!(line.trim(), "---" | "***" | "___") {
blocks.push(CommentBlock {
text: "\u{2500}".repeat(10),
attributes: CommentAttributes::default(),
});
blocks.push(newline(CommentAttributes::default()));
idx += 1;
continue;
}
if let Some(rest) = strip_blockquote(line) {
blocks.push(CommentBlock {
text: "| ".to_string(),
attributes: CommentAttributes::default(),
});
for mut run in parse_inline(rest) {
run.attributes.italic = true;
blocks.push(run);
}
blocks.push(newline(CommentAttributes::default()));
idx += 1;
continue;
}
push_inline_runs(&mut blocks, line);
blocks.push(newline(CommentAttributes::default()));
idx += 1;
}
while blocks.len() > 1 {
let last = &blocks[blocks.len() - 1];
if last.text == "\n" && last.attributes.is_empty() {
blocks.pop();
} else {
break;
}
}
blocks
}
fn strip_bullet(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
for marker in ['-', '*', '+'] {
if let Some(rest) = trimmed.strip_prefix(marker) {
if let Some(rest) = rest.strip_prefix(' ') {
return Some(rest);
}
}
}
None
}
fn strip_ordered(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
let digits_end = trimmed.find(|c: char| !c.is_ascii_digit())?;
if digits_end == 0 {
return None;
}
let after = &trimmed[digits_end..];
for sep in ['.', ')'] {
if let Some(rest) = after.strip_prefix(sep) {
if let Some(rest) = rest.strip_prefix(' ') {
return Some(rest);
}
}
}
None
}
fn strip_heading(line: &str) -> Option<(usize, &str)> {
let hashes = line.chars().take_while(|&c| c == '#').count();
if (1..=6).contains(&hashes) {
let rest = &line[hashes..];
if let Some(rest) = rest.strip_prefix(' ') {
return Some((hashes, rest));
}
}
None
}
fn heading_prefix(level: usize) -> &'static str {
match level {
1 => "\u{25C6} ", 2 => "\u{25B8} ", _ => "",
}
}
fn strip_task_item(line: &str) -> Option<(bool, &str)> {
let rest = strip_bullet(line)?;
if let Some(rest) = rest.strip_prefix("[ ] ") {
Some((false, rest))
} else if let Some(rest) = rest
.strip_prefix("[x] ")
.or_else(|| rest.strip_prefix("[X] "))
{
Some((true, rest))
} else {
None
}
}
fn strip_blockquote(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix('>')?;
Some(rest.strip_prefix(' ').unwrap_or(rest))
}
fn push_bold_run(blocks: &mut Vec<CommentBlock>, text: &str) {
if text.is_empty() {
return;
}
blocks.push(CommentBlock {
text: text.to_string(),
attributes: CommentAttributes {
bold: true,
..Default::default()
},
});
}
fn push_inline_runs(blocks: &mut Vec<CommentBlock>, line: &str) {
for run in parse_inline(line) {
blocks.push(run);
}
}
fn parse_inline(line: &str) -> Vec<CommentBlock> {
let mut runs: Vec<CommentBlock> = Vec::new();
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
let mut plain = String::new();
let flush_plain = |plain: &mut String, runs: &mut Vec<CommentBlock>| {
if !plain.is_empty() {
runs.push(CommentBlock {
text: std::mem::take(plain),
attributes: CommentAttributes::default(),
});
}
};
while i < chars.len() {
let c = chars[i];
if c == '`' {
if let Some(close) = find_char(&chars, i + 1, '`') {
flush_plain(&mut plain, &mut runs);
let text: String = chars[i + 1..close].iter().collect();
runs.push(CommentBlock {
text,
attributes: CommentAttributes {
code: true,
..Default::default()
},
});
i = close + 1;
continue;
}
}
if c == '[' {
if let Some((text_end, url_start, url_end)) = find_link(&chars, i) {
flush_plain(&mut plain, &mut runs);
let text: String = chars[i + 1..text_end].iter().collect();
let url: String = chars[url_start..url_end].iter().collect();
for mut run in parse_inline(&text) {
run.attributes.link = Some(url.clone());
runs.push(run);
}
i = url_end + 1;
continue;
}
}
if c == '*' && i + 1 < chars.len() && chars[i + 1] == '*' {
if let Some(close) = find_double_star(&chars, i + 2) {
flush_plain(&mut plain, &mut runs);
let inner: String = chars[i + 2..close].iter().collect();
for mut run in parse_inline(&inner) {
run.attributes.bold = true;
runs.push(run);
}
i = close + 2;
continue;
}
}
if c == '*' || c == '_' {
if let Some(close) = find_char(&chars, i + 1, c) {
if close > i + 1 {
flush_plain(&mut plain, &mut runs);
let inner: String = chars[i + 1..close].iter().collect();
for mut run in parse_inline(&inner) {
run.attributes.italic = true;
runs.push(run);
}
i = close + 1;
continue;
}
}
}
plain.push(c);
i += 1;
}
flush_plain(&mut plain, &mut runs);
runs
}
fn find_char(chars: &[char], from: usize, needle: char) -> Option<usize> {
(from..chars.len()).find(|&j| chars[j] == needle)
}
fn find_double_star(chars: &[char], from: usize) -> Option<usize> {
let mut j = from;
while j + 1 < chars.len() {
if chars[j] == '*' && chars[j + 1] == '*' {
return Some(j);
}
j += 1;
}
None
}
fn find_link(chars: &[char], open: usize) -> Option<(usize, usize, usize)> {
let close_br = find_char(chars, open + 1, ']')?;
if chars.get(close_br + 1) != Some(&'(') {
return None;
}
let url_start = close_br + 2;
let close_paren = find_char(chars, url_start, ')')?;
Some((close_br, url_start, close_paren))
}
fn is_table_row(line: &str) -> bool {
let l = line.trim();
l.starts_with('|') && l.matches('|').count() >= 2
}
fn split_table_row(line: &str) -> Vec<String> {
let l = line.trim();
let l = l.strip_prefix('|').unwrap_or(l);
let l = l.strip_suffix('|').unwrap_or(l);
l.split('|').map(|c| c.trim().to_string()).collect()
}
fn is_separator_row(cells: &[String]) -> bool {
!cells.is_empty()
&& cells.iter().all(|c| {
let t = c.trim();
!t.is_empty() && t.contains('-') && t.chars().all(|ch| ch == '-' || ch == ':')
})
}
#[derive(Clone, Copy)]
enum Align {
Left,
Right,
Center,
}
fn parse_align(cell: &str) -> Align {
let t = cell.trim();
match (t.starts_with(':'), t.ends_with(':')) {
(true, true) => Align::Center,
(false, true) => Align::Right,
_ => Align::Left,
}
}
fn display_width(s: &str) -> usize {
s.chars().map(char_width).sum()
}
fn char_width(ch: char) -> usize {
let c = ch as u32;
let double = (0x1100..=0x115F).contains(&c)
|| (0x2E80..=0xA4CF).contains(&c)
|| (0xAC00..=0xD7A3).contains(&c)
|| (0xF900..=0xFAFF).contains(&c)
|| (0xFF00..=0xFF60).contains(&c)
|| (0xFFE0..=0xFFE6).contains(&c)
|| (0x1F300..=0x1FAFF).contains(&c)
|| (0x20000..=0x3FFFD).contains(&c);
if double { 2 } else { 1 }
}
fn pad_cell(cell: &str, width: usize, align: Align) -> String {
let w = display_width(cell);
let pad = width.saturating_sub(w);
match align {
Align::Left => format!("{}{}", cell, " ".repeat(pad)),
Align::Right => format!("{}{}", " ".repeat(pad), cell),
Align::Center => {
let left = pad / 2;
format!("{}{}{}", " ".repeat(left), cell, " ".repeat(pad - left))
}
}
}
fn render_table(rows: &[String]) -> Vec<String> {
let parsed: Vec<Vec<String>> = rows.iter().map(|r| split_table_row(r)).collect();
let ncols = parsed.iter().map(|c| c.len()).max().unwrap_or(0);
if ncols == 0 {
return Vec::new();
}
let mut aligns = vec![Align::Left; ncols];
let mut data: Vec<Vec<String>> = Vec::new();
for cells in &parsed {
if is_separator_row(cells) {
for (i, c) in cells.iter().enumerate().take(ncols) {
aligns[i] = parse_align(c);
}
} else {
let mut row = cells.clone();
row.resize(ncols, String::new());
data.push(row);
}
}
if data.is_empty() {
return Vec::new();
}
let mut width = vec![0usize; ncols];
for row in &data {
for (i, cell) in row.iter().enumerate() {
width[i] = width[i].max(display_width(cell));
}
}
let mut out: Vec<String> = Vec::new();
for (ri, row) in data.iter().enumerate() {
let cells: Vec<String> = row
.iter()
.enumerate()
.map(|(i, cell)| pad_cell(cell, width[i], aligns[i]))
.collect();
out.push(format!("| {} |", cells.join(" | ")));
if ri == 0 {
let dividers: Vec<String> = width.iter().map(|w| "-".repeat(*w)).collect();
out.push(format!("|-{}-|", dividers.join("-|-")));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn plain(text: &str) -> CommentBlock {
CommentBlock {
text: text.to_string(),
attributes: CommentAttributes::default(),
}
}
#[test]
fn plain_paragraph() {
let blocks = markdown_to_comment_blocks("hello world");
assert_eq!(blocks, vec![plain("hello world")]);
}
#[test]
fn inline_code_splits_runs() {
let blocks = markdown_to_comment_blocks("the `SecretBackend` trait");
assert_eq!(
blocks,
vec![
plain("the "),
CommentBlock {
text: "SecretBackend".to_string(),
attributes: CommentAttributes {
code: true,
..Default::default()
},
},
plain(" trait"),
]
);
}
#[test]
fn inline_code_does_not_fragment_surrounding_prose() {
let blocks = markdown_to_comment_blocks("a `x` b `y` c");
let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect();
assert_eq!(texts, vec!["a ", "x", " b ", "y", " c"]);
assert!(blocks[1].attributes.code);
assert!(blocks[3].attributes.code);
}
#[test]
fn bold_run() {
let blocks = markdown_to_comment_blocks("a **bold** b");
assert_eq!(blocks[1].text, "bold");
assert!(blocks[1].attributes.bold);
}
#[test]
fn bold_with_nested_inline_code() {
let blocks = markdown_to_comment_blocks("**`SecretBackend`**");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "SecretBackend");
assert!(blocks[0].attributes.bold);
assert!(blocks[0].attributes.code);
}
#[test]
fn bold_with_mixed_inner_content() {
let blocks = markdown_to_comment_blocks("**`backend.rs`** (new)");
assert_eq!(blocks[0].text, "backend.rs");
assert!(blocks[0].attributes.bold && blocks[0].attributes.code);
assert_eq!(blocks[1].text, " (new)");
assert!(blocks[1].attributes.is_empty());
}
#[test]
fn fenced_code_block() {
let body = "```rust\nlet x = 1;\nlet y = 2;\n```";
let blocks = markdown_to_comment_blocks(body);
assert_eq!(blocks.len(), 4);
assert_eq!(blocks[0].text, "let x = 1;");
assert!(blocks[1].attributes.code_block.is_some());
assert_eq!(blocks[1].text, "\n");
assert_eq!(blocks[2].text, "let y = 2;");
assert!(blocks[3].attributes.code_block.is_some());
}
#[test]
fn fenced_code_block_does_not_parse_inline() {
let body = "```\na `b` **c**\n```";
let blocks = markdown_to_comment_blocks(body);
assert_eq!(blocks[0].text, "a `b` **c**");
assert!(blocks[0].attributes.is_empty());
}
#[test]
fn bullet_list() {
let body = "- one\n- two";
let blocks = markdown_to_comment_blocks(body);
assert_eq!(blocks[0].text, "one");
assert_eq!(
blocks[1].attributes.list.as_ref().map(|l| l.list.as_str()),
Some("bullet")
);
assert_eq!(blocks[2].text, "two");
assert_eq!(
blocks[3].attributes.list.as_ref().map(|l| l.list.as_str()),
Some("bullet")
);
}
#[test]
fn bullet_list_item_keeps_inline_code() {
let blocks = markdown_to_comment_blocks("- first with `code`");
assert_eq!(blocks[0].text, "first with ");
assert!(blocks[1].attributes.code);
assert_eq!(blocks[1].text, "code");
assert!(blocks[2].attributes.list.is_some());
}
#[test]
fn ordered_list() {
let body = "1. one\n2. two";
let blocks = markdown_to_comment_blocks(body);
assert_eq!(blocks[0].text, "one");
assert_eq!(
blocks[1].attributes.list.as_ref().map(|l| l.list.as_str()),
Some("ordered")
);
}
#[test]
fn heading_becomes_bold() {
let blocks = markdown_to_comment_blocks("## Done");
assert_eq!(blocks[0].text, "\u{25B8} ");
assert!(blocks[0].attributes.bold);
assert_eq!(blocks[1].text, "Done");
assert!(blocks[1].attributes.bold);
}
#[test]
fn h3_heading_has_no_glyph() {
let blocks = markdown_to_comment_blocks("### Sub");
assert_eq!(blocks[0].text, "Sub");
assert!(blocks[0].attributes.bold);
}
#[test]
fn unterminated_inline_code_is_literal() {
let blocks = markdown_to_comment_blocks("a `b c");
assert_eq!(blocks, vec![plain("a `b c")]);
}
#[test]
fn serializes_attributes_with_clickup_shape() {
let body = "- item\n```\ncode\n```\n`x`";
let blocks = markdown_to_comment_blocks(body);
let json = serde_json::to_string(&blocks).unwrap();
assert!(json.contains(r#""list":{"list":"bullet"}"#));
assert!(json.contains(r#""code-block":{"code-block":"plain"}"#));
assert!(json.contains(r#""code":true"#));
assert!(json.contains(r#"{"text":"item"}"#));
}
#[test]
fn trailing_blank_lines_are_trimmed() {
for body in ["a", "a\n", "a\n\n", "a\n\n\n"] {
let blocks = markdown_to_comment_blocks(body);
assert_eq!(
blocks,
vec![plain("a")],
"body {body:?} should trim every trailing plain newline"
);
}
}
#[test]
fn trailing_block_separator_is_preserved() {
let blocks = markdown_to_comment_blocks("- item\n");
let last = blocks.last().unwrap();
assert!(last.attributes.list.is_some());
}
#[test]
fn empty_body_yields_no_blocks() {
assert!(markdown_to_comment_blocks("").is_empty());
}
#[test]
fn italic_run() {
let blocks = markdown_to_comment_blocks("a *b* c");
assert_eq!(blocks[0], plain("a "));
assert_eq!(blocks[1].text, "b");
assert!(blocks[1].attributes.italic && !blocks[1].attributes.bold);
assert_eq!(blocks[2], plain(" c"));
}
#[test]
fn italic_underscore() {
let blocks = markdown_to_comment_blocks("_x_");
assert_eq!(blocks[0].text, "x");
assert!(blocks[0].attributes.italic);
}
#[test]
fn bold_not_swallowed_by_italic() {
let blocks = markdown_to_comment_blocks("**b**");
assert_eq!(blocks[0].text, "b");
assert!(blocks[0].attributes.bold && !blocks[0].attributes.italic);
}
#[test]
fn link_run() {
let blocks = markdown_to_comment_blocks("see [docs](https://x.io) now");
assert_eq!(blocks[0], plain("see "));
assert_eq!(blocks[1].text, "docs");
assert_eq!(blocks[1].attributes.link.as_deref(), Some("https://x.io"));
assert_eq!(blocks[2], plain(" now"));
}
#[test]
fn task_list_checked_unchecked() {
let blocks = markdown_to_comment_blocks("- [ ] todo\n- [x] done");
assert_eq!(
blocks[1].attributes.list.as_ref().map(|l| l.list.as_str()),
Some("unchecked")
);
assert_eq!(
blocks[3].attributes.list.as_ref().map(|l| l.list.as_str()),
Some("checked")
);
}
#[test]
fn blockquote_is_italic_with_gutter() {
let blocks = markdown_to_comment_blocks("> quoted");
assert_eq!(blocks[0].text, "| ");
assert_eq!(blocks[1].text, "quoted");
assert!(blocks[1].attributes.italic);
}
#[test]
fn horizontal_rule() {
let blocks = markdown_to_comment_blocks("---");
assert_eq!(blocks[0].text, "\u{2500}".repeat(10));
}
#[test]
fn table_cyrillic_aligns() {
let md = "| Проверка | Результат |\n|---|---|\n| meet | OK |";
let blocks = markdown_to_comment_blocks(md);
let lines: Vec<&str> = blocks
.iter()
.filter(|b| b.text != "\n")
.map(|b| b.text.as_str())
.collect();
assert_eq!(lines.len(), 3); assert_eq!(lines[0], "| Проверка | Результат |");
assert!(lines[1].starts_with("|-"));
assert_eq!(lines[2], "| meet | OK |");
assert!(
blocks
.iter()
.any(|b| b.text == "\n" && b.attributes.code_block.is_some())
);
}
#[test]
fn wide_table_preserves_content() {
let wide = "x".repeat(200);
let md = format!("| {wide} | b |\n|---|---|\n| y | z |");
let rendered: String = markdown_to_comment_blocks(&md)
.iter()
.map(|b| b.text.as_str())
.collect();
assert!(rendered.contains(&wide), "wide cell preserved");
assert!(!rendered.contains('\u{2026}'), "no truncation ellipsis");
}
#[test]
fn cyrillic_bold_no_panic() {
let blocks = markdown_to_comment_blocks("жирный **текст** конец");
assert!(
blocks
.iter()
.any(|b| b.attributes.bold && b.text == "текст")
);
}
}