gglib_core/utils/text.rs
1//! Truncating a string for display without splitting a character.
2//!
3//! `&s[..n]` panics when `n` lands inside a multi-byte character, and `s.len()`
4//! is bytes rather than characters — so the obvious spelling of "shorten this
5//! for a log line" is a panic waiting for its first non-ASCII input. That is
6//! not hypothetical: [`InferenceConfig::extract_client_sampling`] renders a
7//! rejected client value into a log line, and a request body carrying
8//! `{"temperature": "ααααα…"}` reached `&s[..40]` mid-character and took the
9//! request task down with it.
10//!
11//! Two call sites wanted the same thing and only one of them got it right, so
12//! the right answer lives here once rather than being re-derived per caller.
13//!
14//! # Byte budget, not character count
15//!
16//! Both functions take `max_bytes` and cut at or below it. A log line's
17//! constraint is how much room it has, and a character budget cannot answer
18//! that — 40 characters is 40 bytes of ASCII and 160 bytes of emoji. The cut
19//! then moves *down* to the nearest character boundary, so the result is
20//! always valid UTF-8 and never longer than asked for.
21//!
22//! [`InferenceConfig::extract_client_sampling`]: crate::domain::InferenceConfig::extract_client_sampling
23
24use std::borrow::Cow;
25
26/// Shorten `s` to at most `max_bytes`, cutting at a character boundary.
27///
28/// Returns `s` unchanged when it already fits. When it does not, the cut moves
29/// down to the nearest boundary at or below `max_bytes`, so the result can be
30/// shorter than the budget but is never longer and never invalid.
31///
32/// Illustrative rather than executable: this helper is crate-internal, and a
33/// doctest compiles as its own crate, so no import path reaches it. The cases
34/// below are asserted for real in this module's tests.
35///
36/// ```text
37/// truncate_at_char_boundary:
38///
39/// truncate_at_char_boundary("hello", 10) == "hello"
40/// truncate_at_char_boundary("hello", 3) == "hel"
41///
42/// // "α" is two bytes, so a budget of 3 fits one of them, not one and a half.
43/// truncate_at_char_boundary("ααα", 3) == "α"
44/// ```
45#[must_use]
46pub(crate) fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str {
47 if s.len() <= max_bytes {
48 s
49 } else {
50 &s[..s.floor_char_boundary(max_bytes)]
51 }
52}
53
54/// [`truncate_at_char_boundary`], with an `…` marking what was cut.
55///
56/// Borrows when nothing was removed, so the common case of a string that
57/// already fits allocates nothing. The ellipsis is appended only on an actual
58/// truncation — a caller can therefore tell "this is the whole value" from
59/// "there was more" by looking at the output, which is the point of printing it.
60///
61/// Illustrative rather than executable: this helper is crate-internal, and a
62/// doctest compiles as its own crate, so no import path reaches it. The cases
63/// below are asserted for real in this module's tests.
64///
65/// ```text
66/// truncate_with_ellipsis:
67///
68/// truncate_with_ellipsis("hello", 10) == "hello"
69/// truncate_with_ellipsis("hello", 3) == "hel…"
70/// ```
71#[must_use]
72pub(crate) fn truncate_with_ellipsis(s: &str, max_bytes: usize) -> Cow<'_, str> {
73 let head = truncate_at_char_boundary(s, max_bytes);
74 if head.len() == s.len() {
75 Cow::Borrowed(s)
76 } else {
77 Cow::Owned(format!("{head}…"))
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn a_string_within_the_budget_is_returned_whole() {
87 assert_eq!(truncate_at_char_boundary("hello", 5), "hello");
88 assert_eq!(truncate_at_char_boundary("hello", 6), "hello");
89 assert_eq!(truncate_with_ellipsis("hello", 5), "hello");
90 }
91
92 #[test]
93 fn an_ascii_string_over_the_budget_is_cut_to_it() {
94 assert_eq!(truncate_at_char_boundary("hello world", 5), "hello");
95 assert_eq!(truncate_with_ellipsis("hello world", 5), "hello…");
96 }
97
98 /// **The defect this module was extracted for.** `&s[..40]` panics when
99 /// byte 40 lands inside a character, and `serde_json` does not escape
100 /// non-ASCII, so any client string long enough reaches it.
101 #[test]
102 fn a_cut_that_lands_inside_a_character_moves_down_to_the_boundary() {
103 // Each "α" is two bytes, so byte 40 of `"ααα…"` (a leading quote plus
104 // pairs) is mid-character — exactly the shape that panicked.
105 let rendered = format!("\"{}\"", "α".repeat(60));
106 let out = truncate_at_char_boundary(&rendered, 40);
107
108 assert!(out.len() <= 40, "must not exceed the budget: {}", out.len());
109 assert_eq!(out.len(), 39, "one byte below, because α does not fit");
110 assert!(rendered.starts_with(out), "must be a prefix");
111 }
112
113 /// Every budget across a multi-byte string must be safe, not just the one
114 /// that happened to bite. Asserting no panic across the whole range is
115 /// cheaper than reasoning about which offsets are boundaries.
116 #[test]
117 fn no_budget_can_split_a_character() {
118 let s = "aα中𝄞bβ𝕏c"; // 1, 2, 3 and 4-byte characters, interleaved
119 for budget in 0..=s.len() + 2 {
120 let out = truncate_at_char_boundary(s, budget);
121 assert!(out.len() <= budget.min(s.len()));
122 assert!(s.starts_with(out));
123 }
124 }
125
126 #[test]
127 fn the_ellipsis_appears_only_when_something_was_cut() {
128 assert!(!truncate_with_ellipsis("short", 40).contains('…'));
129 assert!(truncate_with_ellipsis(&"x".repeat(41), 40).ends_with('…'));
130 }
131
132 /// A string that fits must not allocate — this runs on the request path.
133 #[test]
134 fn a_string_that_fits_is_borrowed_rather_than_copied() {
135 assert!(matches!(
136 truncate_with_ellipsis("short", 40),
137 std::borrow::Cow::Borrowed(_)
138 ));
139 }
140
141 /// A budget of zero is a legitimate ask and must not panic or produce a
142 /// lone ellipsis over nothing.
143 #[test]
144 fn a_zero_budget_yields_an_empty_head() {
145 assert_eq!(truncate_at_char_boundary("αβγ", 0), "");
146 assert_eq!(truncate_with_ellipsis("αβγ", 0), "…");
147 }
148}