Skip to main content

zeph_common/
text.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! String utility functions for Unicode-safe text manipulation.
5
6/// Format a token count as a short human-readable string.
7///
8/// - `>= 1_000_000` → `"{:.1}M"`
9/// - `>= 1_000`     → `"{:.1}k"`
10/// - otherwise      → decimal digits
11///
12/// # Examples
13///
14/// ```
15/// use zeph_common::text::format_tokens;
16///
17/// assert_eq!(format_tokens(0), "0");
18/// assert_eq!(format_tokens(999), "999");
19/// assert_eq!(format_tokens(1_500), "1.5k");
20/// assert_eq!(format_tokens(2_000_000), "2.0M");
21/// ```
22#[must_use]
23#[allow(clippy::cast_precision_loss)]
24pub fn format_tokens(n: u64) -> String {
25    if n >= 1_000_000 {
26        format!("{:.1}M", n as f64 / 1_000_000.0)
27    } else if n >= 1_000 {
28        format!("{:.1}k", n as f64 / 1_000.0)
29    } else {
30        n.to_string()
31    }
32}
33
34/// Truncate `s` to at most `max_bytes` bytes, preserving UTF-8 char boundaries.
35///
36/// Returns an owned `String`. If `s` fits within `max_bytes`, returns a copy
37/// unchanged. Otherwise, walks char boundaries and truncates at the largest
38/// boundary that fits.
39#[must_use]
40pub fn truncate_to_bytes(s: &str, max_bytes: usize) -> String {
41    if s.len() <= max_bytes {
42        return s.to_owned();
43    }
44    let mut byte_count = 0usize;
45    let mut end = 0usize;
46    for ch in s.chars() {
47        let ch_len = ch.len_utf8();
48        if byte_count + ch_len > max_bytes {
49            break;
50        }
51        byte_count += ch_len;
52        end += ch_len;
53    }
54    s[..end].to_owned()
55}
56
57/// Borrow a prefix of `s` that fits within `max_bytes` bytes.
58///
59/// Returns a subslice of `s`. Walks backwards from `max_bytes` to find a valid
60/// UTF-8 char boundary.
61#[must_use]
62pub fn truncate_to_bytes_ref(s: &str, max_bytes: usize) -> &str {
63    if s.len() <= max_bytes {
64        return s;
65    }
66    let mut end = max_bytes;
67    while end > 0 && !s.is_char_boundary(end) {
68        end -= 1;
69    }
70    &s[..end]
71}
72
73/// Rough token count estimate: 1 token ≈ 4 Unicode scalar values.
74///
75/// Uses `chars().count()` rather than byte length to avoid overestimating for
76/// non-ASCII content. This is the canonical fallback used when a BPE tokenizer
77/// is unavailable or the input exceeds the tokenizer's size limit.
78#[must_use]
79pub fn estimate_tokens(text: &str) -> usize {
80    text.chars().count() / 4
81}
82
83/// Borrow a prefix of `s` that is at most `max_chars` Unicode scalar values long.
84///
85/// Returns a subslice of `s`. No ellipsis is appended.
86#[must_use]
87pub fn truncate_chars(s: &str, max_chars: usize) -> &str {
88    if max_chars == 0 {
89        return "";
90    }
91    match s.char_indices().nth(max_chars) {
92        Some((byte_idx, _)) => &s[..byte_idx],
93        None => s,
94    }
95}
96
97/// Truncate a string to at most `max_chars` Unicode scalar values.
98///
99/// If the string is longer than `max_chars` chars, the first `max_chars` chars are
100/// kept and the Unicode ellipsis character `…` (U+2026) is appended. If `max_chars`
101/// is zero, returns an empty string.
102#[must_use]
103pub fn truncate_to_chars(s: &str, max_chars: usize) -> String {
104    if max_chars == 0 {
105        return String::new();
106    }
107    let count = s.chars().count();
108    if count <= max_chars {
109        s.to_owned()
110    } else {
111        let truncated: String = s.chars().take(max_chars).collect();
112        format!("{truncated}\u{2026}")
113    }
114}
115
116/// Insert or overwrite fields in a `SKILL.md` frontmatter block.
117///
118/// `fields` is an ordered list of `(key, value)` pairs. For each key, any existing
119/// line `key: ...` in the frontmatter is removed, then all fields are inserted (in
120/// the given order) immediately after the `name:` line — or at the end of the
121/// frontmatter block if `name:` is absent. Returns `skill_md` unchanged if it does
122/// not start with `---` or has no closing `---` delimiter.
123///
124/// Callers must pass distinct keys — duplicate keys in `fields` are not
125/// deduplicated and produce duplicate `key: value` lines in the output.
126///
127/// # Examples
128///
129/// ```
130/// use zeph_common::text::patch_frontmatter_fields;
131///
132/// let md = "---\nname: foo\ndescription: Foo.\n---\n\n# Body\n";
133/// let patched = patch_frontmatter_fields(md, &[("source", "generator"), ("parent_skill", "bar")]);
134/// assert_eq!(
135///     patched,
136///     "---\nname: foo\nsource: generator\nparent_skill: bar\ndescription: Foo.\n---\n\n# Body\n"
137/// );
138/// ```
139#[must_use]
140pub fn patch_frontmatter_fields(skill_md: &str, fields: &[(&str, &str)]) -> String {
141    let Some(after_open) = skill_md.strip_prefix("---") else {
142        return skill_md.to_string();
143    };
144    let Some(close_pos) = after_open.find("---") else {
145        return skill_md.to_string();
146    };
147    let yaml = &after_open[..close_pos];
148    let rest = &after_open[close_pos..];
149
150    let mut lines: Vec<String> = yaml
151        .lines()
152        .filter(|l| {
153            let t = l.trim_start();
154            !fields
155                .iter()
156                .any(|(key, _)| t.starts_with(&format!("{key}:")))
157        })
158        .map(str::to_string)
159        .collect();
160
161    let insert_after = lines
162        .iter()
163        .position(|l| l.trim_start().starts_with("name:"))
164        .map_or(lines.len(), |p| p + 1);
165
166    for (i, (key, value)) in fields.iter().enumerate() {
167        lines.insert(insert_after + i, format!("{key}: {value}"));
168    }
169
170    format!(
171        "---{}\n---{}",
172        lines.join("\n"),
173        rest.trim_start_matches("---")
174    )
175}
176
177/// Escape XML special characters in a string.
178///
179/// Replaces `&`, `<`, `>`, `"`, and `'` with their XML entity equivalents.
180/// Use this when embedding arbitrary text into XML attributes or text nodes to
181/// prevent tag injection.
182///
183/// # Examples
184///
185/// ```
186/// use zeph_common::text::xml_escape;
187///
188/// assert_eq!(xml_escape("a < b && b > c"), "a &lt; b &amp;&amp; b &gt; c");
189/// assert_eq!(xml_escape(r#"say "hi""#), "say &quot;hi&quot;");
190/// assert_eq!(xml_escape("it's"), "it&#39;s");
191/// ```
192#[must_use]
193pub fn xml_escape(s: &str) -> String {
194    let mut out = String::with_capacity(s.len());
195    for ch in s.chars() {
196        match ch {
197            '&' => out.push_str("&amp;"),
198            '<' => out.push_str("&lt;"),
199            '>' => out.push_str("&gt;"),
200            '"' => out.push_str("&quot;"),
201            '\'' => out.push_str("&#39;"),
202            other => out.push(other),
203        }
204    }
205    out
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    // truncate_to_bytes tests
213    #[test]
214    fn bytes_short_unchanged() {
215        assert_eq!(truncate_to_bytes("hello", 10), "hello");
216    }
217
218    #[test]
219    fn bytes_exact_unchanged() {
220        assert_eq!(truncate_to_bytes("hello", 5), "hello");
221    }
222
223    #[test]
224    fn bytes_truncates_at_boundary() {
225        let s = "hello world";
226        assert_eq!(truncate_to_bytes(s, 5), "hello");
227    }
228
229    #[test]
230    fn bytes_unicode_boundary() {
231        // "é" is 2 bytes in UTF-8
232        let s = "héllo";
233        assert_eq!(truncate_to_bytes(s, 3), "hé");
234    }
235
236    #[test]
237    fn bytes_zero_returns_empty() {
238        assert_eq!(truncate_to_bytes("hello", 0), "");
239    }
240
241    // truncate_to_bytes_ref tests
242    #[test]
243    fn bytes_ref_short_unchanged() {
244        assert_eq!(truncate_to_bytes_ref("hello", 10), "hello");
245    }
246
247    #[test]
248    fn bytes_ref_truncates_at_boundary() {
249        assert_eq!(truncate_to_bytes_ref("hello world", 5), "hello");
250    }
251
252    #[test]
253    fn bytes_ref_unicode_boundary() {
254        let s = "héllo";
255        assert_eq!(truncate_to_bytes_ref(s, 2), "h");
256    }
257
258    // truncate_chars tests
259    #[test]
260    fn chars_short_unchanged() {
261        assert_eq!(truncate_chars("hello", 10), "hello");
262    }
263
264    #[test]
265    fn chars_exact_unchanged() {
266        assert_eq!(truncate_chars("hello", 5), "hello");
267    }
268
269    #[test]
270    fn chars_truncates_by_char() {
271        assert_eq!(truncate_chars("hello world", 5), "hello");
272    }
273
274    #[test]
275    fn chars_zero_returns_empty() {
276        assert_eq!(truncate_chars("hello", 0), "");
277    }
278
279    #[test]
280    fn chars_unicode_by_char() {
281        let s = "😀😁😂😃😄extra";
282        assert_eq!(truncate_chars(s, 5), "😀😁😂😃😄");
283    }
284
285    // truncate_to_chars tests
286    #[test]
287    fn to_chars_short_unchanged() {
288        assert_eq!(truncate_to_chars("hello", 10), "hello");
289    }
290
291    #[test]
292    fn to_chars_exact_unchanged() {
293        assert_eq!(truncate_to_chars("hello", 5), "hello");
294    }
295
296    #[test]
297    fn to_chars_appends_ellipsis() {
298        assert_eq!(truncate_to_chars("hello world", 5), "hello\u{2026}");
299    }
300
301    #[test]
302    fn to_chars_zero_returns_empty() {
303        assert_eq!(truncate_to_chars("hello", 0), "");
304    }
305
306    #[test]
307    fn to_chars_unicode() {
308        let s = "😀😁😂😃😄extra";
309        assert_eq!(truncate_to_chars(s, 5), "😀😁😂😃😄\u{2026}");
310    }
311
312    // patch_frontmatter_fields tests
313    #[test]
314    fn frontmatter_inserts_after_name() {
315        let md = "---\nname: foo\ndescription: Foo.\n---\n\n# Body\n";
316        let patched = patch_frontmatter_fields(md, &[("source", "generator")]);
317        assert!(
318            patched.contains("name: foo\nsource: generator\n"),
319            "patched: {patched}"
320        );
321    }
322
323    #[test]
324    fn frontmatter_replaces_existing_values() {
325        let md = "---\nname: foo\nsource: old\nparent_skill: old-parent\ndescription: Foo.\n---\n\n# Body\n";
326        let patched =
327            patch_frontmatter_fields(md, &[("source", "new"), ("parent_skill", "new-parent")]);
328        assert_eq!(patched.matches("source:").count(), 1);
329        assert!(patched.contains("source: new"));
330        assert!(patched.contains("parent_skill: new-parent"));
331        assert!(!patched.contains("old-parent"));
332    }
333
334    #[test]
335    fn frontmatter_no_delimiters_unchanged() {
336        let md = "# No frontmatter\n\nbody";
337        assert_eq!(patch_frontmatter_fields(md, &[("source", "x")]), md);
338    }
339
340    #[test]
341    fn frontmatter_no_name_line_appends_at_end() {
342        let md = "---\ndescription: Foo.\n---\n\n# Body\n";
343        let patched = patch_frontmatter_fields(md, &[("source", "generator")]);
344        assert!(
345            patched.contains("description: Foo.\nsource: generator\n---"),
346            "patched: {patched}"
347        );
348    }
349
350    #[test]
351    fn frontmatter_missing_closing_delimiter_unchanged() {
352        let md = "---\nname: foo\ndescription: Foo.\n\nbody without closing delimiter";
353        assert_eq!(patch_frontmatter_fields(md, &[("source", "x")]), md);
354    }
355
356    #[test]
357    fn frontmatter_closing_delimiter_preceded_by_newline() {
358        // Regression test for #5725: the closing `---` must not be glued to the
359        // last frontmatter line.
360        let md = "---\nname: foo\ndescription: Foo.\n---\n\n# Body\n";
361        let patched =
362            patch_frontmatter_fields(md, &[("source", "generator"), ("parent_skill", "bar")]);
363        assert!(
364            patched.contains("Foo.\n---"),
365            "closing delimiter not preceded by newline: {patched}"
366        );
367        assert!(!patched.contains("Foo.---"), "patched: {patched}");
368    }
369}