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 sanitize_preserving_layout(raw: &str) -> Text {
133 let no_escapes = strip_escapes(raw);
134 let no_control = strip_c0_keep_tabs(&no_escapes);
135 let tabs_expanded = expand_tabs(&no_control, 8);
136 Text(truncate_chars(&tabs_expanded, MAX_TEXT_CHARS))
137 }
138
139 pub fn as_str(&self) -> &str {
141 &self.0
142 }
143
144 pub fn is_empty(&self) -> bool {
146 self.0.is_empty()
147 }
148
149 pub fn single_line(&self) -> String {
154 let mut out = String::with_capacity(self.0.len());
155 let mut last_was_space = false;
156 for ch in self.0.chars() {
157 let c = if ch == '\n' { ' ' } else { ch };
158 if c == ' ' {
159 if !last_was_space && !out.is_empty() {
160 out.push(' ');
161 }
162 last_was_space = true;
163 } else {
164 out.push(c);
165 last_was_space = false;
166 }
167 }
168 out.trim_end().to_string()
169 }
170}
171
172impl fmt::Display for Text {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 f.write_str(&self.0)
175 }
176}
177
178impl Serialize for Text {
179 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
180 where
181 S: serde::Serializer,
182 {
183 self.0.serialize(serializer)
184 }
185}
186
187impl<'de> Deserialize<'de> for Text {
188 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
195 where
196 D: serde::Deserializer<'de>,
197 {
198 let raw = String::deserialize(deserializer)?;
199 Ok(Text::sanitize(&raw))
200 }
201}
202
203fn strip_escapes(input: &str) -> String {
207 let mut out = String::with_capacity(input.len());
208 let mut chars = input.chars().peekable();
209 while let Some(c) = chars.next() {
210 if c != '\u{1b}' {
211 out.push(c);
212 continue;
213 }
214 match chars.peek() {
215 Some('[') => {
216 chars.next();
218 for c2 in chars.by_ref() {
219 if ('\u{40}'..='\u{7e}').contains(&c2) {
220 break;
221 }
222 }
223 }
224 Some(']') => {
225 chars.next();
227 loop {
228 match chars.next() {
229 None => break,
230 Some('\u{07}') => break,
231 Some('\u{1b}') => {
232 if chars.peek() == Some(&'\\') {
233 chars.next();
234 }
235 break;
236 }
237 Some(_) => continue,
238 }
239 }
240 }
241 Some('P') | Some('X') | Some('^') | Some('_') => {
242 chars.next();
244 loop {
245 match chars.next() {
246 None => break,
247 Some('\u{1b}') => {
248 if chars.peek() == Some(&'\\') {
249 chars.next();
250 }
251 break;
252 }
253 Some(_) => continue,
254 }
255 }
256 }
257 Some(_) => {
258 chars.next();
260 }
261 None => {}
262 }
263 }
264 out
265}
266
267fn resolve_backspace(input: &str) -> String {
272 let mut out: Vec<char> = Vec::with_capacity(input.len());
273 for c in input.chars() {
274 if c == '\u{8}' {
275 out.pop();
276 } else {
277 out.push(c);
278 }
279 }
280 out.into_iter().collect()
281}
282
283fn strip_c0(input: &str) -> String {
286 input
287 .chars()
288 .filter(|&c| {
289 let is_c0 = ('\u{0}'..='\u{1f}').contains(&c);
290 let keep = c == '\t' || c == '\n' || c == '\r';
291 !(is_c0 && !keep) && c != '\u{7f}'
292 })
293 .collect()
294}
295
296fn strip_c0_keep_tabs(input: &str) -> String {
306 input
307 .chars()
308 .filter(|&c| {
309 let is_c0 = ('\u{0}'..='\u{1f}').contains(&c);
310 !(is_c0 && c != '\t') && c != '\u{7f}'
311 })
312 .collect()
313}
314
315fn expand_tabs(input: &str, stop: usize) -> String {
318 let mut out = String::with_capacity(input.len());
319 let mut col = 0usize;
320 for c in input.chars() {
321 match c {
322 '\t' => {
323 let spaces = stop - (col % stop);
324 for _ in 0..spaces {
325 out.push(' ');
326 }
327 col += spaces;
328 }
329 '\n' => {
330 out.push('\n');
331 col = 0;
332 }
333 _ => {
334 out.push(c);
335 col += 1;
336 }
337 }
338 }
339 out
340}
341
342fn normalize_newlines(input: &str) -> String {
344 let mut out = String::with_capacity(input.len());
345 let mut chars = input.chars().peekable();
346 while let Some(c) = chars.next() {
347 if c == '\r' {
348 if chars.peek() == Some(&'\n') {
349 chars.next();
350 }
351 out.push('\n');
352 } else {
353 out.push(c);
354 }
355 }
356 out
357}
358
359fn unwrap_paragraphs(input: &str) -> String {
372 #[derive(Clone, Copy, PartialEq, Eq)]
373 enum Prev {
374 Fresh,
377 Joinable,
379 Standalone,
381 }
382
383 let mut out = String::with_capacity(input.len());
384 let mut prev = Prev::Fresh;
385 for line in input.split('\n') {
386 if line.trim().is_empty() {
387 out.push_str("\n\n");
388 prev = Prev::Fresh;
389 continue;
390 }
391 let standalone = is_standalone_line(line);
392 match prev {
393 Prev::Fresh => out.push_str(line),
394 Prev::Joinable if !standalone => {
395 out.push(' ');
396 out.push_str(line.trim_start());
397 }
398 Prev::Joinable | Prev::Standalone => {
399 out.push('\n');
400 out.push_str(line);
401 }
402 }
403 prev = if standalone {
404 Prev::Standalone
405 } else {
406 Prev::Joinable
407 };
408 }
409 out
410}
411
412fn is_standalone_line(line: &str) -> bool {
416 if line.starts_with(' ') || line.starts_with('\t') {
417 return true;
418 }
419 if line.starts_with("- ") || line.starts_with("* ") || line.starts_with("+ ") {
420 return true;
421 }
422 if let Some(dot) = line.find(". ") {
423 if dot > 0 && line.as_bytes()[..dot].iter().all(|b| b.is_ascii_digit()) {
424 return true;
425 }
426 }
427 false
428}
429
430fn normalize_markdown(input: &str) -> String {
436 let s = strip_markdown_links(input);
437 let s = strip_paired_delim(&s, "`");
438 let s = strip_paired_delim(&s, "**");
439 let s = strip_emphasis_single_char(&s, '*');
440 strip_emphasis_single_char(&s, '_')
441}
442
443fn strip_markdown_links(input: &str) -> String {
449 let chars: Vec<char> = input.chars().collect();
450 let mut out = String::with_capacity(input.len());
451 let mut i = 0;
452 while i < chars.len() {
453 if chars[i] == '[' {
454 if let Some((label, next_i)) = try_parse_link(&chars, i) {
455 out.push_str(&label);
456 i = next_i;
457 continue;
458 }
459 }
460 out.push(chars[i]);
461 i += 1;
462 }
463 out
464}
465
466fn try_parse_link(chars: &[char], start: usize) -> Option<(String, usize)> {
469 let mut j = start + 1;
470 while j < chars.len() && chars[j] != ']' {
471 if chars[j] == '\n' || chars[j] == '[' {
472 return None;
473 }
474 j += 1;
475 }
476 if j >= chars.len() || j == start + 1 {
477 return None;
478 }
479 if chars.get(j + 1) != Some(&'(') {
480 return None;
481 }
482 let mut k = j + 2;
483 while k < chars.len() && chars[k] != ')' {
484 if chars[k] == '\n' || chars[k] == '(' || chars[k].is_whitespace() {
485 return None;
486 }
487 k += 1;
488 }
489 if k >= chars.len() || k == j + 2 {
490 return None;
491 }
492 let label: String = chars[start + 1..j].iter().collect();
493 Some((label, k + 1))
494}
495
496fn strip_paired_delim(input: &str, delim: &str) -> String {
502 let mut out = String::with_capacity(input.len());
503 let mut rest = input;
504 loop {
505 let Some(open_idx) = rest.find(delim) else {
506 out.push_str(rest);
507 break;
508 };
509 let after_open = &rest[open_idx + delim.len()..];
510 if let Some(close_rel) = after_open.find(delim) {
511 let content = &after_open[..close_rel];
512 if !content.is_empty() && !content.contains('\n') {
513 out.push_str(&rest[..open_idx]);
514 out.push_str(content);
515 rest = &after_open[close_rel + delim.len()..];
516 continue;
517 }
518 }
519 out.push_str(&rest[..open_idx + delim.len()]);
520 rest = &rest[open_idx + delim.len()..];
521 }
522 out
523}
524
525fn strip_emphasis_single_char(input: &str, delim: char) -> String {
532 let chars: Vec<char> = input.chars().collect();
533 let mut out = String::with_capacity(input.len());
534 let mut i = 0;
535 while i < chars.len() {
536 if chars[i] == delim && (i == 0 || !is_word_char(chars[i - 1])) {
537 if let Some((content, after)) = try_parse_emphasis(&chars, i, delim) {
538 out.push_str(&content);
539 i = after;
540 continue;
541 }
542 }
543 out.push(chars[i]);
544 i += 1;
545 }
546 out
547}
548
549fn try_parse_emphasis(chars: &[char], open: usize, delim: char) -> Option<(String, usize)> {
550 let mut j = open + 1;
551 while j < chars.len() && chars[j] != '\n' {
552 if chars[j] == delim {
553 let content: String = chars[open + 1..j].iter().collect();
554 let after_ok = chars.get(j + 1).map(|c| !is_word_char(*c)).unwrap_or(true);
555 let content_ok = !content.is_empty()
556 && !content.starts_with(char::is_whitespace)
557 && !content.ends_with(char::is_whitespace)
558 && !content.contains(delim);
559 return if after_ok && content_ok {
560 Some((content, j + 1))
561 } else {
562 None
563 };
564 }
565 j += 1;
566 }
567 None
568}
569
570fn is_word_char(c: char) -> bool {
571 c.is_alphanumeric() || c == '_'
572}
573
574fn collapse_horizontal_whitespace(input: &str) -> String {
579 let mut out = String::with_capacity(input.len());
580 let mut space_run = false;
581 let mut newline_run = 0usize;
582 for c in input.chars() {
583 match c {
584 ' ' => {
585 space_run = true;
586 newline_run = 0;
587 }
588 '\n' => {
589 if space_run {
590 space_run = false;
592 }
593 newline_run += 1;
594 if newline_run <= 2 {
595 out.push('\n');
596 }
597 }
598 _ => {
599 if space_run {
600 out.push(' ');
601 space_run = false;
602 }
603 newline_run = 0;
604 out.push(c);
605 }
606 }
607 }
608 if space_run {
609 out.push(' ');
610 }
611 out
612}
613
614fn trim_lines_and_whole(input: &str) -> String {
616 let lines: Vec<&str> = input.lines().map(|l| l.trim_end_matches(' ')).collect();
617 lines.join("\n").trim().to_string()
618}
619
620fn truncate_chars(input: &str, max_chars: usize) -> String {
622 if input.chars().count() <= max_chars {
623 return input.to_string();
624 }
625 input.chars().take(max_chars).collect()
626}
627
628#[cfg(test)]
629mod markdown_tests {
630 use super::*;
631
632 #[test]
633 fn strips_link_keeping_label() {
634 let t = Text::sanitize_markdown("See [gittutorial](man://gittutorial/7) to start");
635 assert_eq!(t.as_str(), "See gittutorial to start");
636 }
637
638 #[test]
639 fn strips_link_with_https_scheme() {
640 let t = Text::sanitize_markdown("visit [docs](https://example.com/docs) now");
641 assert_eq!(t.as_str(), "visit docs now");
642 }
643
644 #[test]
645 fn strips_link_with_cmd_scheme() {
646 let t = Text::sanitize_markdown("use [gh pr create](cmd://gh/pr/create) instead");
647 assert_eq!(t.as_str(), "use gh pr create instead");
648 }
649
650 #[test]
651 fn does_not_touch_bracket_without_following_paren() {
652 let t = Text::sanitize_markdown("[OPTIONS] COMMAND [ARG...]");
654 assert_eq!(t.as_str(), "[OPTIONS] COMMAND [ARG...]");
655 }
656
657 #[test]
658 fn strips_inline_code_backticks() {
659 let t = Text::sanitize_markdown("run `git bisect start` to begin");
660 assert_eq!(t.as_str(), "run git bisect start to begin");
661 }
662
663 #[test]
664 fn strips_bold() {
665 let t = Text::sanitize_markdown("- **Configured providers** defined here");
666 assert_eq!(t.as_str(), "- Configured providers defined here");
667 }
668
669 #[test]
670 fn strips_single_asterisk_emphasis() {
671 let t = Text::sanitize_markdown("changed *any* property of the project");
672 assert_eq!(t.as_str(), "changed any property of the project");
673 }
674
675 #[test]
676 fn strips_underscore_emphasis() {
677 let t = Text::sanitize_markdown("run with _<cmd>_ and _<arg>_ should exit");
678 assert_eq!(t.as_str(), "run with <cmd> and <arg> should exit");
679 }
680
681 #[test]
682 fn does_not_touch_snake_case_identifiers() {
683 let t = Text::sanitize_markdown("sync from $ANDROID_PRODUCT_OUT to the device");
684 assert_eq!(t.as_str(), "sync from $ANDROID_PRODUCT_OUT to the device");
685 }
686
687 #[test]
688 fn does_not_touch_multiple_underscore_env_vars_in_backticks() {
689 let t = Text::sanitize_markdown("Use `GH_TOKEN` and `GH_DEBUG` for auth and logging");
690 assert_eq!(t.as_str(), "Use GH_TOKEN and GH_DEBUG for auth and logging");
691 }
692
693 #[test]
694 fn leaves_unpaired_delimiters_alone() {
695 let t = Text::sanitize_markdown("this * has an unmatched asterisk");
696 assert_eq!(t.as_str(), "this * has an unmatched asterisk");
697 }
698
699 #[test]
700 fn does_not_span_multiline_code_fence() {
701 let raw = "before\n```\nsome\ncode\n```\nafter";
702 let t = Text::sanitize_markdown(raw);
703 assert!(t.as_str().contains('`'));
706 }
707
708 #[test]
709 fn markdown_sanitize_is_idempotent() {
710 let raw = "See [x](man://x/1) and `code` and **bold** and *em* and _em_";
711 let once = Text::sanitize_markdown(raw);
712 let twice = Text::sanitize_markdown(once.as_str());
713 assert_eq!(once, twice);
714 }
715
716 #[test]
717 fn unwrap_preserves_list_items() {
718 let raw = "Intro line one\nIntro line two\n\n- item one\n- item two\n- item three";
719 let t = Text::sanitize_markdown(raw);
720 assert_eq!(
721 t.as_str(),
722 "Intro line one Intro line two\n\n- item one\n- item two\n- item three"
723 );
724 }
725
726 #[test]
727 fn unwrap_preserves_indented_lines() {
728 let raw = "some prose\n code line one\n code line two\nmore prose";
729 let t = Text::sanitize(raw);
730 assert!(t.as_str().contains("some prose\n"));
732 assert!(t.as_str().contains("code line one\n"));
733 }
734
735 #[test]
736 fn hard_wrapped_paragraph_reflows_to_one_line() {
737 let raw = "Git is a fast, scalable, distributed revision\ncontrol system with an\nunusually rich command set.";
738 let t = Text::sanitize(raw);
739 assert_eq!(
740 t.as_str(),
741 "Git is a fast, scalable, distributed revision control system with an unusually rich command set."
742 );
743 }
744}
745
746#[cfg(test)]
747mod fixture_tests {
748 use super::*;
749 use std::collections::HashMap;
750
751 fn fixtures() -> HashMap<String, String> {
752 let json = include_str!("../tests/fixtures/carapace_markdown_samples.json");
753 serde_json::from_str(json).expect("fixture file is valid JSON")
754 }
755
756 #[test]
760 fn no_fixture_leaks_raw_markdown_link_syntax() {
761 for (name, raw) in fixtures() {
762 let sanitized = Text::sanitize_markdown(raw.as_str());
763 assert!(
764 !sanitized.as_str().contains("]("),
765 "fixture {name:?} leaked raw markdown link syntax: {:?}",
766 sanitized.as_str()
767 );
768 }
769 }
770
771 #[test]
772 fn git_root_doc_links_become_plain_labels() {
773 let fixtures = fixtures();
774 let raw = &fixtures["git_root"];
775 let sanitized = Text::sanitize_markdown(raw);
776 let s = sanitized.as_str();
777 assert!(
778 s.contains("gittutorial"),
779 "label text should survive: {s:?}"
780 );
781 assert!(
782 !s.contains("man://"),
783 "raw URI scheme should not leak: {s:?}"
784 );
785 assert!(!s.contains("]("), "{s:?}");
786 }
787
788 #[test]
789 fn genuine_emphasis_fixture_strips_markers_without_mangling_identifiers() {
790 let fixtures = fixtures();
798 let raw = &fixtures["genuine_emphasis"];
799 let sanitized = Text::sanitize_markdown(raw);
800 let s = sanitized.as_str();
801 assert!(s.contains("git bisect picks a commit"), "{s:?}");
802 assert!(
803 s.contains("any property of your project"),
804 "em marker around 'any' should be stripped: {s:?}"
805 );
806 assert!(s.chars().count() <= MAX_TEXT_CHARS);
807 }
808
809 #[test]
810 fn underscore_emphasis_survives_when_not_truncated_away() {
811 let raw = "Note that _<cmd>_ run with _<arg>_ should exit\nwith code 0";
814 let sanitized = Text::sanitize_markdown(raw);
815 let s = sanitized.as_str();
816 assert!(s.contains("<cmd>"), "{s:?}");
817 assert!(s.contains("<arg>"), "{s:?}");
818 assert!(!s.contains('_'), "{s:?}");
819 }
820
821 #[test]
822 fn snake_case_fixture_is_untouched_by_emphasis_stripping() {
823 let fixtures = fixtures();
824 let raw = &fixtures["snake_case_false_positive"];
825 let sanitized = Text::sanitize_markdown(raw);
826 assert!(sanitized.as_str().contains("ANDROID_PRODUCT_OUT"));
827 }
828
829 #[test]
830 fn env_var_fixture_backticks_stripped_underscores_preserved() {
831 let fixtures = fixtures();
832 let raw = &fixtures["gh_env_vars"];
833 let sanitized = Text::sanitize_markdown(raw);
834 let s = sanitized.as_str();
835 assert!(s.contains("GH_TOKEN"), "{s:?}");
836 assert!(s.contains("GH_DEBUG"), "{s:?}");
837 assert!(!s.contains('`'), "backticks should be stripped: {s:?}");
838 }
839
840 #[test]
841 fn bold_list_fixture_strips_bold_and_links_keeps_list_structure() {
842 let fixtures = fixtures();
843 let raw = &fixtures["bold_sample"];
844 let sanitized = Text::sanitize_markdown(raw);
845 let s = sanitized.as_str();
846 assert!(s.contains("Configured providers"), "{s:?}");
847 assert!(!s.contains("**"), "{s:?}");
848 assert!(!s.contains("]("), "{s:?}");
849 assert!(s.contains("\n- Configured providers"), "{s:?}");
851 assert!(s.contains("\n- Known providers"), "{s:?}");
852 }
853
854 #[test]
858 fn hard_wrapped_git_archive_doc_reflows_paragraphs() {
859 let fixtures = fixtures();
860 let raw = &fixtures["git_archive_hardwrap"];
861 let sanitized = Text::sanitize_markdown(raw);
862 let s = sanitized.as_str();
863 assert!(s.contains("tree structure for the named tree"), "{s:?}");
867 assert!(s.contains("\n\n"), "paragraph break should survive: {s:?}");
869 }
870
871 #[test]
872 fn list_items_fixture_keeps_each_bullet_on_its_own_line() {
873 let fixtures = fixtures();
874 let raw = &fixtures["list_items_sample"];
875 let sanitized = Text::sanitize_markdown(raw);
876 let s = sanitized.as_str();
877 let bullet_lines: Vec<&str> = s.lines().filter(|l| l.starts_with("- ")).collect();
878 assert!(
879 bullet_lines.len() >= 3,
880 "expected multiple preserved bullet lines, got {bullet_lines:?} in {s:?}"
881 );
882 }
883}
884
885#[cfg(test)]
886mod tests {
887 use super::*;
888
889 #[test]
890 fn strips_c0_controls() {
891 let t = Text::sanitize("hello\x01\x02world");
892 assert_eq!(t.as_str(), "helloworld");
893 }
894
895 #[test]
896 fn strips_ansi_csi() {
897 let t = Text::sanitize("\x1b[31mred\x1b[0m text");
898 assert_eq!(t.as_str(), "red text");
899 }
900
901 #[test]
902 fn strips_osc_sequence() {
903 let t = Text::sanitize("\x1b]0;window title\x07visible");
904 assert_eq!(t.as_str(), "visible");
905 }
906
907 #[test]
908 fn strips_osc_sequence_st_terminated() {
909 let t = Text::sanitize("\x1b]8;;http://example.com\x1b\\link\x1b]8;;\x1b\\");
910 assert_eq!(t.as_str(), "link");
911 }
912
913 #[test]
914 fn resolves_underline_overstrike() {
915 let raw = "_\u{8}H_\u{8}e_\u{8}l_\u{8}l_\u{8}o";
917 let t = Text::sanitize(raw);
918 assert_eq!(t.as_str(), "Hello");
919 }
920
921 #[test]
922 fn resolves_bold_overstrike() {
923 let raw = "H\u{8}He\u{8}el\u{8}ll\u{8}lo\u{8}o";
924 let t = Text::sanitize(raw);
925 assert_eq!(t.as_str(), "Hello");
926 }
927
928 #[test]
929 fn stray_backspace_is_absorbed() {
930 let t = Text::sanitize("\u{8}\u{8}\u{8}hello");
931 assert_eq!(t.as_str(), "hello");
932 }
933
934 #[test]
935 fn tab_becomes_whitespace_then_collapses_like_any_other_run() {
936 let t = Text::sanitize("a\tb");
944 assert_eq!(t.as_str(), "a b");
945 }
946
947 #[test]
948 fn tabs_do_not_leak_through_as_raw_characters() {
949 let t = Text::sanitize("col1\tcol2\tcol3");
950 assert!(!t.as_str().contains('\t'));
951 }
952
953 #[test]
954 fn collapses_whitespace_runs() {
955 let t = Text::sanitize("a b");
956 assert_eq!(t.as_str(), "a b");
957 }
958
959 #[test]
960 fn normalizes_crlf() {
961 let t = Text::sanitize("- a\r\n- b\r- c");
968 assert_eq!(t.as_str(), "- a\n- b\n- c");
969 }
970
971 #[test]
972 fn unwraps_single_newlines_within_a_paragraph() {
973 let t = Text::sanitize("a\nb\nc");
974 assert_eq!(t.as_str(), "a b c");
975 }
976
977 #[test]
978 fn keeps_paragraph_breaks() {
979 let t = Text::sanitize("para one\n\npara two");
980 assert_eq!(t.as_str(), "para one\n\npara two");
981 }
982
983 #[test]
984 fn collapses_excess_newlines_to_paragraph_break() {
985 let t = Text::sanitize("para one\n\n\n\n\npara two");
986 assert_eq!(t.as_str(), "para one\n\npara two");
987 }
988
989 #[test]
990 fn trims_whole_text() {
991 let t = Text::sanitize(" hello world ");
992 assert_eq!(t.as_str(), "hello world");
993 }
994
995 #[test]
996 fn truncates_pathological_length() {
997 let raw = "x".repeat(10 * 1024 * 1024);
998 let t = Text::sanitize(&raw);
999 assert!(t.as_str().chars().count() <= MAX_TEXT_CHARS);
1000 }
1001
1002 #[test]
1003 fn truncates_at_char_boundary_with_multibyte() {
1004 let raw = "\u{1F600}".repeat(MAX_TEXT_CHARS + 100);
1005 let t = Text::sanitize(&raw);
1006 assert!(t.as_str().chars().count() <= MAX_TEXT_CHARS);
1007 assert!(t.as_str().chars().all(|c| c == '\u{1F600}'));
1009 }
1010
1011 #[test]
1012 fn preserves_cjk_and_emoji() {
1013 let t = Text::sanitize("日本語 emoji 🎉 test");
1014 assert_eq!(t.as_str(), "日本語 emoji 🎉 test");
1015 }
1016
1017 #[test]
1018 fn single_line_collapses_newlines() {
1019 let t = Text::sanitize("line one\nline two\n\nline three");
1020 assert_eq!(t.single_line(), "line one line two line three");
1021 }
1022
1023 #[test]
1024 fn sanitize_is_idempotent() {
1025 let raw = "\x1b[1mBold\x1b[0m\ttext\r\nwith\n\n\n\nparagraphs and spaces ";
1026 let once = Text::sanitize(raw);
1027 let twice = Text::sanitize(once.as_str());
1028 assert_eq!(once, twice);
1029 }
1030
1031 #[test]
1032 fn deserialize_sanitizes() {
1033 let json = "\"\\u001b[31mred\\u0007\"";
1034 let t: Text = serde_json::from_str(json).unwrap();
1035 assert_eq!(t.as_str(), "red");
1036 }
1037
1038 #[test]
1039 fn serialize_roundtrip() {
1040 let t = Text::sanitize("hello world");
1041 let json = serde_json::to_string(&t).unwrap();
1042 let back: Text = serde_json::from_str(&json).unwrap();
1043 assert_eq!(t, back);
1044 }
1045
1046 #[test]
1049 fn preserving_layout_keeps_leading_indentation() {
1050 let t = Text::sanitize_preserving_layout(" -a, --all write counts for all files");
1053 assert_eq!(t.as_str(), " -a, --all write counts for all files");
1054 }
1055
1056 #[test]
1057 fn preserving_layout_keeps_internal_column_gaps() {
1058 let t = Text::sanitize_preserving_layout("--block-size=SIZE scale sizes by SIZE");
1061 assert_eq!(t.as_str(), "--block-size=SIZE scale sizes by SIZE");
1062 }
1063
1064 #[test]
1065 fn preserving_layout_still_strips_ansi_escapes() {
1066 let t = Text::sanitize_preserving_layout("\x1b[31mred\x1b[0m text");
1067 assert_eq!(t.as_str(), "red text");
1068 }
1069
1070 #[test]
1071 fn preserving_layout_strips_osc_sequence() {
1072 let t = Text::sanitize_preserving_layout("\x1b]0;window title\x07visible");
1073 assert_eq!(t.as_str(), "visible");
1074 }
1075
1076 #[test]
1077 fn preserving_layout_strips_stray_carriage_return() {
1078 let t = Text::sanitize_preserving_layout("done\rDONE");
1082 assert_eq!(t.as_str(), "doneDONE");
1083 assert!(!t.as_str().contains('\r'));
1084 }
1085
1086 #[test]
1087 fn preserving_layout_strips_other_c0_controls() {
1088 let t = Text::sanitize_preserving_layout("hello\x01\x02world");
1089 assert_eq!(t.as_str(), "helloworld");
1090 }
1091
1092 #[test]
1093 fn preserving_layout_expands_tabs_instead_of_leaving_them_raw() {
1094 let t = Text::sanitize_preserving_layout("a\tb");
1098 assert_eq!(t.as_str(), "a b");
1099 assert!(!t.as_str().contains('\t'));
1100 }
1101
1102 #[test]
1103 fn preserving_layout_does_not_trim_or_collapse_whitespace() {
1104 let t = Text::sanitize_preserving_layout(" a b ");
1105 assert_eq!(t.as_str(), " a b ");
1106 }
1107
1108 #[test]
1109 fn preserving_layout_bounds_pathological_length() {
1110 let raw = "x".repeat(10 * 1024 * 1024);
1111 let t = Text::sanitize_preserving_layout(&raw);
1112 assert!(t.as_str().chars().count() <= MAX_TEXT_CHARS);
1113 }
1114
1115 #[test]
1116 fn preserving_layout_is_idempotent() {
1117 let raw = "\x1b[1mBold\x1b[0m\t text with\rstray CR";
1118 let once = Text::sanitize_preserving_layout(raw);
1119 let twice = Text::sanitize_preserving_layout(once.as_str());
1120 assert_eq!(once, twice);
1121 }
1122}