Skip to main content

devboy_clickup/
comment_format.rs

1//! Markdown → ClickUp comment rich-text conversion.
2//!
3//! ClickUp's Comments API does **not** render markdown. The `comment_text`
4//! field is run through a lossy auto-formatter that turns every backtick span
5//! into a fragmented inline-code chip and drops everything else; the
6//! `markdown_content` field (which works for task descriptions) is silently
7//! ignored on comments. See <https://developer.clickup.com/docs/comments>.
8//!
9//! The only way to get clean rendering is the structured `comment` array — a
10//! [Quill Delta](https://quilljs.com/docs/delta/)-style list of `{text,
11//! attributes}` runs. Inline marks (`code`, `bold`) attach to the content run;
12//! block marks (`code-block`, `list`) attach to the trailing `"\n"` separator
13//! that closes the line. See <https://developer.clickup.com/docs/comment-formatting>.
14//!
15//! This module converts the markdown subset our comments use — inline code,
16//! bold, italic, links, fenced code blocks, bullet/ordered/task lists, ATX
17//! headings, blockquotes, horizontal rules, GFM tables, and plain paragraphs —
18//! into that array. ClickUp comments have no table block, so GFM tables render
19//! as an aligned monospace `code-block`. Anything it doesn't recognise is
20//! emitted as plain text, so output is never worse than the old behaviour.
21
22use serde::Serialize;
23
24/// One run in a ClickUp comment's `comment` array.
25///
26/// `attributes` is omitted from the wire payload when empty so the request
27/// mirrors what the ClickUp UI itself sends.
28#[derive(Debug, Clone, PartialEq, Serialize)]
29pub struct CommentBlock {
30    pub text: String,
31    #[serde(skip_serializing_if = "CommentAttributes::is_empty")]
32    pub attributes: CommentAttributes,
33}
34
35/// Formatting marks for a single run. Inline marks (`code`, `bold`) sit on a
36/// content run; block marks (`code_block`, `list`) sit on a `"\n"` separator.
37#[derive(Debug, Clone, Default, PartialEq, Serialize)]
38pub struct CommentAttributes {
39    #[serde(skip_serializing_if = "is_false")]
40    pub bold: bool,
41    #[serde(skip_serializing_if = "is_false")]
42    pub italic: bool,
43    #[serde(skip_serializing_if = "is_false")]
44    pub code: bool,
45    /// Hyperlink target URL for this run's text (inline `[text](url)`).
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub link: Option<String>,
48    /// Block-level code fence. ClickUp's shape is `{"code-block": "plain"}`.
49    #[serde(rename = "code-block", skip_serializing_if = "Option::is_none")]
50    pub code_block: Option<CodeBlockAttr>,
51    /// List membership. ClickUp's shape is
52    /// `{"list": "bullet" | "ordered" | "checked" | "unchecked"}`.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub list: Option<ListAttr>,
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize)]
58pub struct CodeBlockAttr {
59    #[serde(rename = "code-block")]
60    pub code_block: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize)]
64pub struct ListAttr {
65    pub list: String,
66}
67
68impl CommentAttributes {
69    fn is_empty(&self) -> bool {
70        !self.bold
71            && !self.italic
72            && !self.code
73            && self.link.is_none()
74            && self.code_block.is_none()
75            && self.list.is_none()
76    }
77}
78
79#[allow(clippy::trivially_copy_pass_by_ref)] // signature required by serde's skip_serializing_if
80fn is_false(b: &bool) -> bool {
81    !*b
82}
83
84/// A line separator carrying a block-level attribute (or none).
85fn newline(attributes: CommentAttributes) -> CommentBlock {
86    CommentBlock {
87        text: "\n".to_string(),
88        attributes,
89    }
90}
91
92/// Convert a markdown comment body into ClickUp's `comment` rich-text array.
93///
94/// The supported subset:
95/// - fenced code blocks (```` ``` ````) → each line + a `code-block` newline;
96/// - `- ` / `* ` / `+ ` bullets → content + a `bullet` list newline;
97/// - `- [ ]` / `- [x]` task items → content + an `unchecked`/`checked` newline;
98/// - `1. ` ordered items → content + an `ordered` list newline;
99/// - ATX headings (`#`..`######`) → bold content + a leading glyph (no heading mark);
100/// - GFM tables → an aligned monospace `code-block` (comments have no table mark);
101/// - `> ` blockquotes and `---` horizontal rules;
102/// - inline `` `code` ``, `**bold**`, `*italic*`, and `[text](url)` links;
103/// - everything else → plain text.
104pub fn markdown_to_comment_blocks(body: &str) -> Vec<CommentBlock> {
105    // An empty body has no structure to express; returning no blocks lets the
106    // request fall back to plain `comment_text` (the `comment` array is skipped
107    // when empty).
108    if body.is_empty() {
109        return Vec::new();
110    }
111
112    let mut blocks: Vec<CommentBlock> = Vec::new();
113    let mut in_code_fence = false;
114
115    // Indexed walk (not a plain `for`) so table detection can look ahead at the
116    // separator row and consume the contiguous table block.
117    let lines: Vec<&str> = body.split('\n').collect();
118    let mut idx = 0;
119    while idx < lines.len() {
120        let line = lines[idx];
121
122        // Fence toggles. A line whose trimmed start is ``` opens or closes a
123        // fenced block; the fence line itself (and its info string) is dropped.
124        if line.trim_start().starts_with("```") {
125            in_code_fence = !in_code_fence;
126            idx += 1;
127            continue;
128        }
129
130        if in_code_fence {
131            // Inside a fence everything is literal; no inline parsing.
132            if !line.is_empty() {
133                blocks.push(CommentBlock {
134                    text: line.to_string(),
135                    attributes: CommentAttributes::default(),
136                });
137            }
138            blocks.push(newline(CommentAttributes {
139                code_block: Some(CodeBlockAttr {
140                    code_block: "plain".to_string(),
141                }),
142                ..Default::default()
143            }));
144            idx += 1;
145            continue;
146        }
147
148        // GFM table: a `|...|` row immediately followed by a separator row.
149        // Rendered as an aligned monospace code-block (no table mark exists).
150        if is_table_row(line)
151            && idx + 1 < lines.len()
152            && is_separator_row(&split_table_row(lines[idx + 1]))
153        {
154            let mut rows = vec![line.to_string()];
155            let mut j = idx + 1;
156            while j < lines.len() && is_table_row(lines[j]) {
157                rows.push(lines[j].to_string());
158                j += 1;
159            }
160            for rendered in render_table(&rows) {
161                blocks.push(CommentBlock {
162                    text: rendered,
163                    attributes: CommentAttributes::default(),
164                });
165                blocks.push(newline(CommentAttributes {
166                    code_block: Some(CodeBlockAttr {
167                        code_block: "plain".to_string(),
168                    }),
169                    ..Default::default()
170                }));
171            }
172            idx = j;
173            continue;
174        }
175
176        // Task list item: `- [ ]` / `- [x]` (checked/unchecked list).
177        if let Some((checked, rest)) = strip_task_item(line) {
178            push_inline_runs(&mut blocks, rest);
179            blocks.push(newline(CommentAttributes {
180                list: Some(ListAttr {
181                    list: if checked { "checked" } else { "unchecked" }.to_string(),
182                }),
183                ..Default::default()
184            }));
185            idx += 1;
186            continue;
187        }
188
189        // Bullet list item: `-`, `*`, or `+` followed by a space.
190        if let Some(rest) = strip_bullet(line) {
191            push_inline_runs(&mut blocks, rest);
192            blocks.push(newline(CommentAttributes {
193                list: Some(ListAttr {
194                    list: "bullet".to_string(),
195                }),
196                ..Default::default()
197            }));
198            idx += 1;
199            continue;
200        }
201
202        // Ordered list item: `<n>.` or `<n>)` followed by a space.
203        if let Some(rest) = strip_ordered(line) {
204            push_inline_runs(&mut blocks, rest);
205            blocks.push(newline(CommentAttributes {
206                list: Some(ListAttr {
207                    list: "ordered".to_string(),
208                }),
209                ..Default::default()
210            }));
211            idx += 1;
212            continue;
213        }
214
215        // ATX heading: 1–6 leading `#` then a space. Comments have no heading
216        // attribute, so render the text bold with a leading glyph (◆ for h1,
217        // ▸ for h2) to preserve section structure.
218        if let Some((level, rest)) = strip_heading(line) {
219            let prefix = heading_prefix(level);
220            if !prefix.is_empty() {
221                blocks.push(CommentBlock {
222                    text: prefix.to_string(),
223                    attributes: CommentAttributes {
224                        bold: true,
225                        ..Default::default()
226                    },
227                });
228            }
229            push_bold_run(&mut blocks, rest);
230            blocks.push(newline(CommentAttributes::default()));
231            idx += 1;
232            continue;
233        }
234
235        // Horizontal rule: `---`, `***`, or `___` on its own line.
236        if matches!(line.trim(), "---" | "***" | "___") {
237            blocks.push(CommentBlock {
238                text: "\u{2500}".repeat(10),
239                attributes: CommentAttributes::default(),
240            });
241            blocks.push(newline(CommentAttributes::default()));
242            idx += 1;
243            continue;
244        }
245
246        // Blockquote: `> ...` rendered as an italic line with a `| ` gutter
247        // (comments have no quote mark).
248        if let Some(rest) = strip_blockquote(line) {
249            blocks.push(CommentBlock {
250                text: "| ".to_string(),
251                attributes: CommentAttributes::default(),
252            });
253            for mut run in parse_inline(rest) {
254                run.attributes.italic = true;
255                blocks.push(run);
256            }
257            blocks.push(newline(CommentAttributes::default()));
258            idx += 1;
259            continue;
260        }
261
262        // Plain paragraph line (may contain inline code / bold / italic / link).
263        push_inline_runs(&mut blocks, line);
264        blocks.push(newline(CommentAttributes::default()));
265        idx += 1;
266    }
267
268    // `split('\n')` yields a trailing empty segment for every trailing newline
269    // in the body, each producing a redundant plain separator. Trim all
270    // trailing *plain* newlines (never the last remaining block, and never a
271    // separator carrying a block attribute like code-block/list — those are
272    // structurally significant) so we don't emit dangling blank lines.
273    while blocks.len() > 1 {
274        let last = &blocks[blocks.len() - 1];
275        if last.text == "\n" && last.attributes.is_empty() {
276            blocks.pop();
277        } else {
278            break;
279        }
280    }
281
282    blocks
283}
284
285/// Strip a `- ` / `* ` / `+ ` bullet marker (allowing leading indent),
286/// returning the item text. `None` if the line isn't a bullet.
287fn strip_bullet(line: &str) -> Option<&str> {
288    let trimmed = line.trim_start();
289    for marker in ['-', '*', '+'] {
290        if let Some(rest) = trimmed.strip_prefix(marker) {
291            if let Some(rest) = rest.strip_prefix(' ') {
292                return Some(rest);
293            }
294        }
295    }
296    None
297}
298
299/// Strip a `<n>. ` / `<n>) ` ordered marker, returning the item text.
300fn strip_ordered(line: &str) -> Option<&str> {
301    let trimmed = line.trim_start();
302    let digits_end = trimmed.find(|c: char| !c.is_ascii_digit())?;
303    if digits_end == 0 {
304        return None;
305    }
306    let after = &trimmed[digits_end..];
307    for sep in ['.', ')'] {
308        if let Some(rest) = after.strip_prefix(sep) {
309            if let Some(rest) = rest.strip_prefix(' ') {
310                return Some(rest);
311            }
312        }
313    }
314    None
315}
316
317/// Strip 1–6 leading `#` followed by a space, returning `(level, heading text)`.
318fn strip_heading(line: &str) -> Option<(usize, &str)> {
319    let hashes = line.chars().take_while(|&c| c == '#').count();
320    if (1..=6).contains(&hashes) {
321        let rest = &line[hashes..];
322        if let Some(rest) = rest.strip_prefix(' ') {
323            return Some((hashes, rest));
324        }
325    }
326    None
327}
328
329/// Leading glyph for a heading level (comments have no heading mark). Neutral
330/// geometric glyphs keep it locale-neutral; deeper levels get plain bold.
331fn heading_prefix(level: usize) -> &'static str {
332    match level {
333        1 => "\u{25C6} ", // ◆
334        2 => "\u{25B8} ", // ▸
335        _ => "",
336    }
337}
338
339/// Strip a `- [ ] ` / `- [x] ` task-list marker, returning `(checked, text)`.
340fn strip_task_item(line: &str) -> Option<(bool, &str)> {
341    let rest = strip_bullet(line)?;
342    if let Some(rest) = rest.strip_prefix("[ ] ") {
343        Some((false, rest))
344    } else if let Some(rest) = rest
345        .strip_prefix("[x] ")
346        .or_else(|| rest.strip_prefix("[X] "))
347    {
348        Some((true, rest))
349    } else {
350        None
351    }
352}
353
354/// Strip a `> ` (or `>`) blockquote marker, returning the quoted text.
355fn strip_blockquote(line: &str) -> Option<&str> {
356    let trimmed = line.trim_start();
357    let rest = trimmed.strip_prefix('>')?;
358    Some(rest.strip_prefix(' ').unwrap_or(rest))
359}
360
361/// Push `text` as a single bold run (used for headings).
362fn push_bold_run(blocks: &mut Vec<CommentBlock>, text: &str) {
363    if text.is_empty() {
364        return;
365    }
366    blocks.push(CommentBlock {
367        text: text.to_string(),
368        attributes: CommentAttributes {
369            bold: true,
370            ..Default::default()
371        },
372    });
373}
374
375/// Split a single line into runs, honouring inline `` `code` `` and `**bold**`,
376/// and push them onto `blocks`. Inline code takes precedence over bold (so a
377/// backtick span is never re-parsed for `**`).
378fn push_inline_runs(blocks: &mut Vec<CommentBlock>, line: &str) {
379    for run in parse_inline(line) {
380        blocks.push(run);
381    }
382}
383
384/// Parse inline marks in a single line into a sequence of runs.
385fn parse_inline(line: &str) -> Vec<CommentBlock> {
386    let mut runs: Vec<CommentBlock> = Vec::new();
387    let chars: Vec<char> = line.chars().collect();
388    let mut i = 0;
389    let mut plain = String::new();
390
391    let flush_plain = |plain: &mut String, runs: &mut Vec<CommentBlock>| {
392        if !plain.is_empty() {
393            runs.push(CommentBlock {
394                text: std::mem::take(plain),
395                attributes: CommentAttributes::default(),
396            });
397        }
398    };
399
400    while i < chars.len() {
401        let c = chars[i];
402
403        // Inline code: `...` (single backtick, no nesting).
404        if c == '`' {
405            if let Some(close) = find_char(&chars, i + 1, '`') {
406                flush_plain(&mut plain, &mut runs);
407                let text: String = chars[i + 1..close].iter().collect();
408                runs.push(CommentBlock {
409                    text,
410                    attributes: CommentAttributes {
411                        code: true,
412                        ..Default::default()
413                    },
414                });
415                i = close + 1;
416                continue;
417            }
418        }
419
420        // Link: [text](url). The link text may contain inline marks, so parse
421        // it recursively and attach the URL to every resulting run.
422        if c == '[' {
423            if let Some((text_end, url_start, url_end)) = find_link(&chars, i) {
424                flush_plain(&mut plain, &mut runs);
425                let text: String = chars[i + 1..text_end].iter().collect();
426                let url: String = chars[url_start..url_end].iter().collect();
427                for mut run in parse_inline(&text) {
428                    run.attributes.link = Some(url.clone());
429                    runs.push(run);
430                }
431                i = url_end + 1;
432                continue;
433            }
434        }
435
436        // Bold: **...**. The inner content may itself contain inline code, so
437        // parse it recursively and mark every resulting run bold (a run can
438        // carry both `bold` and `code` where they overlap, e.g. **`x`**).
439        if c == '*' && i + 1 < chars.len() && chars[i + 1] == '*' {
440            if let Some(close) = find_double_star(&chars, i + 2) {
441                flush_plain(&mut plain, &mut runs);
442                let inner: String = chars[i + 2..close].iter().collect();
443                for mut run in parse_inline(&inner) {
444                    run.attributes.bold = true;
445                    runs.push(run);
446                }
447                i = close + 2;
448                continue;
449            }
450        }
451
452        // Italic: *...* or _..._ (single delimiter, not part of a `**` pair —
453        // bold is handled above so any remaining lone `*` is italic).
454        if c == '*' || c == '_' {
455            if let Some(close) = find_char(&chars, i + 1, c) {
456                if close > i + 1 {
457                    flush_plain(&mut plain, &mut runs);
458                    let inner: String = chars[i + 1..close].iter().collect();
459                    for mut run in parse_inline(&inner) {
460                        run.attributes.italic = true;
461                        runs.push(run);
462                    }
463                    i = close + 1;
464                    continue;
465                }
466            }
467        }
468
469        plain.push(c);
470        i += 1;
471    }
472
473    flush_plain(&mut plain, &mut runs);
474    runs
475}
476
477/// Find the next index of `needle` in `chars` at or after `from`.
478fn find_char(chars: &[char], from: usize, needle: char) -> Option<usize> {
479    (from..chars.len()).find(|&j| chars[j] == needle)
480}
481
482/// Find the next `**` (start index) in `chars` at or after `from`.
483fn find_double_star(chars: &[char], from: usize) -> Option<usize> {
484    let mut j = from;
485    while j + 1 < chars.len() {
486        if chars[j] == '*' && chars[j + 1] == '*' {
487            return Some(j);
488        }
489        j += 1;
490    }
491    None
492}
493
494/// Parse a `[text](url)` link starting at `open` (the `[`). Returns
495/// `(text_end, url_start, url_end)` as char indices, where `text_end` is the
496/// `]`, `url_start` is the first URL char, and `url_end` is the `)`.
497fn find_link(chars: &[char], open: usize) -> Option<(usize, usize, usize)> {
498    let close_br = find_char(chars, open + 1, ']')?;
499    if chars.get(close_br + 1) != Some(&'(') {
500        return None;
501    }
502    let url_start = close_br + 2;
503    let close_paren = find_char(chars, url_start, ')')?;
504    Some((close_br, url_start, close_paren))
505}
506
507// =============================================================================
508// GFM tables → aligned monospace code-block (ClickUp comments have no table
509// mark). Columns size to content; cell data is never truncated.
510// =============================================================================
511
512/// True when a line looks like a GFM table row (`| ... |`).
513fn is_table_row(line: &str) -> bool {
514    let l = line.trim();
515    l.starts_with('|') && l.matches('|').count() >= 2
516}
517
518/// Split a `| a | b |` row into trimmed cells.
519fn split_table_row(line: &str) -> Vec<String> {
520    let l = line.trim();
521    let l = l.strip_prefix('|').unwrap_or(l);
522    let l = l.strip_suffix('|').unwrap_or(l);
523    l.split('|').map(|c| c.trim().to_string()).collect()
524}
525
526/// True when every cell is a separator (`---`, `:--`, `--:`, `:-:`).
527fn is_separator_row(cells: &[String]) -> bool {
528    !cells.is_empty()
529        && cells.iter().all(|c| {
530            let t = c.trim();
531            !t.is_empty() && t.contains('-') && t.chars().all(|ch| ch == '-' || ch == ':')
532        })
533}
534
535#[derive(Clone, Copy)]
536enum Align {
537    Left,
538    Right,
539    Center,
540}
541
542fn parse_align(cell: &str) -> Align {
543    let t = cell.trim();
544    match (t.starts_with(':'), t.ends_with(':')) {
545        (true, true) => Align::Center,
546        (false, true) => Align::Right,
547        _ => Align::Left,
548    }
549}
550
551/// Display width: most chars = 1, CJK/fullwidth/emoji = 2. Cyrillic counts as
552/// 1, so Russian table columns align correctly. No `unicode-width` dep.
553fn display_width(s: &str) -> usize {
554    s.chars().map(char_width).sum()
555}
556
557fn char_width(ch: char) -> usize {
558    let c = ch as u32;
559    let double = (0x1100..=0x115F).contains(&c)
560        || (0x2E80..=0xA4CF).contains(&c)
561        || (0xAC00..=0xD7A3).contains(&c)
562        || (0xF900..=0xFAFF).contains(&c)
563        || (0xFF00..=0xFF60).contains(&c)
564        || (0xFFE0..=0xFFE6).contains(&c)
565        || (0x1F300..=0x1FAFF).contains(&c)
566        || (0x20000..=0x3FFFD).contains(&c);
567    if double { 2 } else { 1 }
568}
569
570/// Pad a cell to `width` display columns per alignment. Never truncates: the
571/// width is always the column's content width, so cell data is preserved
572/// verbatim (read-back stays a valid GFM table for downstream LLMs).
573fn pad_cell(cell: &str, width: usize, align: Align) -> String {
574    let w = display_width(cell);
575    let pad = width.saturating_sub(w);
576    match align {
577        Align::Left => format!("{}{}", cell, " ".repeat(pad)),
578        Align::Right => format!("{}{}", " ".repeat(pad), cell),
579        Align::Center => {
580            let left = pad / 2;
581            format!("{}{}{}", " ".repeat(left), cell, " ".repeat(pad - left))
582        }
583    }
584}
585
586/// Render GFM table rows to aligned monospace lines (one string per line).
587fn render_table(rows: &[String]) -> Vec<String> {
588    let parsed: Vec<Vec<String>> = rows.iter().map(|r| split_table_row(r)).collect();
589    let ncols = parsed.iter().map(|c| c.len()).max().unwrap_or(0);
590    if ncols == 0 {
591        return Vec::new();
592    }
593
594    let mut aligns = vec![Align::Left; ncols];
595    let mut data: Vec<Vec<String>> = Vec::new();
596    for cells in &parsed {
597        if is_separator_row(cells) {
598            for (i, c) in cells.iter().enumerate().take(ncols) {
599                aligns[i] = parse_align(c);
600            }
601        } else {
602            let mut row = cells.clone();
603            row.resize(ncols, String::new());
604            data.push(row);
605        }
606    }
607    if data.is_empty() {
608        return Vec::new();
609    }
610
611    // Column widths sized to content (never below, so no truncation).
612    let mut width = vec![0usize; ncols];
613    for row in &data {
614        for (i, cell) in row.iter().enumerate() {
615            width[i] = width[i].max(display_width(cell));
616        }
617    }
618
619    let mut out: Vec<String> = Vec::new();
620    for (ri, row) in data.iter().enumerate() {
621        let cells: Vec<String> = row
622            .iter()
623            .enumerate()
624            .map(|(i, cell)| pad_cell(cell, width[i], aligns[i]))
625            .collect();
626        out.push(format!("| {} |", cells.join(" | ")));
627        if ri == 0 {
628            let dividers: Vec<String> = width.iter().map(|w| "-".repeat(*w)).collect();
629            out.push(format!("|-{}-|", dividers.join("-|-")));
630        }
631    }
632    out
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    fn plain(text: &str) -> CommentBlock {
640        CommentBlock {
641            text: text.to_string(),
642            attributes: CommentAttributes::default(),
643        }
644    }
645
646    #[test]
647    fn plain_paragraph() {
648        let blocks = markdown_to_comment_blocks("hello world");
649        assert_eq!(blocks, vec![plain("hello world")]);
650    }
651
652    #[test]
653    fn inline_code_splits_runs() {
654        let blocks = markdown_to_comment_blocks("the `SecretBackend` trait");
655        assert_eq!(
656            blocks,
657            vec![
658                plain("the "),
659                CommentBlock {
660                    text: "SecretBackend".to_string(),
661                    attributes: CommentAttributes {
662                        code: true,
663                        ..Default::default()
664                    },
665                },
666                plain(" trait"),
667            ]
668        );
669    }
670
671    #[test]
672    fn inline_code_does_not_fragment_surrounding_prose() {
673        // Regression for the issue: many backtick tokens must stay as
674        // contiguous prose runs, not each become an isolated chip with the
675        // text shattered around them.
676        let blocks = markdown_to_comment_blocks("a `x` b `y` c");
677        let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect();
678        assert_eq!(texts, vec!["a ", "x", " b ", "y", " c"]);
679        assert!(blocks[1].attributes.code);
680        assert!(blocks[3].attributes.code);
681    }
682
683    #[test]
684    fn bold_run() {
685        let blocks = markdown_to_comment_blocks("a **bold** b");
686        assert_eq!(blocks[1].text, "bold");
687        assert!(blocks[1].attributes.bold);
688    }
689
690    #[test]
691    fn bold_with_nested_inline_code() {
692        // **`SecretBackend`** must yield a single run that is BOTH bold and
693        // code, with the backticks consumed — not a bold run with literal
694        // backticks in the text.
695        let blocks = markdown_to_comment_blocks("**`SecretBackend`**");
696        assert_eq!(blocks.len(), 1);
697        assert_eq!(blocks[0].text, "SecretBackend");
698        assert!(blocks[0].attributes.bold);
699        assert!(blocks[0].attributes.code);
700    }
701
702    #[test]
703    fn bold_with_mixed_inner_content() {
704        // **`backend.rs`** (new) — leading bold+code run, then plain tail.
705        let blocks = markdown_to_comment_blocks("**`backend.rs`** (new)");
706        assert_eq!(blocks[0].text, "backend.rs");
707        assert!(blocks[0].attributes.bold && blocks[0].attributes.code);
708        assert_eq!(blocks[1].text, " (new)");
709        assert!(blocks[1].attributes.is_empty());
710    }
711
712    #[test]
713    fn fenced_code_block() {
714        let body = "```rust\nlet x = 1;\nlet y = 2;\n```";
715        let blocks = markdown_to_comment_blocks(body);
716        // Two content lines, each followed by a code-block newline. The fence
717        // markers themselves are dropped.
718        assert_eq!(blocks.len(), 4);
719        assert_eq!(blocks[0].text, "let x = 1;");
720        assert!(blocks[1].attributes.code_block.is_some());
721        assert_eq!(blocks[1].text, "\n");
722        assert_eq!(blocks[2].text, "let y = 2;");
723        assert!(blocks[3].attributes.code_block.is_some());
724    }
725
726    #[test]
727    fn fenced_code_block_does_not_parse_inline() {
728        // Backticks/stars inside a fence are literal.
729        let body = "```\na `b` **c**\n```";
730        let blocks = markdown_to_comment_blocks(body);
731        assert_eq!(blocks[0].text, "a `b` **c**");
732        assert!(blocks[0].attributes.is_empty());
733    }
734
735    #[test]
736    fn bullet_list() {
737        let body = "- one\n- two";
738        let blocks = markdown_to_comment_blocks(body);
739        assert_eq!(blocks[0].text, "one");
740        assert_eq!(
741            blocks[1].attributes.list.as_ref().map(|l| l.list.as_str()),
742            Some("bullet")
743        );
744        assert_eq!(blocks[2].text, "two");
745        assert_eq!(
746            blocks[3].attributes.list.as_ref().map(|l| l.list.as_str()),
747            Some("bullet")
748        );
749    }
750
751    #[test]
752    fn bullet_list_item_keeps_inline_code() {
753        let blocks = markdown_to_comment_blocks("- first with `code`");
754        assert_eq!(blocks[0].text, "first with ");
755        assert!(blocks[1].attributes.code);
756        assert_eq!(blocks[1].text, "code");
757        assert!(blocks[2].attributes.list.is_some());
758    }
759
760    #[test]
761    fn ordered_list() {
762        let body = "1. one\n2. two";
763        let blocks = markdown_to_comment_blocks(body);
764        assert_eq!(blocks[0].text, "one");
765        assert_eq!(
766            blocks[1].attributes.list.as_ref().map(|l| l.list.as_str()),
767            Some("ordered")
768        );
769    }
770
771    #[test]
772    fn heading_becomes_bold() {
773        // h2 → leading ▸ glyph (bold) + bold heading text.
774        let blocks = markdown_to_comment_blocks("## Done");
775        assert_eq!(blocks[0].text, "\u{25B8} ");
776        assert!(blocks[0].attributes.bold);
777        assert_eq!(blocks[1].text, "Done");
778        assert!(blocks[1].attributes.bold);
779    }
780
781    #[test]
782    fn h3_heading_has_no_glyph() {
783        let blocks = markdown_to_comment_blocks("### Sub");
784        assert_eq!(blocks[0].text, "Sub");
785        assert!(blocks[0].attributes.bold);
786    }
787
788    #[test]
789    fn unterminated_inline_code_is_literal() {
790        let blocks = markdown_to_comment_blocks("a `b c");
791        assert_eq!(blocks, vec![plain("a `b c")]);
792    }
793
794    #[test]
795    fn serializes_attributes_with_clickup_shape() {
796        let body = "- item\n```\ncode\n```\n`x`";
797        let blocks = markdown_to_comment_blocks(body);
798        let json = serde_json::to_string(&blocks).unwrap();
799        // List newline shape.
800        assert!(json.contains(r#""list":{"list":"bullet"}"#));
801        // Code-block newline shape.
802        assert!(json.contains(r#""code-block":{"code-block":"plain"}"#));
803        // Inline code mark.
804        assert!(json.contains(r#""code":true"#));
805        // Plain runs omit the attributes object entirely.
806        assert!(json.contains(r#"{"text":"item"}"#));
807    }
808
809    #[test]
810    fn trailing_blank_lines_are_trimmed() {
811        // A body ending in one or more blank lines must not leave dangling
812        // plain newline separators (regression for PR #294 review feedback).
813        // All trailing plain newlines are trimmed, leaving just the content.
814        for body in ["a", "a\n", "a\n\n", "a\n\n\n"] {
815            let blocks = markdown_to_comment_blocks(body);
816            assert_eq!(
817                blocks,
818                vec![plain("a")],
819                "body {body:?} should trim every trailing plain newline"
820            );
821        }
822    }
823
824    #[test]
825    fn trailing_block_separator_is_preserved() {
826        // A trailing newline that carries a block attribute (list/code-block)
827        // is structurally significant and must NOT be trimmed.
828        let blocks = markdown_to_comment_blocks("- item\n");
829        let last = blocks.last().unwrap();
830        assert!(last.attributes.list.is_some());
831    }
832
833    #[test]
834    fn empty_body_yields_no_blocks() {
835        assert!(markdown_to_comment_blocks("").is_empty());
836    }
837
838    // --- additions: italic, links, task lists, blockquote, hr, tables ---
839
840    #[test]
841    fn italic_run() {
842        let blocks = markdown_to_comment_blocks("a *b* c");
843        assert_eq!(blocks[0], plain("a "));
844        assert_eq!(blocks[1].text, "b");
845        assert!(blocks[1].attributes.italic && !blocks[1].attributes.bold);
846        assert_eq!(blocks[2], plain(" c"));
847    }
848
849    #[test]
850    fn italic_underscore() {
851        let blocks = markdown_to_comment_blocks("_x_");
852        assert_eq!(blocks[0].text, "x");
853        assert!(blocks[0].attributes.italic);
854    }
855
856    #[test]
857    fn bold_not_swallowed_by_italic() {
858        // `**b**` must stay bold, not be parsed as two italic `*` pairs.
859        let blocks = markdown_to_comment_blocks("**b**");
860        assert_eq!(blocks[0].text, "b");
861        assert!(blocks[0].attributes.bold && !blocks[0].attributes.italic);
862    }
863
864    #[test]
865    fn link_run() {
866        let blocks = markdown_to_comment_blocks("see [docs](https://x.io) now");
867        assert_eq!(blocks[0], plain("see "));
868        assert_eq!(blocks[1].text, "docs");
869        assert_eq!(blocks[1].attributes.link.as_deref(), Some("https://x.io"));
870        assert_eq!(blocks[2], plain(" now"));
871    }
872
873    #[test]
874    fn task_list_checked_unchecked() {
875        let blocks = markdown_to_comment_blocks("- [ ] todo\n- [x] done");
876        assert_eq!(
877            blocks[1].attributes.list.as_ref().map(|l| l.list.as_str()),
878            Some("unchecked")
879        );
880        assert_eq!(
881            blocks[3].attributes.list.as_ref().map(|l| l.list.as_str()),
882            Some("checked")
883        );
884    }
885
886    #[test]
887    fn blockquote_is_italic_with_gutter() {
888        let blocks = markdown_to_comment_blocks("> quoted");
889        assert_eq!(blocks[0].text, "| ");
890        assert_eq!(blocks[1].text, "quoted");
891        assert!(blocks[1].attributes.italic);
892    }
893
894    #[test]
895    fn horizontal_rule() {
896        let blocks = markdown_to_comment_blocks("---");
897        assert_eq!(blocks[0].text, "\u{2500}".repeat(10));
898    }
899
900    #[test]
901    fn table_cyrillic_aligns() {
902        let md = "| Проверка | Результат |\n|---|---|\n| meet | OK |";
903        let blocks = markdown_to_comment_blocks(md);
904        let lines: Vec<&str> = blocks
905            .iter()
906            .filter(|b| b.text != "\n")
907            .map(|b| b.text.as_str())
908            .collect();
909        assert_eq!(lines.len(), 3); // header, divider, one data row
910        assert_eq!(lines[0], "| Проверка | Результат |");
911        assert!(lines[1].starts_with("|-"));
912        assert_eq!(lines[2], "| meet     | OK        |");
913        // table lines carry the code-block block attribute (on the newline runs)
914        assert!(
915            blocks
916                .iter()
917                .any(|b| b.text == "\n" && b.attributes.code_block.is_some())
918        );
919    }
920
921    #[test]
922    fn wide_table_preserves_content() {
923        let wide = "x".repeat(200);
924        let md = format!("| {wide} | b |\n|---|---|\n| y | z |");
925        let rendered: String = markdown_to_comment_blocks(&md)
926            .iter()
927            .map(|b| b.text.as_str())
928            .collect();
929        assert!(rendered.contains(&wide), "wide cell preserved");
930        assert!(!rendered.contains('\u{2026}'), "no truncation ellipsis");
931    }
932
933    #[test]
934    fn cyrillic_bold_no_panic() {
935        // char-based parser must not panic on multi-byte UTF-8 next to a mark.
936        let blocks = markdown_to_comment_blocks("жирный **текст** конец");
937        assert!(
938            blocks
939                .iter()
940                .any(|b| b.attributes.bold && b.text == "текст")
941        );
942    }
943}