Skip to main content

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}