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 used to be aborting the daemon.
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/// Substitute `{name}` placeholders in a template.
137///
138/// Plain sequential `str::replace`, the same scheme `CompactionConfig`'s
139/// `user_prompt` uses for `{content}` / `{region_name}` - not a template
140/// language. Placeholders absent from `vars` pass through untouched, so a
141/// nudge or required-region message can contain literal braces without
142/// escaping as long as they don't collide with a supported name.
143pub fn interpolate(template: &str, vars: &[(&str, &str)]) -> String {
144    let mut out = template.to_string();
145    for (name, value) in vars {
146        out = out.replace(&format!("{{{name}}}"), value);
147    }
148    out
149}
150
151#[cfg(test)]
152mod snippet_tests {
153    use super::*;
154
155    const HAY: &str = "the quick brown fox jumps over the lazy dog";
156
157    #[test]
158    fn a_window_in_the_middle_is_elided_at_both_ends() {
159        let at = HAY.find("fox").unwrap();
160        let out = snippet_around(HAY, at, 6);
161        assert!(out.starts_with('…'));
162        assert!(out.ends_with('…'));
163        assert!(out.contains("fox"));
164    }
165
166    #[test]
167    fn a_window_at_the_edges_is_not_elided_there() {
168        assert!(!snippet_around(HAY, 0, 5).starts_with('…'));
169        assert!(!snippet_around(HAY, HAY.len(), 5).ends_with('…'));
170    }
171
172    #[test]
173    fn a_radius_covering_everything_returns_the_whole_string_unmarked() {
174        assert_eq!(snippet_around(HAY, 10, 1000), HAY);
175    }
176
177    /// The reason this lives here rather than at a call site: a byte offset
178    /// landing inside a multi-byte character used to abort the daemon.
179    #[test]
180    fn multi_byte_characters_are_never_split() {
181        let hay = "aaa🇯🇵🎉bbb needle ccc🚀ddd";
182        let at = hay.find("needle").unwrap();
183        // Every radius walks the ends over the emoji in both directions.
184        for radius in 0..hay.len() + 4 {
185            let out = snippet_around(hay, at, radius);
186            assert!(out.chars().all(|c| c != '\u{FFFD}'));
187            if radius >= "needle".len() {
188                assert!(out.contains("needle"));
189            }
190        }
191    }
192
193    #[test]
194    fn an_offset_past_the_end_clamps_instead_of_panicking() {
195        let out = snippet_around(HAY, HAY.len() + 500, 4);
196        assert!(out.starts_with('…'));
197        assert!(!out.ends_with('…'));
198    }
199
200    #[test]
201    fn an_empty_haystack_yields_an_empty_snippet() {
202        assert_eq!(snippet_around("", 0, 10), "");
203        assert_eq!(snippet_around("", 7, 10), "");
204    }
205
206    #[test]
207    fn ceil_char_boundary_walks_forward_and_clamps() {
208        let s = "a🎉b";
209        assert_eq!(ceil_char_boundary(s, 0), 0);
210        // Bytes 2..4 are inside the emoji; the next boundary is 5.
211        assert_eq!(ceil_char_boundary(s, 2), 5);
212        assert_eq!(ceil_char_boundary(s, 900), s.len());
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn estimate_tokens_rounds_up_and_never_zero_for_nonempty() {
222        assert_eq!(estimate_tokens(""), 0);
223        assert_eq!(estimate_tokens("abc"), 1);
224        assert_eq!(estimate_tokens("abcd"), 1);
225        assert_eq!(estimate_tokens("abcde"), 2);
226        // Bytes, not chars: one 3-byte character still costs one budget unit.
227        assert_eq!(estimate_tokens("\u{65e5}"), 1);
228    }
229
230    #[test]
231    fn interpolate_replaces_known_placeholders_and_keeps_the_rest() {
232        // Present, repeated, and absent placeholders in one template.
233        assert_eq!(
234            interpolate(
235                "populate {region} - yes, {region} - in stage {stage} {unknown}",
236                &[("region", "plan"), ("stage", "design")]
237            ),
238            "populate plan - yes, plan - in stage design {unknown}"
239        );
240        // No vars: the template passes through unchanged.
241        assert_eq!(interpolate("no placeholders", &[]), "no placeholders");
242    }
243
244    #[test]
245    fn floor_char_boundary_clamps_and_walks_back() {
246        // Past the end clamps to the length.
247        assert_eq!(floor_char_boundary("abc", 99), 3);
248        // Already a boundary - unchanged.
249        assert_eq!(floor_char_boundary("abc", 2), 2);
250        // Zero is always a boundary, so no walk-back happens.
251        assert_eq!(floor_char_boundary("日本語", 0), 0);
252        // Mid-character walks back to the start of that character. '🎉' is four
253        // bytes at 3..7, so 4, 5 and 6 all floor to 3.
254        let s = "abc🎉";
255        assert_eq!(floor_char_boundary(s, 4), 3);
256        assert_eq!(floor_char_boundary(s, 6), 3);
257        assert_eq!(floor_char_boundary(s, 7), 7);
258        // Walking back all the way to 0 when the first character straddles the cut.
259        assert_eq!(floor_char_boundary("🎉abc", 2), 0);
260        // Empty string: byte 0 is a boundary, so any max clamps to 0.
261        assert_eq!(floor_char_boundary("", 5), 0);
262    }
263
264    #[test]
265    fn truncate_at_boundary_never_splits_a_character() {
266        assert_eq!(truncate_at_boundary("abc", 99), "abc");
267        assert_eq!(truncate_at_boundary("abcdef", 3), "abc");
268        // The exact shape that panicked in issues #109/#115.
269        assert_eq!(truncate_at_boundary("abc🎉def", 5), "abc");
270        assert_eq!(truncate_at_boundary("🎉abc", 2), "");
271        assert_eq!(truncate_at_boundary("", 5), "");
272    }
273
274    #[test]
275    fn substring_is_total() {
276        assert_eq!(substring("abcdef", 2, 4), "cd");
277        assert_eq!(substring("abcdef", 0, 6), "abcdef");
278        // Both ends walk back off a multi-byte character rather than panicking.
279        assert_eq!(substring("a🎉b", 1, 4), "");
280        assert_eq!(substring("a🎉b", 0, 3), "a");
281        assert_eq!(substring("a🎉b", 1, 5), "🎉");
282        // No pair of arguments panics: past the end, backwards, and saturated.
283        assert_eq!(substring("abc", 1, 99), "bc");
284        assert_eq!(substring("abc", 99, 99), "");
285        assert_eq!(substring("abc", 2, 1), "");
286        assert_eq!(substring("abc", usize::MAX, usize::MAX), "");
287        assert_eq!(substring("", 3, 9), "");
288    }
289
290    #[test]
291    fn split_at_boundary_halves_rejoin_to_the_input() {
292        assert_eq!(split_at_boundary("abcdef", 2), ("ab", "cdef"));
293        assert_eq!(split_at_boundary("abc", 0), ("", "abc"));
294        // A cut inside the emoji lands before it, so nothing is lost or doubled.
295        assert_eq!(split_at_boundary("a🎉b", 3), ("a", "🎉b"));
296        // Past the end puts everything in the first half.
297        assert_eq!(split_at_boundary("abc", 99), ("abc", ""));
298        assert_eq!(split_at_boundary("", 4), ("", ""));
299        for mid in 0..=10 {
300            let (head, tail) = split_at_boundary("a🎉bc", mid);
301            assert_eq!(format!("{head}{tail}"), "a🎉bc", "lost text at mid={mid}");
302        }
303    }
304}