Skip to main content

agent_core/
text.rs

1//! Shared UTF-8-safe, byte-budgeted text truncation (T2).
2//!
3//! [`BoundedText`] is the one workspace-wide utility for producing
4//! byte-bounded, valid-UTF-8 previews of arbitrary (user- or model-derived)
5//! strings. Production code must never byte-index-slice such strings
6//! directly; route through this type or [`crate::truncate_str`] (the
7//! borrowing primitive this type is built on).
8
9/// A byte-bounded, char-boundary-safe view of a string, with exact
10/// accounting of what was kept and what was cut.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct BoundedText {
13    /// The retained prefix — always valid UTF-8, at most the byte budget.
14    pub text: String,
15    /// Byte length of the original input.
16    pub original_bytes: usize,
17    /// Byte length of `text` (≤ budget, ≤ `original_bytes`).
18    pub retained_bytes: usize,
19    /// Whether anything was cut (`retained_bytes < original_bytes`).
20    pub truncated: bool,
21}
22
23impl BoundedText {
24    /// Truncate `s` to at most `max_bytes` bytes at a valid UTF-8 boundary.
25    /// Never panics; greedy (keeps the longest prefix that fits).
26    pub fn new(s: &str, max_bytes: usize) -> Self {
27        let text = crate::truncate_str(s, max_bytes);
28        Self {
29            original_bytes: s.len(),
30            retained_bytes: text.len(),
31            truncated: text.len() < s.len(),
32            text: text.to_string(),
33        }
34    }
35}
36
37#[cfg(test)]
38mod tests {
39    use super::*;
40
41    #[test]
42    fn bounded_text_reports_exact_accounting() {
43        let bt = BoundedText::new("hello", 10);
44        assert_eq!(bt.text, "hello");
45        assert_eq!(bt.original_bytes, 5);
46        assert_eq!(bt.retained_bytes, 5);
47        assert!(!bt.truncated);
48
49        let bt = BoundedText::new("hello", 3);
50        assert_eq!(bt.text, "hel");
51        assert_eq!(bt.original_bytes, 5);
52        assert_eq!(bt.retained_bytes, 3);
53        assert!(bt.truncated);
54    }
55
56    /// Property-style sweep: for a deterministic corpus of adversarial
57    /// Unicode inputs (multibyte Latin, emoji + ZWJ, CJK, combining marks,
58    /// LCG-generated mixes) and EVERY byte budget from 0 to len+2, the
59    /// constructor never panics, never exceeds the budget, always yields a
60    /// valid-UTF-8 prefix, accounts bytes exactly, and is greedy.
61    #[test]
62    fn bounded_text_property_sweep_never_exceeds_budget() {
63        let pool: Vec<char> = "aé汉字テ🌟👨\u{200D}👩\u{301}\u{300}あ€𝄞\u{FFFD}"
64            .chars()
65            .collect();
66        let mut corpus: Vec<String> = vec![
67            String::new(),
68            "plain ascii".into(),
69            "héllo wörld".into(),
70            "🌟👨\u{200D}👩\u{200D}👧\u{200D}👦🌟".into(),
71            "汉字漢字テスト한글".into(),
72            "e\u{301}\u{300}o\u{308}".into(),
73            "a🌟b汉c\u{301}d".into(),
74        ];
75        // Deterministic LCG generator — no new dependencies.
76        let mut state: u64 = 0x2545_F491_4F6C_DD1D;
77        for len in [1usize, 3, 7, 17, 33] {
78            let mut s = String::new();
79            for _ in 0..len {
80                state = state
81                    .wrapping_mul(6364136223846793005)
82                    .wrapping_add(1442695040888963407);
83                s.push(pool[(state >> 33) as usize % pool.len()]);
84            }
85            corpus.push(s);
86        }
87        for s in &corpus {
88            for budget in 0..=s.len() + 2 {
89                let bt = BoundedText::new(s, budget);
90                assert!(
91                    bt.retained_bytes <= budget,
92                    "budget exceeded: {s:?} @ {budget}"
93                );
94                assert_eq!(bt.retained_bytes, bt.text.len());
95                assert_eq!(bt.original_bytes, s.len());
96                assert!(s.starts_with(&bt.text), "not a prefix: {s:?} @ {budget}");
97                assert_eq!(bt.truncated, bt.retained_bytes < s.len());
98                // Greedy: the next char (if any) must not have fit.
99                if bt.truncated {
100                    let next_len = s[bt.retained_bytes..]
101                        .chars()
102                        .next()
103                        .map(char::len_utf8)
104                        .unwrap_or(0);
105                    assert!(
106                        bt.retained_bytes + next_len > budget,
107                        "not greedy: {s:?} @ {budget}"
108                    );
109                }
110            }
111        }
112    }
113}