sqz_engine/text_boundary.rs
1//! UTF-8 boundary-safe slicing helpers.
2//!
3//! sqz slices user-controlled text (command output, session content,
4//! function signatures) at computed byte offsets in many places. Direct
5//! `&s[..n]` panics when `n` lands inside a multi-byte character — with
6//! the shell hook in the pipeline, that panic swallows the user's entire
7//! command output (issue #34, `Привет мир` + a `password:` marker).
8//!
9//! Every truncation of arbitrary text must go through these helpers.
10//! Slicing is only safe to do directly when the surrounding code has
11//! already proven the offset is a boundary (an ASCII-only match, a value
12//! returned by `find()`, a hex hash).
13
14/// Largest byte index `<= max_bytes` that is a `char` boundary of `s`.
15///
16/// `O(1)`: a UTF-8 sequence is at most 4 bytes, so the nearest boundary
17/// is at most 3 bytes back.
18#[inline]
19pub fn floor_char_boundary(s: &str, max_bytes: usize) -> usize {
20 if max_bytes >= s.len() {
21 return s.len();
22 }
23 let mut i = max_bytes;
24 while i > 0 && !s.is_char_boundary(i) {
25 i -= 1;
26 }
27 i
28}
29
30/// Boundary-safe version of `&s[..max_bytes]`: truncates to the largest
31/// char boundary at or below `max_bytes`. Never panics.
32#[inline]
33pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
34 &s[..floor_char_boundary(s, max_bytes)]
35}
36
37/// Boundary-safe version of `s.split_at(mid)`. `mid` is rounded DOWN to
38/// the nearest char boundary so the left side never exceeds `mid` bytes.
39#[inline]
40pub fn split_at_boundary_safe(s: &str, mid: usize) -> (&str, &str) {
41 s.split_at(floor_char_boundary(s, mid))
42}
43
44// ── Tests ─────────────────────────────────────────────────────────────────
45
46#[cfg(test)]
47mod tests {
48 use super::*;
49 use proptest::prelude::*;
50
51 #[test]
52 fn ascii_passthrough() {
53 assert_eq!(truncate_str("hello world", 5), "hello");
54 assert_eq!(truncate_str("hello", 100), "hello");
55 assert_eq!(truncate_str("", 5), "");
56 }
57
58 #[test]
59 fn cyrillic_mid_char() {
60 // "Привет" — every char is 2 bytes. Byte 7 splits 'в' (bytes 6..8).
61 let s = "Привет мир";
62 assert_eq!(truncate_str(s, 7), "При"); // 6 bytes, 3 chars
63 assert_eq!(truncate_str(s, 6), "При");
64 assert_eq!(truncate_str(s, 8), "Прив");
65 }
66
67 #[test]
68 fn four_byte_emoji() {
69 let s = "ab🦀cd"; // 🦀 = 4 bytes at offset 2..6
70 assert_eq!(truncate_str(s, 3), "ab");
71 assert_eq!(truncate_str(s, 4), "ab");
72 assert_eq!(truncate_str(s, 5), "ab");
73 assert_eq!(truncate_str(s, 6), "ab🦀");
74 }
75
76 #[test]
77 fn split_never_panics_mid_char() {
78 let s = "Привет";
79 let (l, r) = split_at_boundary_safe(s, 7);
80 assert_eq!(l, "При");
81 assert_eq!(r, "вет");
82 assert_eq!(format!("{l}{r}"), s);
83 }
84
85 proptest! {
86 /// For ANY string and ANY cut point, truncate_str never panics,
87 /// returns a prefix, and never exceeds the requested byte length.
88 #[test]
89 fn prop_truncate_safe(s in "\\PC*", n in 0usize..512) {
90 let t = truncate_str(&s, n);
91 prop_assert!(t.len() <= n.min(s.len()));
92 prop_assert!(s.starts_with(t));
93 }
94
95 /// split_at_boundary_safe always reassembles to the original.
96 #[test]
97 fn prop_split_reassembles(s in "\\PC*", n in 0usize..512) {
98 let (l, r) = split_at_boundary_safe(&s, n);
99 let reassembled = format!("{l}{r}");
100 let left_len = l.len();
101 prop_assert_eq!(reassembled, s.clone());
102 prop_assert!(left_len <= n.min(s.len()));
103 }
104 }
105}