1use serde::Serialize;
23
24#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
47 pub link: Option<String>,
48 #[serde(rename = "code-block", skip_serializing_if = "Option::is_none")]
50 pub code_block: Option<CodeBlockAttr>,
51 #[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)] fn is_false(b: &bool) -> bool {
81 !*b
82}
83
84fn newline(attributes: CommentAttributes) -> CommentBlock {
86 CommentBlock {
87 text: "\n".to_string(),
88 attributes,
89 }
90}
91
92pub fn markdown_to_comment_blocks(body: &str) -> Vec<CommentBlock> {
105 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 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 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 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 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 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 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 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 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 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 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 push_inline_runs(&mut blocks, line);
264 blocks.push(newline(CommentAttributes::default()));
265 idx += 1;
266 }
267
268 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
285fn 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
299fn 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
317fn 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
329fn heading_prefix(level: usize) -> &'static str {
332 match level {
333 1 => "\u{25C6} ", 2 => "\u{25B8} ", _ => "",
336 }
337}
338
339fn 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
354fn 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
361fn 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
375fn push_inline_runs(blocks: &mut Vec<CommentBlock>, line: &str) {
379 for run in parse_inline(line) {
380 blocks.push(run);
381 }
382}
383
384fn 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 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 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 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 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
477fn find_char(chars: &[char], from: usize, needle: char) -> Option<usize> {
479 (from..chars.len()).find(|&j| chars[j] == needle)
480}
481
482fn 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
494fn 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
507fn is_table_row(line: &str) -> bool {
514 let l = line.trim();
515 l.starts_with('|') && l.matches('|').count() >= 2
516}
517
518fn 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
526fn 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
551fn 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
570fn 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
586fn 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 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 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 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 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 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 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 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 assert!(json.contains(r#""list":{"list":"bullet"}"#));
801 assert!(json.contains(r#""code-block":{"code-block":"plain"}"#));
803 assert!(json.contains(r#""code":true"#));
805 assert!(json.contains(r#"{"text":"item"}"#));
807 }
808
809 #[test]
810 fn trailing_blank_lines_are_trimmed() {
811 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 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 #[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 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); assert_eq!(lines[0], "| Проверка | Результат |");
911 assert!(lines[1].starts_with("|-"));
912 assert_eq!(lines[2], "| meet | OK |");
913 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 let blocks = markdown_to_comment_blocks("жирный **текст** конец");
937 assert!(
938 blocks
939 .iter()
940 .any(|b| b.attributes.bold && b.text == "текст")
941 );
942 }
943}