1#[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#[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#[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#[must_use]
79pub fn estimate_tokens(text: &str) -> usize {
80 text.chars().count() / 4
81}
82
83#[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#[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#[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#[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("&"),
198 '<' => out.push_str("<"),
199 '>' => out.push_str(">"),
200 '"' => out.push_str("""),
201 '\'' => out.push_str("'"),
202 other => out.push(other),
203 }
204 }
205 out
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[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 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 #[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 #[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 #[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 #[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 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}