1use serde::Serialize;
21
22#[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#[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 #[serde(rename = "code-block", skip_serializing_if = "Option::is_none")]
43 pub code_block: Option<CodeBlockAttr>,
44 #[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)] fn is_false(b: &bool) -> bool {
68 !*b
69}
70
71fn newline(attributes: CommentAttributes) -> CommentBlock {
73 CommentBlock {
74 text: "\n".to_string(),
75 attributes,
76 }
77}
78
79pub fn markdown_to_comment_blocks(body: &str) -> Vec<CommentBlock> {
89 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 if line.trim_start().starts_with("```") {
103 in_code_fence = !in_code_fence;
104 continue;
105 }
106
107 if in_code_fence {
108 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 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 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 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 push_inline_runs(&mut blocks, line);
158 blocks.push(newline(CommentAttributes::default()));
159 }
160
161 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
178fn 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
192fn 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
210fn 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
222fn 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
236fn push_inline_runs(blocks: &mut Vec<CommentBlock>, line: &str) {
240 for run in parse_inline(line) {
241 blocks.push(run);
242 }
243}
244
245fn 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 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 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
305fn find_char(chars: &[char], from: usize, needle: char) -> Option<usize> {
307 (from..chars.len()).find(|&j| chars[j] == needle)
308}
309
310fn 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 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 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 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 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 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 assert!(json.contains(r#""list":{"list":"bullet"}"#));
478 assert!(json.contains(r#""code-block":{"code-block":"plain"}"#));
480 assert!(json.contains(r#""code":true"#));
482 assert!(json.contains(r#"{"text":"item"}"#));
484 }
485
486 #[test]
487 fn trailing_blank_lines_are_trimmed() {
488 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 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}