Skip to main content

leviath_core/
text.rs

1//! Cutting a `&str` at a byte offset without splitting a character.
2//!
3//! Rust panics on `&s[..n]` when `n` lands inside a multi-byte character, and
4//! this workspace slices strings at fixed byte budgets in a lot of places:
5//! script-tool I/O caps, ACP frame chunking, region seed truncation, dashboard
6//! column fitting, log previews. Every one of those had grown its own
7//! `while !s.is_char_boundary(end) { end -= 1 }` loop with its own comment
8//! explaining why - and two sites (`lev test`'s response preview and `lev
9//! setup`'s key redactor) never grew one at all and panicked on any emoji.
10//!
11//! That failure has happened for real: a byte cut-off through a flag emoji
12//! inside a Rhai host function double-panicked and aborted the whole daemon.
13//! Keeping the walk-back in one tested place means a new truncation site cannot
14//! forget it. The workspace denies `clippy::string_slice` with no exceptions, so
15//! reaching for a raw `&s[..n]` instead of these helpers is a compile error.
16//!
17//! [`substring`] and [`split_at_boundary`] are the general replacements, and
18//! both are *total*: no combination of offsets makes either panic. That matters
19//! more than it sounds. The proof obligation on `&s[a..b]` is real but it is
20//! discharged by reading, and the sites that need it most are byte-offset
21//! scanners walking text nobody in this repo wrote - fetched HTML, a model's
22//! fenced output, an SSE frame off the wire. Those are exactly the places where
23//! a careful reading is least likely to be right, and where being wrong took
24//! the daemon down. Clamping is a worse answer than a correct index and a much
25//! better one than an abort.
26
27/// Largest byte index `<= max` that is a char boundary in `s`.
28///
29/// `max` past the end clamps to `s.len()`. The walk-back always terminates:
30/// byte 0 is a boundary in every string, including the empty one.
31pub fn floor_char_boundary(s: &str, max: usize) -> usize {
32    let mut end = max.min(s.len());
33    while !s.is_char_boundary(end) {
34        end -= 1;
35    }
36    end
37}
38
39/// The text of `s` between two byte offsets, with both ends walked back to a
40/// char boundary and clamped into the string.
41///
42/// This is the workspace's substitute for `&s[a..b]`, and it is total: there is
43/// no offset, ordering or overflow of the two arguments that can make it panic.
44/// A range running off the end yields what is there, and a backwards range
45/// yields nothing. Callers that have *searched* for their offsets get the exact
46/// slice they asked for, because a `find` hit is already a boundary; callers
47/// that computed one get the nearest cut that does not split a character.
48///
49/// That totality is the point. A scanner walking byte offsets through text it
50/// did not author - HTML from a fetch, a model's fenced output, an SSE frame -
51/// cannot be read closely enough to prove every offset correct, and the failure
52/// mode for getting one wrong is a panic that takes the daemon with it.
53pub fn substring(s: &str, start: usize, end: usize) -> &str {
54    let end = floor_char_boundary(s, end);
55    let start = floor_char_boundary(s, start.min(end));
56    // Both bounds are now char boundaries at or inside `s`, so the range is
57    // always valid and the fallback is unreachable. It is spelled out anyway so
58    // that a later change to the clamping above cannot reintroduce a panic.
59    s.get(start..end).unwrap_or("")
60}
61
62/// `s` cut in two at `mid`, walked back to a char boundary.
63///
64/// The pair form of [`substring`], for scanners that need both the text before
65/// an offset and the text after it. `mid` past the end puts everything in the
66/// first half.
67pub fn split_at_boundary(s: &str, mid: usize) -> (&str, &str) {
68    let mid = floor_char_boundary(s, mid);
69    (substring(s, 0, mid), substring(s, mid, s.len()))
70}
71
72/// `&s[..max]`, backed off to the nearest char boundary at or before `max`.
73///
74/// Returns all of `s` when `max` reaches the end. Callers append their own
75/// ellipsis or truncation marker - this only cuts.
76pub fn truncate_at_boundary(s: &str, max: usize) -> &str {
77    substring(s, 0, max)
78}
79
80/// Smallest byte index `>= min` that is a char boundary in `s`.
81///
82/// The mirror of [`floor_char_boundary`], for cutting the *end* of a window.
83/// The walk-forward always terminates: `s.len()` is a boundary in every string.
84pub fn ceil_char_boundary(s: &str, min: usize) -> usize {
85    let mut start = min.min(s.len());
86    while !s.is_char_boundary(start) {
87        start += 1;
88    }
89    start
90}
91
92/// A window of `s` around the byte offset `at`, reaching `radius` bytes either
93/// side, with `…` marking each end that was cut.
94///
95/// For showing *why* something matched: a search hit deep in a megabyte of
96/// transcript is only useful with the text around it, and neither existing
97/// helper gives that - [`truncate_at_boundary`] only takes a prefix.
98///
99/// Both ends are moved outward to char boundaries rather than inward, so the
100/// window never loses a character that was inside the requested radius, and the
101/// match itself cannot be clipped by a boundary walk. `at` past the end clamps,
102/// so a stale offset yields a short window instead of a panic.
103pub fn snippet_around(s: &str, at: usize, radius: usize) -> String {
104    let at = at.min(s.len());
105    let start = floor_char_boundary(s, at.saturating_sub(radius));
106    let end = ceil_char_boundary(s, (at + radius).min(s.len()));
107    let mut out = String::new();
108    if start > 0 {
109        out.push('…');
110    }
111    out.push_str(substring(s, start, end));
112    if end < s.len() {
113        out.push('…');
114    }
115    out
116}
117
118/// The workspace's one generic token estimate: bytes divided by four,
119/// rounded up.
120///
121/// Every context budget, eviction threshold, and truncation cap that has no
122/// exact tokenizer runs on this. It was open-coded across ~30 sites with
123/// three disagreeing formulas (`/4`, `/4 + 1`, `div_ceil(4)`), which meant
124/// the same text could count differently on the budgeting side and the
125/// truncation side of one decision. Rounding up (never 0 for non-empty text)
126/// is the safe direction for a budget: overestimating spends a token of
127/// headroom, underestimating overflows a window.
128///
129/// Provider-accuracy heuristics (e.g. the Anthropic-calibrated bytes/3.5 in
130/// `leviath-providers`) are deliberately separate: they estimate a specific
131/// tokenizer, this estimates "text-shaped budget units".
132pub fn estimate_tokens(s: &str) -> usize {
133    s.len().div_ceil(4)
134}
135
136/// The first `width` characters of `s`, whole characters only.
137///
138/// For fixed-width table cells over author-supplied names: counts characters
139/// rather than bytes, so a name of accented letters is cut where the column
140/// ends and never inside a character. No ellipsis; a cell that wants one
141/// appends it. Two commands had this as a private helper each.
142pub fn truncate_chars(s: &str, width: usize) -> String {
143    match s.chars().count() > width {
144        true => s.chars().take(width).collect(),
145        false => s.to_string(),
146    }
147}
148
149/// Substitute `{name}` placeholders in a template.
150///
151/// Plain sequential `str::replace`, the same scheme `CompactionConfig`'s
152/// `user_prompt` uses for `{content}` / `{region_name}` - not a template
153/// language. Placeholders absent from `vars` pass through untouched, so a
154/// nudge or required-region message can contain literal braces without
155/// escaping as long as they don't collide with a supported name.
156pub fn interpolate(template: &str, vars: &[(&str, &str)]) -> String {
157    let mut out = template.to_string();
158    for (name, value) in vars {
159        out = out.replace(&format!("{{{name}}}"), value);
160    }
161    out
162}
163
164#[cfg(test)]
165mod snippet_tests {
166    use super::*;
167
168    const HAY: &str = "the quick brown fox jumps over the lazy dog";
169
170    #[test]
171    fn a_window_in_the_middle_is_elided_at_both_ends() {
172        let at = HAY.find("fox").unwrap();
173        let out = snippet_around(HAY, at, 6);
174        assert!(out.starts_with('…'));
175        assert!(out.ends_with('…'));
176        assert!(out.contains("fox"));
177    }
178
179    #[test]
180    fn a_window_at_the_edges_is_not_elided_there() {
181        assert!(!snippet_around(HAY, 0, 5).starts_with('…'));
182        assert!(!snippet_around(HAY, HAY.len(), 5).ends_with('…'));
183    }
184
185    #[test]
186    fn a_radius_covering_everything_returns_the_whole_string_unmarked() {
187        assert_eq!(snippet_around(HAY, 10, 1000), HAY);
188    }
189
190    /// The reason this lives here rather than at a call site: a byte offset
191    /// landing inside a multi-byte character panics a plain slice, and that
192    /// panic takes the daemon with it.
193    #[test]
194    fn multi_byte_characters_are_never_split() {
195        let hay = "aaa🇯🇵🎉bbb needle ccc🚀ddd";
196        let at = hay.find("needle").unwrap();
197        // Every radius walks the ends over the emoji in both directions.
198        for radius in 0..hay.len() + 4 {
199            let out = snippet_around(hay, at, radius);
200            assert!(out.chars().all(|c| c != '\u{FFFD}'));
201            if radius >= "needle".len() {
202                assert!(out.contains("needle"));
203            }
204        }
205    }
206
207    #[test]
208    fn an_offset_past_the_end_clamps_instead_of_panicking() {
209        let out = snippet_around(HAY, HAY.len() + 500, 4);
210        assert!(out.starts_with('…'));
211        assert!(!out.ends_with('…'));
212    }
213
214    #[test]
215    fn an_empty_haystack_yields_an_empty_snippet() {
216        assert_eq!(snippet_around("", 0, 10), "");
217        assert_eq!(snippet_around("", 7, 10), "");
218    }
219
220    #[test]
221    fn ceil_char_boundary_walks_forward_and_clamps() {
222        let s = "a🎉b";
223        assert_eq!(ceil_char_boundary(s, 0), 0);
224        // Bytes 2..4 are inside the emoji; the next boundary is 5.
225        assert_eq!(ceil_char_boundary(s, 2), 5);
226        assert_eq!(ceil_char_boundary(s, 900), s.len());
227    }
228}
229
230#[cfg(test)]
231mod tests {
232
233    #[test]
234    fn truncate_chars_counts_characters_not_bytes() {
235        assert_eq!(truncate_chars("short", 20), "short");
236        assert_eq!(truncate_chars("exactly", 7), "exactly");
237        assert_eq!(truncate_chars("longer than that", 6), "longer");
238        // Each `é` is two bytes; five characters is five characters.
239        let cut = truncate_chars(&"é".repeat(30), 5);
240        assert_eq!(cut.chars().count(), 5);
241        assert_eq!(cut, "ééééé");
242    }
243    use super::*;
244
245    #[test]
246    fn estimate_tokens_rounds_up_and_never_zero_for_nonempty() {
247        assert_eq!(estimate_tokens(""), 0);
248        assert_eq!(estimate_tokens("abc"), 1);
249        assert_eq!(estimate_tokens("abcd"), 1);
250        assert_eq!(estimate_tokens("abcde"), 2);
251        // Bytes, not chars: one 3-byte character still costs one budget unit.
252        assert_eq!(estimate_tokens("\u{65e5}"), 1);
253    }
254
255    #[test]
256    fn interpolate_replaces_known_placeholders_and_keeps_the_rest() {
257        // Present, repeated, and absent placeholders in one template.
258        assert_eq!(
259            interpolate(
260                "populate {region} - yes, {region} - in stage {stage} {unknown}",
261                &[("region", "plan"), ("stage", "design")]
262            ),
263            "populate plan - yes, plan - in stage design {unknown}"
264        );
265        // No vars: the template passes through unchanged.
266        assert_eq!(interpolate("no placeholders", &[]), "no placeholders");
267    }
268
269    #[test]
270    fn floor_char_boundary_clamps_and_walks_back() {
271        // Past the end clamps to the length.
272        assert_eq!(floor_char_boundary("abc", 99), 3);
273        // Already a boundary - unchanged.
274        assert_eq!(floor_char_boundary("abc", 2), 2);
275        // Zero is always a boundary, so no walk-back happens.
276        assert_eq!(floor_char_boundary("日本語", 0), 0);
277        // Mid-character walks back to the start of that character. '🎉' is four
278        // bytes at 3..7, so 4, 5 and 6 all floor to 3.
279        let s = "abc🎉";
280        assert_eq!(floor_char_boundary(s, 4), 3);
281        assert_eq!(floor_char_boundary(s, 6), 3);
282        assert_eq!(floor_char_boundary(s, 7), 7);
283        // Walking back all the way to 0 when the first character straddles the cut.
284        assert_eq!(floor_char_boundary("🎉abc", 2), 0);
285        // Empty string: byte 0 is a boundary, so any max clamps to 0.
286        assert_eq!(floor_char_boundary("", 5), 0);
287    }
288
289    #[test]
290    fn truncate_at_boundary_never_splits_a_character() {
291        assert_eq!(truncate_at_boundary("abc", 99), "abc");
292        assert_eq!(truncate_at_boundary("abcdef", 3), "abc");
293        // The exact shape that panics a plain `&s[..n]`.
294        assert_eq!(truncate_at_boundary("abc🎉def", 5), "abc");
295        assert_eq!(truncate_at_boundary("🎉abc", 2), "");
296        assert_eq!(truncate_at_boundary("", 5), "");
297    }
298
299    #[test]
300    fn substring_is_total() {
301        assert_eq!(substring("abcdef", 2, 4), "cd");
302        assert_eq!(substring("abcdef", 0, 6), "abcdef");
303        // Both ends walk back off a multi-byte character rather than panicking.
304        assert_eq!(substring("a🎉b", 1, 4), "");
305        assert_eq!(substring("a🎉b", 0, 3), "a");
306        assert_eq!(substring("a🎉b", 1, 5), "🎉");
307        // No pair of arguments panics: past the end, backwards, and saturated.
308        assert_eq!(substring("abc", 1, 99), "bc");
309        assert_eq!(substring("abc", 99, 99), "");
310        assert_eq!(substring("abc", 2, 1), "");
311        assert_eq!(substring("abc", usize::MAX, usize::MAX), "");
312        assert_eq!(substring("", 3, 9), "");
313    }
314
315    #[test]
316    fn split_at_boundary_halves_rejoin_to_the_input() {
317        assert_eq!(split_at_boundary("abcdef", 2), ("ab", "cdef"));
318        assert_eq!(split_at_boundary("abc", 0), ("", "abc"));
319        // A cut inside the emoji lands before it, so nothing is lost or doubled.
320        assert_eq!(split_at_boundary("a🎉b", 3), ("a", "🎉b"));
321        // Past the end puts everything in the first half.
322        assert_eq!(split_at_boundary("abc", 99), ("abc", ""));
323        assert_eq!(split_at_boundary("", 4), ("", ""));
324        for mid in 0..=10 {
325            let (head, tail) = split_at_boundary("a🎉bc", mid);
326            assert_eq!(format!("{head}{tail}"), "a🎉bc", "lost text at mid={mid}");
327        }
328    }
329}