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 small markdown subset our comments use — inline
16//! code, bold, fenced code blocks, bullet/ordered lists, ATX headings, and
17//! plain paragraphs — into that array. Anything it doesn't recognise is
18//! emitted as plain text, so output is never worse than the old behaviour.
19
20use serde::Serialize;
21
22/// One run in a ClickUp comment's `comment` array.
23///
24/// `attributes` is omitted from the wire payload when empty so the request
25/// mirrors what the ClickUp UI itself sends.
26#[derive(Debug, Clone, PartialEq, Serialize)]
27pub struct CommentBlock {
28    pub text: String,
29    #[serde(skip_serializing_if = "CommentAttributes::is_empty")]
30    pub attributes: CommentAttributes,
31}
32
33/// Formatting marks for a single run. Inline marks (`code`, `bold`) sit on a
34/// content run; block marks (`code_block`, `list`) sit on a `"\n"` separator.
35#[derive(Debug, Clone, Default, PartialEq, Serialize)]
36pub struct CommentAttributes {
37    #[serde(skip_serializing_if = "is_false")]
38    pub bold: bool,
39    #[serde(skip_serializing_if = "is_false")]
40    pub code: bool,
41    /// Block-level code fence. ClickUp's shape is `{"code-block": "plain"}`.
42    #[serde(rename = "code-block", skip_serializing_if = "Option::is_none")]
43    pub code_block: Option<CodeBlockAttr>,
44    /// List membership. ClickUp's shape is `{"list": "bullet" | "ordered"}`.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub list: Option<ListAttr>,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize)]
50pub struct CodeBlockAttr {
51    #[serde(rename = "code-block")]
52    pub code_block: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize)]
56pub struct ListAttr {
57    pub list: String,
58}
59
60impl CommentAttributes {
61    fn is_empty(&self) -> bool {
62        !self.bold && !self.code && self.code_block.is_none() && self.list.is_none()
63    }
64}
65
66#[allow(clippy::trivially_copy_pass_by_ref)] // signature required by serde's skip_serializing_if
67fn is_false(b: &bool) -> bool {
68    !*b
69}
70
71/// A line separator carrying a block-level attribute (or none).
72fn newline(attributes: CommentAttributes) -> CommentBlock {
73    CommentBlock {
74        text: "\n".to_string(),
75        attributes,
76    }
77}
78
79/// Convert a markdown comment body into ClickUp's `comment` rich-text array.
80///
81/// The supported subset:
82/// - fenced code blocks (```` ``` ````) → each line + a `code-block` newline;
83/// - `- ` / `* ` / `+ ` bullets → content + a `bullet` list newline;
84/// - `1. ` ordered items → content + an `ordered` list newline;
85/// - ATX headings (`#`..`######`) → bold content (comments have no heading mark);
86/// - inline `` `code` `` and `**bold**` within any non-code line;
87/// - everything else → plain text.
88pub fn markdown_to_comment_blocks(body: &str) -> Vec<CommentBlock> {
89    // An empty body has no structure to express; returning no blocks lets the
90    // request fall back to plain `comment_text` (the `comment` array is skipped
91    // when empty).
92    if body.is_empty() {
93        return Vec::new();
94    }
95
96    let mut blocks: Vec<CommentBlock> = Vec::new();
97    let mut in_code_fence = false;
98
99    for line in body.split('\n') {
100        // Fence toggles. A line whose trimmed start is ``` opens or closes a
101        // fenced block; the fence line itself (and its info string) is dropped.
102        if line.trim_start().starts_with("```") {
103            in_code_fence = !in_code_fence;
104            continue;
105        }
106
107        if in_code_fence {
108            // Inside a fence everything is literal; no inline parsing.
109            if !line.is_empty() {
110                blocks.push(CommentBlock {
111                    text: line.to_string(),
112                    attributes: CommentAttributes::default(),
113                });
114            }
115            blocks.push(newline(CommentAttributes {
116                code_block: Some(CodeBlockAttr {
117                    code_block: "plain".to_string(),
118                }),
119                ..Default::default()
120            }));
121            continue;
122        }
123
124        // Bullet list item: `-`, `*`, or `+` followed by a space.
125        if let Some(rest) = strip_bullet(line) {
126            push_inline_runs(&mut blocks, rest);
127            blocks.push(newline(CommentAttributes {
128                list: Some(ListAttr {
129                    list: "bullet".to_string(),
130                }),
131                ..Default::default()
132            }));
133            continue;
134        }
135
136        // Ordered list item: `<n>.` or `<n>)` followed by a space.
137        if let Some(rest) = strip_ordered(line) {
138            push_inline_runs(&mut blocks, rest);
139            blocks.push(newline(CommentAttributes {
140                list: Some(ListAttr {
141                    list: "ordered".to_string(),
142                }),
143                ..Default::default()
144            }));
145            continue;
146        }
147
148        // ATX heading: 1–6 leading `#` then a space. Comments have no heading
149        // attribute, so render the text bold to preserve emphasis.
150        if let Some(rest) = strip_heading(line) {
151            push_bold_run(&mut blocks, rest);
152            blocks.push(newline(CommentAttributes::default()));
153            continue;
154        }
155
156        // Plain paragraph line (may contain inline code / bold).
157        push_inline_runs(&mut blocks, line);
158        blocks.push(newline(CommentAttributes::default()));
159    }
160
161    // `split('\n')` yields a trailing empty segment for every trailing newline
162    // in the body, each producing a redundant plain separator. Trim all
163    // trailing *plain* newlines (never the last remaining block, and never a
164    // separator carrying a block attribute like code-block/list — those are
165    // structurally significant) so we don't emit dangling blank lines.
166    while blocks.len() > 1 {
167        let last = &blocks[blocks.len() - 1];
168        if last.text == "\n" && last.attributes.is_empty() {
169            blocks.pop();
170        } else {
171            break;
172        }
173    }
174
175    blocks
176}
177
178/// Strip a `- ` / `* ` / `+ ` bullet marker (allowing leading indent),
179/// returning the item text. `None` if the line isn't a bullet.
180fn strip_bullet(line: &str) -> Option<&str> {
181    let trimmed = line.trim_start();
182    for marker in ['-', '*', '+'] {
183        if let Some(rest) = trimmed.strip_prefix(marker) {
184            if let Some(rest) = rest.strip_prefix(' ') {
185                return Some(rest);
186            }
187        }
188    }
189    None
190}
191
192/// Strip a `<n>. ` / `<n>) ` ordered marker, returning the item text.
193fn strip_ordered(line: &str) -> Option<&str> {
194    let trimmed = line.trim_start();
195    let digits_end = trimmed.find(|c: char| !c.is_ascii_digit())?;
196    if digits_end == 0 {
197        return None;
198    }
199    let after = &trimmed[digits_end..];
200    for sep in ['.', ')'] {
201        if let Some(rest) = after.strip_prefix(sep) {
202            if let Some(rest) = rest.strip_prefix(' ') {
203                return Some(rest);
204            }
205        }
206    }
207    None
208}
209
210/// Strip 1–6 leading `#` followed by a space, returning the heading text.
211fn strip_heading(line: &str) -> Option<&str> {
212    let hashes = line.chars().take_while(|&c| c == '#').count();
213    if (1..=6).contains(&hashes) {
214        let rest = &line[hashes..];
215        if let Some(rest) = rest.strip_prefix(' ') {
216            return Some(rest);
217        }
218    }
219    None
220}
221
222/// Push `text` as a single bold run (used for headings).
223fn push_bold_run(blocks: &mut Vec<CommentBlock>, text: &str) {
224    if text.is_empty() {
225        return;
226    }
227    blocks.push(CommentBlock {
228        text: text.to_string(),
229        attributes: CommentAttributes {
230            bold: true,
231            ..Default::default()
232        },
233    });
234}
235
236/// Split a single line into runs, honouring inline `` `code` `` and `**bold**`,
237/// and push them onto `blocks`. Inline code takes precedence over bold (so a
238/// backtick span is never re-parsed for `**`).
239fn push_inline_runs(blocks: &mut Vec<CommentBlock>, line: &str) {
240    for run in parse_inline(line) {
241        blocks.push(run);
242    }
243}
244
245/// Parse inline marks in a single line into a sequence of runs.
246fn parse_inline(line: &str) -> Vec<CommentBlock> {
247    let mut runs: Vec<CommentBlock> = Vec::new();
248    let chars: Vec<char> = line.chars().collect();
249    let mut i = 0;
250    let mut plain = String::new();
251
252    let flush_plain = |plain: &mut String, runs: &mut Vec<CommentBlock>| {
253        if !plain.is_empty() {
254            runs.push(CommentBlock {
255                text: std::mem::take(plain),
256                attributes: CommentAttributes::default(),
257            });
258        }
259    };
260
261    while i < chars.len() {
262        let c = chars[i];
263
264        // Inline code: `...` (single backtick, no nesting).
265        if c == '`' {
266            if let Some(close) = find_char(&chars, i + 1, '`') {
267                flush_plain(&mut plain, &mut runs);
268                let text: String = chars[i + 1..close].iter().collect();
269                runs.push(CommentBlock {
270                    text,
271                    attributes: CommentAttributes {
272                        code: true,
273                        ..Default::default()
274                    },
275                });
276                i = close + 1;
277                continue;
278            }
279        }
280
281        // Bold: **...**. The inner content may itself contain inline code, so
282        // parse it recursively and mark every resulting run bold (a run can
283        // carry both `bold` and `code` where they overlap, e.g. **`x`**).
284        if c == '*' && i + 1 < chars.len() && chars[i + 1] == '*' {
285            if let Some(close) = find_double_star(&chars, i + 2) {
286                flush_plain(&mut plain, &mut runs);
287                let inner: String = chars[i + 2..close].iter().collect();
288                for mut run in parse_inline(&inner) {
289                    run.attributes.bold = true;
290                    runs.push(run);
291                }
292                i = close + 2;
293                continue;
294            }
295        }
296
297        plain.push(c);
298        i += 1;
299    }
300
301    flush_plain(&mut plain, &mut runs);
302    runs
303}
304
305/// Find the next index of `needle` in `chars` at or after `from`.
306fn find_char(chars: &[char], from: usize, needle: char) -> Option<usize> {
307    (from..chars.len()).find(|&j| chars[j] == needle)
308}
309
310/// Find the next `**` (start index) in `chars` at or after `from`.
311fn find_double_star(chars: &[char], from: usize) -> Option<usize> {
312    let mut j = from;
313    while j + 1 < chars.len() {
314        if chars[j] == '*' && chars[j + 1] == '*' {
315            return Some(j);
316        }
317        j += 1;
318    }
319    None
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    fn plain(text: &str) -> CommentBlock {
327        CommentBlock {
328            text: text.to_string(),
329            attributes: CommentAttributes::default(),
330        }
331    }
332
333    #[test]
334    fn plain_paragraph() {
335        let blocks = markdown_to_comment_blocks("hello world");
336        assert_eq!(blocks, vec![plain("hello world")]);
337    }
338
339    #[test]
340    fn inline_code_splits_runs() {
341        let blocks = markdown_to_comment_blocks("the `SecretBackend` trait");
342        assert_eq!(
343            blocks,
344            vec![
345                plain("the "),
346                CommentBlock {
347                    text: "SecretBackend".to_string(),
348                    attributes: CommentAttributes {
349                        code: true,
350                        ..Default::default()
351                    },
352                },
353                plain(" trait"),
354            ]
355        );
356    }
357
358    #[test]
359    fn inline_code_does_not_fragment_surrounding_prose() {
360        // Regression for the issue: many backtick tokens must stay as
361        // contiguous prose runs, not each become an isolated chip with the
362        // text shattered around them.
363        let blocks = markdown_to_comment_blocks("a `x` b `y` c");
364        let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect();
365        assert_eq!(texts, vec!["a ", "x", " b ", "y", " c"]);
366        assert!(blocks[1].attributes.code);
367        assert!(blocks[3].attributes.code);
368    }
369
370    #[test]
371    fn bold_run() {
372        let blocks = markdown_to_comment_blocks("a **bold** b");
373        assert_eq!(blocks[1].text, "bold");
374        assert!(blocks[1].attributes.bold);
375    }
376
377    #[test]
378    fn bold_with_nested_inline_code() {
379        // **`SecretBackend`** must yield a single run that is BOTH bold and
380        // code, with the backticks consumed — not a bold run with literal
381        // backticks in the text.
382        let blocks = markdown_to_comment_blocks("**`SecretBackend`**");
383        assert_eq!(blocks.len(), 1);
384        assert_eq!(blocks[0].text, "SecretBackend");
385        assert!(blocks[0].attributes.bold);
386        assert!(blocks[0].attributes.code);
387    }
388
389    #[test]
390    fn bold_with_mixed_inner_content() {
391        // **`backend.rs`** (new) — leading bold+code run, then plain tail.
392        let blocks = markdown_to_comment_blocks("**`backend.rs`** (new)");
393        assert_eq!(blocks[0].text, "backend.rs");
394        assert!(blocks[0].attributes.bold && blocks[0].attributes.code);
395        assert_eq!(blocks[1].text, " (new)");
396        assert!(blocks[1].attributes.is_empty());
397    }
398
399    #[test]
400    fn fenced_code_block() {
401        let body = "```rust\nlet x = 1;\nlet y = 2;\n```";
402        let blocks = markdown_to_comment_blocks(body);
403        // Two content lines, each followed by a code-block newline. The fence
404        // markers themselves are dropped.
405        assert_eq!(blocks.len(), 4);
406        assert_eq!(blocks[0].text, "let x = 1;");
407        assert!(blocks[1].attributes.code_block.is_some());
408        assert_eq!(blocks[1].text, "\n");
409        assert_eq!(blocks[2].text, "let y = 2;");
410        assert!(blocks[3].attributes.code_block.is_some());
411    }
412
413    #[test]
414    fn fenced_code_block_does_not_parse_inline() {
415        // Backticks/stars inside a fence are literal.
416        let body = "```\na `b` **c**\n```";
417        let blocks = markdown_to_comment_blocks(body);
418        assert_eq!(blocks[0].text, "a `b` **c**");
419        assert!(blocks[0].attributes.is_empty());
420    }
421
422    #[test]
423    fn bullet_list() {
424        let body = "- one\n- two";
425        let blocks = markdown_to_comment_blocks(body);
426        assert_eq!(blocks[0].text, "one");
427        assert_eq!(
428            blocks[1].attributes.list.as_ref().map(|l| l.list.as_str()),
429            Some("bullet")
430        );
431        assert_eq!(blocks[2].text, "two");
432        assert_eq!(
433            blocks[3].attributes.list.as_ref().map(|l| l.list.as_str()),
434            Some("bullet")
435        );
436    }
437
438    #[test]
439    fn bullet_list_item_keeps_inline_code() {
440        let blocks = markdown_to_comment_blocks("- first with `code`");
441        assert_eq!(blocks[0].text, "first with ");
442        assert!(blocks[1].attributes.code);
443        assert_eq!(blocks[1].text, "code");
444        assert!(blocks[2].attributes.list.is_some());
445    }
446
447    #[test]
448    fn ordered_list() {
449        let body = "1. one\n2. two";
450        let blocks = markdown_to_comment_blocks(body);
451        assert_eq!(blocks[0].text, "one");
452        assert_eq!(
453            blocks[1].attributes.list.as_ref().map(|l| l.list.as_str()),
454            Some("ordered")
455        );
456    }
457
458    #[test]
459    fn heading_becomes_bold() {
460        let blocks = markdown_to_comment_blocks("## Done");
461        assert_eq!(blocks[0].text, "Done");
462        assert!(blocks[0].attributes.bold);
463    }
464
465    #[test]
466    fn unterminated_inline_code_is_literal() {
467        let blocks = markdown_to_comment_blocks("a `b c");
468        assert_eq!(blocks, vec![plain("a `b c")]);
469    }
470
471    #[test]
472    fn serializes_attributes_with_clickup_shape() {
473        let body = "- item\n```\ncode\n```\n`x`";
474        let blocks = markdown_to_comment_blocks(body);
475        let json = serde_json::to_string(&blocks).unwrap();
476        // List newline shape.
477        assert!(json.contains(r#""list":{"list":"bullet"}"#));
478        // Code-block newline shape.
479        assert!(json.contains(r#""code-block":{"code-block":"plain"}"#));
480        // Inline code mark.
481        assert!(json.contains(r#""code":true"#));
482        // Plain runs omit the attributes object entirely.
483        assert!(json.contains(r#"{"text":"item"}"#));
484    }
485
486    #[test]
487    fn trailing_blank_lines_are_trimmed() {
488        // A body ending in one or more blank lines must not leave dangling
489        // plain newline separators (regression for PR #294 review feedback).
490        // All trailing plain newlines are trimmed, leaving just the content.
491        for body in ["a", "a\n", "a\n\n", "a\n\n\n"] {
492            let blocks = markdown_to_comment_blocks(body);
493            assert_eq!(
494                blocks,
495                vec![plain("a")],
496                "body {body:?} should trim every trailing plain newline"
497            );
498        }
499    }
500
501    #[test]
502    fn trailing_block_separator_is_preserved() {
503        // A trailing newline that carries a block attribute (list/code-block)
504        // is structurally significant and must NOT be trimmed.
505        let blocks = markdown_to_comment_blocks("- item\n");
506        let last = blocks.last().unwrap();
507        assert!(last.attributes.list.is_some());
508    }
509
510    #[test]
511    fn empty_body_yields_no_blocks() {
512        assert!(markdown_to_comment_blocks("").is_empty());
513    }
514}