1use serde::{Deserialize, Serialize};
11use std::fmt;
12
13pub const MAX_TEXT_CHARS: usize = 8192;
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
28pub struct Text(String);
29
30impl Text {
31 pub fn sanitize(raw: &str) -> Text {
50 let no_escapes = strip_escapes(raw);
51 Text(Self::finish_pipeline(&no_escapes))
52 }
53
54 pub fn sanitize_markdown(raw: &str) -> Text {
70 let no_escapes = strip_escapes(raw);
71 let normalized = normalize_markdown(&no_escapes);
72 Text(Self::finish_pipeline(&normalized))
73 }
74
75 fn finish_pipeline(after_escapes: &str) -> String {
81 let overstruck = resolve_backspace(after_escapes);
82 let no_control = strip_c0(&overstruck);
83 let tabs_expanded = expand_tabs(&no_control, 8);
84 let newlines_normalized = normalize_newlines(&tabs_expanded);
85 let unwrapped = unwrap_paragraphs(&newlines_normalized);
86 let collapsed = collapse_horizontal_whitespace(&unwrapped);
87 let trimmed = trim_lines_and_whole(&collapsed);
88 truncate_chars(&trimmed, MAX_TEXT_CHARS)
89 }
90
91 pub fn as_str(&self) -> &str {
93 &self.0
94 }
95
96 pub fn is_empty(&self) -> bool {
98 self.0.is_empty()
99 }
100
101 pub fn single_line(&self) -> String {
106 let mut out = String::with_capacity(self.0.len());
107 let mut last_was_space = false;
108 for ch in self.0.chars() {
109 let c = if ch == '\n' { ' ' } else { ch };
110 if c == ' ' {
111 if !last_was_space && !out.is_empty() {
112 out.push(' ');
113 }
114 last_was_space = true;
115 } else {
116 out.push(c);
117 last_was_space = false;
118 }
119 }
120 out.trim_end().to_string()
121 }
122}
123
124impl fmt::Display for Text {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 f.write_str(&self.0)
127 }
128}
129
130impl Serialize for Text {
131 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
132 where
133 S: serde::Serializer,
134 {
135 self.0.serialize(serializer)
136 }
137}
138
139impl<'de> Deserialize<'de> for Text {
140 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
147 where
148 D: serde::Deserializer<'de>,
149 {
150 let raw = String::deserialize(deserializer)?;
151 Ok(Text::sanitize(&raw))
152 }
153}
154
155fn strip_escapes(input: &str) -> String {
159 let mut out = String::with_capacity(input.len());
160 let mut chars = input.chars().peekable();
161 while let Some(c) = chars.next() {
162 if c != '\u{1b}' {
163 out.push(c);
164 continue;
165 }
166 match chars.peek() {
167 Some('[') => {
168 chars.next();
170 for c2 in chars.by_ref() {
171 if ('\u{40}'..='\u{7e}').contains(&c2) {
172 break;
173 }
174 }
175 }
176 Some(']') => {
177 chars.next();
179 loop {
180 match chars.next() {
181 None => break,
182 Some('\u{07}') => break,
183 Some('\u{1b}') => {
184 if chars.peek() == Some(&'\\') {
185 chars.next();
186 }
187 break;
188 }
189 Some(_) => continue,
190 }
191 }
192 }
193 Some('P') | Some('X') | Some('^') | Some('_') => {
194 chars.next();
196 loop {
197 match chars.next() {
198 None => break,
199 Some('\u{1b}') => {
200 if chars.peek() == Some(&'\\') {
201 chars.next();
202 }
203 break;
204 }
205 Some(_) => continue,
206 }
207 }
208 }
209 Some(_) => {
210 chars.next();
212 }
213 None => {}
214 }
215 }
216 out
217}
218
219fn resolve_backspace(input: &str) -> String {
224 let mut out: Vec<char> = Vec::with_capacity(input.len());
225 for c in input.chars() {
226 if c == '\u{8}' {
227 out.pop();
228 } else {
229 out.push(c);
230 }
231 }
232 out.into_iter().collect()
233}
234
235fn strip_c0(input: &str) -> String {
238 input
239 .chars()
240 .filter(|&c| {
241 let is_c0 = ('\u{0}'..='\u{1f}').contains(&c);
242 let keep = c == '\t' || c == '\n' || c == '\r';
243 !(is_c0 && !keep) && c != '\u{7f}'
244 })
245 .collect()
246}
247
248fn expand_tabs(input: &str, stop: usize) -> String {
251 let mut out = String::with_capacity(input.len());
252 let mut col = 0usize;
253 for c in input.chars() {
254 match c {
255 '\t' => {
256 let spaces = stop - (col % stop);
257 for _ in 0..spaces {
258 out.push(' ');
259 }
260 col += spaces;
261 }
262 '\n' => {
263 out.push('\n');
264 col = 0;
265 }
266 _ => {
267 out.push(c);
268 col += 1;
269 }
270 }
271 }
272 out
273}
274
275fn normalize_newlines(input: &str) -> String {
277 let mut out = String::with_capacity(input.len());
278 let mut chars = input.chars().peekable();
279 while let Some(c) = chars.next() {
280 if c == '\r' {
281 if chars.peek() == Some(&'\n') {
282 chars.next();
283 }
284 out.push('\n');
285 } else {
286 out.push(c);
287 }
288 }
289 out
290}
291
292fn unwrap_paragraphs(input: &str) -> String {
305 #[derive(Clone, Copy, PartialEq, Eq)]
306 enum Prev {
307 Fresh,
310 Joinable,
312 Standalone,
314 }
315
316 let mut out = String::with_capacity(input.len());
317 let mut prev = Prev::Fresh;
318 for line in input.split('\n') {
319 if line.trim().is_empty() {
320 out.push_str("\n\n");
321 prev = Prev::Fresh;
322 continue;
323 }
324 let standalone = is_standalone_line(line);
325 match prev {
326 Prev::Fresh => out.push_str(line),
327 Prev::Joinable if !standalone => {
328 out.push(' ');
329 out.push_str(line.trim_start());
330 }
331 Prev::Joinable | Prev::Standalone => {
332 out.push('\n');
333 out.push_str(line);
334 }
335 }
336 prev = if standalone {
337 Prev::Standalone
338 } else {
339 Prev::Joinable
340 };
341 }
342 out
343}
344
345fn is_standalone_line(line: &str) -> bool {
349 if line.starts_with(' ') || line.starts_with('\t') {
350 return true;
351 }
352 if line.starts_with("- ") || line.starts_with("* ") || line.starts_with("+ ") {
353 return true;
354 }
355 if let Some(dot) = line.find(". ") {
356 if dot > 0 && line.as_bytes()[..dot].iter().all(|b| b.is_ascii_digit()) {
357 return true;
358 }
359 }
360 false
361}
362
363fn normalize_markdown(input: &str) -> String {
369 let s = strip_markdown_links(input);
370 let s = strip_paired_delim(&s, "`");
371 let s = strip_paired_delim(&s, "**");
372 let s = strip_emphasis_single_char(&s, '*');
373 strip_emphasis_single_char(&s, '_')
374}
375
376fn strip_markdown_links(input: &str) -> String {
382 let chars: Vec<char> = input.chars().collect();
383 let mut out = String::with_capacity(input.len());
384 let mut i = 0;
385 while i < chars.len() {
386 if chars[i] == '[' {
387 if let Some((label, next_i)) = try_parse_link(&chars, i) {
388 out.push_str(&label);
389 i = next_i;
390 continue;
391 }
392 }
393 out.push(chars[i]);
394 i += 1;
395 }
396 out
397}
398
399fn try_parse_link(chars: &[char], start: usize) -> Option<(String, usize)> {
402 let mut j = start + 1;
403 while j < chars.len() && chars[j] != ']' {
404 if chars[j] == '\n' || chars[j] == '[' {
405 return None;
406 }
407 j += 1;
408 }
409 if j >= chars.len() || j == start + 1 {
410 return None;
411 }
412 if chars.get(j + 1) != Some(&'(') {
413 return None;
414 }
415 let mut k = j + 2;
416 while k < chars.len() && chars[k] != ')' {
417 if chars[k] == '\n' || chars[k] == '(' || chars[k].is_whitespace() {
418 return None;
419 }
420 k += 1;
421 }
422 if k >= chars.len() || k == j + 2 {
423 return None;
424 }
425 let label: String = chars[start + 1..j].iter().collect();
426 Some((label, k + 1))
427}
428
429fn strip_paired_delim(input: &str, delim: &str) -> String {
435 let mut out = String::with_capacity(input.len());
436 let mut rest = input;
437 loop {
438 let Some(open_idx) = rest.find(delim) else {
439 out.push_str(rest);
440 break;
441 };
442 let after_open = &rest[open_idx + delim.len()..];
443 if let Some(close_rel) = after_open.find(delim) {
444 let content = &after_open[..close_rel];
445 if !content.is_empty() && !content.contains('\n') {
446 out.push_str(&rest[..open_idx]);
447 out.push_str(content);
448 rest = &after_open[close_rel + delim.len()..];
449 continue;
450 }
451 }
452 out.push_str(&rest[..open_idx + delim.len()]);
453 rest = &rest[open_idx + delim.len()..];
454 }
455 out
456}
457
458fn strip_emphasis_single_char(input: &str, delim: char) -> String {
465 let chars: Vec<char> = input.chars().collect();
466 let mut out = String::with_capacity(input.len());
467 let mut i = 0;
468 while i < chars.len() {
469 if chars[i] == delim && (i == 0 || !is_word_char(chars[i - 1])) {
470 if let Some((content, after)) = try_parse_emphasis(&chars, i, delim) {
471 out.push_str(&content);
472 i = after;
473 continue;
474 }
475 }
476 out.push(chars[i]);
477 i += 1;
478 }
479 out
480}
481
482fn try_parse_emphasis(chars: &[char], open: usize, delim: char) -> Option<(String, usize)> {
483 let mut j = open + 1;
484 while j < chars.len() && chars[j] != '\n' {
485 if chars[j] == delim {
486 let content: String = chars[open + 1..j].iter().collect();
487 let after_ok = chars.get(j + 1).map(|c| !is_word_char(*c)).unwrap_or(true);
488 let content_ok = !content.is_empty()
489 && !content.starts_with(char::is_whitespace)
490 && !content.ends_with(char::is_whitespace)
491 && !content.contains(delim);
492 return if after_ok && content_ok {
493 Some((content, j + 1))
494 } else {
495 None
496 };
497 }
498 j += 1;
499 }
500 None
501}
502
503fn is_word_char(c: char) -> bool {
504 c.is_alphanumeric() || c == '_'
505}
506
507fn collapse_horizontal_whitespace(input: &str) -> String {
512 let mut out = String::with_capacity(input.len());
513 let mut space_run = false;
514 let mut newline_run = 0usize;
515 for c in input.chars() {
516 match c {
517 ' ' => {
518 space_run = true;
519 newline_run = 0;
520 }
521 '\n' => {
522 if space_run {
523 space_run = false;
525 }
526 newline_run += 1;
527 if newline_run <= 2 {
528 out.push('\n');
529 }
530 }
531 _ => {
532 if space_run {
533 out.push(' ');
534 space_run = false;
535 }
536 newline_run = 0;
537 out.push(c);
538 }
539 }
540 }
541 if space_run {
542 out.push(' ');
543 }
544 out
545}
546
547fn trim_lines_and_whole(input: &str) -> String {
549 let lines: Vec<&str> = input.lines().map(|l| l.trim_end_matches(' ')).collect();
550 lines.join("\n").trim().to_string()
551}
552
553fn truncate_chars(input: &str, max_chars: usize) -> String {
555 if input.chars().count() <= max_chars {
556 return input.to_string();
557 }
558 input.chars().take(max_chars).collect()
559}
560
561#[cfg(test)]
562mod markdown_tests {
563 use super::*;
564
565 #[test]
566 fn strips_link_keeping_label() {
567 let t = Text::sanitize_markdown("See [gittutorial](man://gittutorial/7) to start");
568 assert_eq!(t.as_str(), "See gittutorial to start");
569 }
570
571 #[test]
572 fn strips_link_with_https_scheme() {
573 let t = Text::sanitize_markdown("visit [docs](https://example.com/docs) now");
574 assert_eq!(t.as_str(), "visit docs now");
575 }
576
577 #[test]
578 fn strips_link_with_cmd_scheme() {
579 let t = Text::sanitize_markdown("use [gh pr create](cmd://gh/pr/create) instead");
580 assert_eq!(t.as_str(), "use gh pr create instead");
581 }
582
583 #[test]
584 fn does_not_touch_bracket_without_following_paren() {
585 let t = Text::sanitize_markdown("[OPTIONS] COMMAND [ARG...]");
587 assert_eq!(t.as_str(), "[OPTIONS] COMMAND [ARG...]");
588 }
589
590 #[test]
591 fn strips_inline_code_backticks() {
592 let t = Text::sanitize_markdown("run `git bisect start` to begin");
593 assert_eq!(t.as_str(), "run git bisect start to begin");
594 }
595
596 #[test]
597 fn strips_bold() {
598 let t = Text::sanitize_markdown("- **Configured providers** defined here");
599 assert_eq!(t.as_str(), "- Configured providers defined here");
600 }
601
602 #[test]
603 fn strips_single_asterisk_emphasis() {
604 let t = Text::sanitize_markdown("changed *any* property of the project");
605 assert_eq!(t.as_str(), "changed any property of the project");
606 }
607
608 #[test]
609 fn strips_underscore_emphasis() {
610 let t = Text::sanitize_markdown("run with _<cmd>_ and _<arg>_ should exit");
611 assert_eq!(t.as_str(), "run with <cmd> and <arg> should exit");
612 }
613
614 #[test]
615 fn does_not_touch_snake_case_identifiers() {
616 let t = Text::sanitize_markdown("sync from $ANDROID_PRODUCT_OUT to the device");
617 assert_eq!(t.as_str(), "sync from $ANDROID_PRODUCT_OUT to the device");
618 }
619
620 #[test]
621 fn does_not_touch_multiple_underscore_env_vars_in_backticks() {
622 let t = Text::sanitize_markdown("Use `GH_TOKEN` and `GH_DEBUG` for auth and logging");
623 assert_eq!(t.as_str(), "Use GH_TOKEN and GH_DEBUG for auth and logging");
624 }
625
626 #[test]
627 fn leaves_unpaired_delimiters_alone() {
628 let t = Text::sanitize_markdown("this * has an unmatched asterisk");
629 assert_eq!(t.as_str(), "this * has an unmatched asterisk");
630 }
631
632 #[test]
633 fn does_not_span_multiline_code_fence() {
634 let raw = "before\n```\nsome\ncode\n```\nafter";
635 let t = Text::sanitize_markdown(raw);
636 assert!(t.as_str().contains('`'));
639 }
640
641 #[test]
642 fn markdown_sanitize_is_idempotent() {
643 let raw = "See [x](man://x/1) and `code` and **bold** and *em* and _em_";
644 let once = Text::sanitize_markdown(raw);
645 let twice = Text::sanitize_markdown(once.as_str());
646 assert_eq!(once, twice);
647 }
648
649 #[test]
650 fn unwrap_preserves_list_items() {
651 let raw = "Intro line one\nIntro line two\n\n- item one\n- item two\n- item three";
652 let t = Text::sanitize_markdown(raw);
653 assert_eq!(
654 t.as_str(),
655 "Intro line one Intro line two\n\n- item one\n- item two\n- item three"
656 );
657 }
658
659 #[test]
660 fn unwrap_preserves_indented_lines() {
661 let raw = "some prose\n code line one\n code line two\nmore prose";
662 let t = Text::sanitize(raw);
663 assert!(t.as_str().contains("some prose\n"));
665 assert!(t.as_str().contains("code line one\n"));
666 }
667
668 #[test]
669 fn hard_wrapped_paragraph_reflows_to_one_line() {
670 let raw = "Git is a fast, scalable, distributed revision\ncontrol system with an\nunusually rich command set.";
671 let t = Text::sanitize(raw);
672 assert_eq!(
673 t.as_str(),
674 "Git is a fast, scalable, distributed revision control system with an unusually rich command set."
675 );
676 }
677}
678
679#[cfg(test)]
680mod fixture_tests {
681 use super::*;
682 use std::collections::HashMap;
683
684 fn fixtures() -> HashMap<String, String> {
685 let json = include_str!("../tests/fixtures/carapace_markdown_samples.json");
686 serde_json::from_str(json).expect("fixture file is valid JSON")
687 }
688
689 #[test]
693 fn no_fixture_leaks_raw_markdown_link_syntax() {
694 for (name, raw) in fixtures() {
695 let sanitized = Text::sanitize_markdown(raw.as_str());
696 assert!(
697 !sanitized.as_str().contains("]("),
698 "fixture {name:?} leaked raw markdown link syntax: {:?}",
699 sanitized.as_str()
700 );
701 }
702 }
703
704 #[test]
705 fn git_root_doc_links_become_plain_labels() {
706 let fixtures = fixtures();
707 let raw = &fixtures["git_root"];
708 let sanitized = Text::sanitize_markdown(raw);
709 let s = sanitized.as_str();
710 assert!(
711 s.contains("gittutorial"),
712 "label text should survive: {s:?}"
713 );
714 assert!(
715 !s.contains("man://"),
716 "raw URI scheme should not leak: {s:?}"
717 );
718 assert!(!s.contains("]("), "{s:?}");
719 }
720
721 #[test]
722 fn genuine_emphasis_fixture_strips_markers_without_mangling_identifiers() {
723 let fixtures = fixtures();
731 let raw = &fixtures["genuine_emphasis"];
732 let sanitized = Text::sanitize_markdown(raw);
733 let s = sanitized.as_str();
734 assert!(s.contains("git bisect picks a commit"), "{s:?}");
735 assert!(
736 s.contains("any property of your project"),
737 "em marker around 'any' should be stripped: {s:?}"
738 );
739 assert!(s.chars().count() <= MAX_TEXT_CHARS);
740 }
741
742 #[test]
743 fn underscore_emphasis_survives_when_not_truncated_away() {
744 let raw = "Note that _<cmd>_ run with _<arg>_ should exit\nwith code 0";
747 let sanitized = Text::sanitize_markdown(raw);
748 let s = sanitized.as_str();
749 assert!(s.contains("<cmd>"), "{s:?}");
750 assert!(s.contains("<arg>"), "{s:?}");
751 assert!(!s.contains('_'), "{s:?}");
752 }
753
754 #[test]
755 fn snake_case_fixture_is_untouched_by_emphasis_stripping() {
756 let fixtures = fixtures();
757 let raw = &fixtures["snake_case_false_positive"];
758 let sanitized = Text::sanitize_markdown(raw);
759 assert!(sanitized.as_str().contains("ANDROID_PRODUCT_OUT"));
760 }
761
762 #[test]
763 fn env_var_fixture_backticks_stripped_underscores_preserved() {
764 let fixtures = fixtures();
765 let raw = &fixtures["gh_env_vars"];
766 let sanitized = Text::sanitize_markdown(raw);
767 let s = sanitized.as_str();
768 assert!(s.contains("GH_TOKEN"), "{s:?}");
769 assert!(s.contains("GH_DEBUG"), "{s:?}");
770 assert!(!s.contains('`'), "backticks should be stripped: {s:?}");
771 }
772
773 #[test]
774 fn bold_list_fixture_strips_bold_and_links_keeps_list_structure() {
775 let fixtures = fixtures();
776 let raw = &fixtures["bold_sample"];
777 let sanitized = Text::sanitize_markdown(raw);
778 let s = sanitized.as_str();
779 assert!(s.contains("Configured providers"), "{s:?}");
780 assert!(!s.contains("**"), "{s:?}");
781 assert!(!s.contains("]("), "{s:?}");
782 assert!(s.contains("\n- Configured providers"), "{s:?}");
784 assert!(s.contains("\n- Known providers"), "{s:?}");
785 }
786
787 #[test]
791 fn hard_wrapped_git_archive_doc_reflows_paragraphs() {
792 let fixtures = fixtures();
793 let raw = &fixtures["git_archive_hardwrap"];
794 let sanitized = Text::sanitize_markdown(raw);
795 let s = sanitized.as_str();
796 assert!(s.contains("tree structure for the named tree"), "{s:?}");
800 assert!(s.contains("\n\n"), "paragraph break should survive: {s:?}");
802 }
803
804 #[test]
805 fn list_items_fixture_keeps_each_bullet_on_its_own_line() {
806 let fixtures = fixtures();
807 let raw = &fixtures["list_items_sample"];
808 let sanitized = Text::sanitize_markdown(raw);
809 let s = sanitized.as_str();
810 let bullet_lines: Vec<&str> = s.lines().filter(|l| l.starts_with("- ")).collect();
811 assert!(
812 bullet_lines.len() >= 3,
813 "expected multiple preserved bullet lines, got {bullet_lines:?} in {s:?}"
814 );
815 }
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821
822 #[test]
823 fn strips_c0_controls() {
824 let t = Text::sanitize("hello\x01\x02world");
825 assert_eq!(t.as_str(), "helloworld");
826 }
827
828 #[test]
829 fn strips_ansi_csi() {
830 let t = Text::sanitize("\x1b[31mred\x1b[0m text");
831 assert_eq!(t.as_str(), "red text");
832 }
833
834 #[test]
835 fn strips_osc_sequence() {
836 let t = Text::sanitize("\x1b]0;window title\x07visible");
837 assert_eq!(t.as_str(), "visible");
838 }
839
840 #[test]
841 fn strips_osc_sequence_st_terminated() {
842 let t = Text::sanitize("\x1b]8;;http://example.com\x1b\\link\x1b]8;;\x1b\\");
843 assert_eq!(t.as_str(), "link");
844 }
845
846 #[test]
847 fn resolves_underline_overstrike() {
848 let raw = "_\u{8}H_\u{8}e_\u{8}l_\u{8}l_\u{8}o";
850 let t = Text::sanitize(raw);
851 assert_eq!(t.as_str(), "Hello");
852 }
853
854 #[test]
855 fn resolves_bold_overstrike() {
856 let raw = "H\u{8}He\u{8}el\u{8}ll\u{8}lo\u{8}o";
857 let t = Text::sanitize(raw);
858 assert_eq!(t.as_str(), "Hello");
859 }
860
861 #[test]
862 fn stray_backspace_is_absorbed() {
863 let t = Text::sanitize("\u{8}\u{8}\u{8}hello");
864 assert_eq!(t.as_str(), "hello");
865 }
866
867 #[test]
868 fn tab_becomes_whitespace_then_collapses_like_any_other_run() {
869 let t = Text::sanitize("a\tb");
877 assert_eq!(t.as_str(), "a b");
878 }
879
880 #[test]
881 fn tabs_do_not_leak_through_as_raw_characters() {
882 let t = Text::sanitize("col1\tcol2\tcol3");
883 assert!(!t.as_str().contains('\t'));
884 }
885
886 #[test]
887 fn collapses_whitespace_runs() {
888 let t = Text::sanitize("a b");
889 assert_eq!(t.as_str(), "a b");
890 }
891
892 #[test]
893 fn normalizes_crlf() {
894 let t = Text::sanitize("- a\r\n- b\r- c");
901 assert_eq!(t.as_str(), "- a\n- b\n- c");
902 }
903
904 #[test]
905 fn unwraps_single_newlines_within_a_paragraph() {
906 let t = Text::sanitize("a\nb\nc");
907 assert_eq!(t.as_str(), "a b c");
908 }
909
910 #[test]
911 fn keeps_paragraph_breaks() {
912 let t = Text::sanitize("para one\n\npara two");
913 assert_eq!(t.as_str(), "para one\n\npara two");
914 }
915
916 #[test]
917 fn collapses_excess_newlines_to_paragraph_break() {
918 let t = Text::sanitize("para one\n\n\n\n\npara two");
919 assert_eq!(t.as_str(), "para one\n\npara two");
920 }
921
922 #[test]
923 fn trims_whole_text() {
924 let t = Text::sanitize(" hello world ");
925 assert_eq!(t.as_str(), "hello world");
926 }
927
928 #[test]
929 fn truncates_pathological_length() {
930 let raw = "x".repeat(10 * 1024 * 1024);
931 let t = Text::sanitize(&raw);
932 assert!(t.as_str().chars().count() <= MAX_TEXT_CHARS);
933 }
934
935 #[test]
936 fn truncates_at_char_boundary_with_multibyte() {
937 let raw = "\u{1F600}".repeat(MAX_TEXT_CHARS + 100);
938 let t = Text::sanitize(&raw);
939 assert!(t.as_str().chars().count() <= MAX_TEXT_CHARS);
940 assert!(t.as_str().chars().all(|c| c == '\u{1F600}'));
942 }
943
944 #[test]
945 fn preserves_cjk_and_emoji() {
946 let t = Text::sanitize("日本語 emoji 🎉 test");
947 assert_eq!(t.as_str(), "日本語 emoji 🎉 test");
948 }
949
950 #[test]
951 fn single_line_collapses_newlines() {
952 let t = Text::sanitize("line one\nline two\n\nline three");
953 assert_eq!(t.single_line(), "line one line two line three");
954 }
955
956 #[test]
957 fn sanitize_is_idempotent() {
958 let raw = "\x1b[1mBold\x1b[0m\ttext\r\nwith\n\n\n\nparagraphs and spaces ";
959 let once = Text::sanitize(raw);
960 let twice = Text::sanitize(once.as_str());
961 assert_eq!(once, twice);
962 }
963
964 #[test]
965 fn deserialize_sanitizes() {
966 let json = "\"\\u001b[31mred\\u0007\"";
967 let t: Text = serde_json::from_str(json).unwrap();
968 assert_eq!(t.as_str(), "red");
969 }
970
971 #[test]
972 fn serialize_roundtrip() {
973 let t = Text::sanitize("hello world");
974 let json = serde_json::to_string(&t).unwrap();
975 let back: Text = serde_json::from_str(&json).unwrap();
976 assert_eq!(t, back);
977 }
978}