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}