pub(crate) use choreo_sanitize::{MAX_TOOL_OUTPUT_BYTES, finish_tool_output, truncate_tool_output};
use choreo_sanitize::{is_non_joiner_format_char, is_unsafe_unicode};
use std::borrow::Cow;
fn is_plain_ascii(text: &str, keep_tabs: bool) -> bool {
text.bytes()
.all(|b| (b == b'\t' && keep_tabs) || (0x20..=0x7e).contains(&b))
}
pub(crate) fn sanitize_text(text: &str, keep_tabs: bool) -> String {
if is_plain_ascii(text, keep_tabs) {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if sanitize_keeps(c, keep_tabs) {
out.push(c);
} else {
out.extend(c.escape_default());
}
}
out
}
fn sanitize_keeps(c: char, keep_tabs: bool) -> bool {
if c.is_ascii() {
return (c == '\t' && keep_tabs) || (' '..='~').contains(&c);
}
!c.is_control() && !is_unsafe_unicode(c)
}
pub(crate) fn sanitize_text_len(text: &str, keep_tabs: bool) -> usize {
if is_plain_ascii(text, keep_tabs) {
return text.len();
}
text.chars()
.map(|c| {
if sanitize_keeps(c, keep_tabs) {
c.len_utf8()
} else {
c.escape_default().count()
}
})
.sum()
}
pub(crate) fn sanitize_name(name: &str) -> String {
sanitize_text(name, false)
}
pub(crate) fn sanitize_content(content: &str) -> String {
sanitize_text(content, true)
}
pub(crate) fn sanitize_transcript(text: &str) -> Cow<'_, str> {
if text
.bytes()
.all(|b| b.is_ascii() && (b >= 0x20 || matches!(b, b'\n' | b'\t' | b'\r')))
{
return Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if is_non_joiner_format_char(c) {
out.extend(c.escape_default());
} else {
out.push(c);
}
}
Cow::Owned(out)
}
pub(crate) fn finish_tool_output_sanitized(body: &str, marker: Option<String>) -> String {
finish_tool_output(&sanitize_transcript(body), marker)
}
pub(crate) fn sanitize_multiline(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for (i, line) in text.split('\n').enumerate() {
if i > 0 {
out.push('\n');
}
let line = line.strip_suffix('\r').unwrap_or(line);
out.push_str(&sanitize_text(line, true));
}
out
}
pub(crate) fn truncation_marker(truncated: bool, cap: usize, noun: &str) -> Option<String> {
truncated.then(|| format!("...[truncated at {cap} {noun}]"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_name_escapes_control_chars() {
assert_eq!(sanitize_name("plain.txt"), "plain.txt");
assert_eq!(sanitize_name("a\nb"), "a\\nb");
assert_eq!(sanitize_name("a\tb"), "a\\tb");
}
#[test]
fn sanitize_name_escapes_unicode_separators_and_format_chars() {
assert_eq!(sanitize_name("a\u{2028}b"), "a\\u{2028}b");
assert_eq!(sanitize_name("a\u{2029}b"), "a\\u{2029}b");
assert_eq!(sanitize_name("a\u{200e}b"), "a\\u{200e}b");
assert_eq!(sanitize_name("a\u{200f}b"), "a\\u{200f}b");
assert_eq!(sanitize_name("a\u{061c}b"), "a\\u{61c}b");
assert_eq!(sanitize_name("a\u{202e}b"), "a\\u{202e}b");
assert_eq!(sanitize_name("a\u{2066}b"), "a\\u{2066}b");
assert_eq!(sanitize_name("a\u{200b}b"), "a\\u{200b}b");
assert_eq!(sanitize_name("a\u{2060}b"), "a\\u{2060}b");
assert_eq!(sanitize_name("a\u{feff}b"), "a\\u{feff}b");
assert_eq!(sanitize_name("a\u{180e}b"), "a\\u{180e}b");
assert_eq!(sanitize_name("a\u{200c}b"), "a\u{200c}b");
assert_eq!(sanitize_name("a\u{200d}b"), "a\u{200d}b");
assert_eq!(sanitize_name("café"), "café");
}
#[test]
fn sanitize_content_keeps_tabs_but_escapes_separators_and_format_chars() {
assert_eq!(sanitize_content("a\tb"), "a\tb");
assert_eq!(sanitize_content("a\nb"), "a\\nb");
assert_eq!(sanitize_content("a\u{2028}b"), "a\\u{2028}b");
assert_eq!(sanitize_content("a\u{2029}b"), "a\\u{2029}b");
assert_eq!(sanitize_content("a\u{200f}b"), "a\\u{200f}b");
assert_eq!(sanitize_content("a\u{202e}b"), "a\\u{202e}b");
assert_eq!(sanitize_content("a\u{1b}b"), "a\\u{1b}b");
assert_eq!(sanitize_content("a\u{200b}b"), "a\\u{200b}b");
assert_eq!(sanitize_content("a\u{2060}b"), "a\\u{2060}b");
assert_eq!(sanitize_content("a\u{feff}b"), "a\\u{feff}b");
assert_eq!(sanitize_content("a\u{180e}b"), "a\\u{180e}b");
assert_eq!(sanitize_content("a\u{200c}b"), "a\u{200c}b");
}
#[test]
fn sanitize_multiline_preserves_structural_newlines() {
assert_eq!(sanitize_multiline("a\nb\nc"), "a\nb\nc");
assert_eq!(sanitize_multiline("a\n\nb"), "a\n\nb");
}
#[test]
fn sanitize_multiline_folds_crlf() {
assert_eq!(sanitize_multiline("a\r\nb"), "a\nb");
assert_eq!(sanitize_multiline("a\r\nb\r\n"), "a\nb\n");
}
#[test]
fn sanitize_multiline_preserves_trailing_newline() {
assert_eq!(sanitize_multiline("a\n"), "a\n");
assert_eq!(sanitize_multiline("\n"), "\n");
}
#[test]
fn sanitize_multiline_escapes_controls_on_any_line() {
assert_eq!(
sanitize_multiline("ok\u{1b}[31m\nplain"),
"ok\\u{1b}[31m\nplain"
);
assert_eq!(sanitize_multiline("a\u{202e}b\nc"), "a\\u{202e}b\nc");
assert_eq!(sanitize_multiline("a\n\u{1b}b"), "a\n\\u{1b}b");
}
#[test]
fn sanitize_multiline_keeps_tabs_and_ascii() {
assert_eq!(sanitize_multiline("a\tb\nc\td"), "a\tb\nc\td");
assert_eq!(sanitize_multiline("plain text"), "plain text");
assert_eq!(sanitize_multiline("café\n日本語"), "café\n日本語");
}
#[test]
fn sanitize_transcript_escapes_only_format_chars() {
assert_eq!(sanitize_transcript("a\u{202e}b"), "a\\u{202e}b");
assert_eq!(sanitize_transcript("a\u{200b}b"), "a\\u{200b}b");
assert_eq!(sanitize_transcript("a\u{2066}b"), "a\\u{2066}b");
assert_eq!(sanitize_transcript("a\u{200c}b"), "a\u{200c}b");
assert_eq!(sanitize_transcript("a\u{200d}b"), "a\u{200d}b");
assert_eq!(
sanitize_transcript("\u{1b}[31mred\u{1b}[0m"),
"\u{1b}[31mred\u{1b}[0m"
);
assert_eq!(sanitize_transcript("a\nb\tc"), "a\nb\tc");
assert_eq!(sanitize_transcript("café 日本語"), "café 日本語");
assert_eq!(
sanitize_transcript("plain text\nline two"),
"plain text\nline two"
);
assert_eq!(sanitize_transcript("tail\u{feff}"), "tail\\u{feff}");
}
#[test]
fn sanitize_transcript_then_truncate_stays_within_budget() {
let raw = "\u{00ad}".repeat(super::MAX_TOOL_OUTPUT_BYTES / 2); let sanitized = sanitize_transcript(&raw);
assert!(
sanitized.len() > super::MAX_TOOL_OUTPUT_BYTES,
"escaping must expand past the raw byte cap: {} > {}",
sanitized.len(),
super::MAX_TOOL_OUTPUT_BYTES
);
let capped = truncate_tool_output(&sanitized);
assert!(
capped.len() <= super::MAX_TOOL_OUTPUT_BYTES + "\n...[truncated]".len(),
"sanitize-then-truncate must stay within budget + marker: {}",
capped.len()
);
assert!(
capped.ends_with("...[truncated]"),
"the truncation marker must survive the composition"
);
std::str::from_utf8(capped.as_bytes()).expect("capped output must be valid UTF-8");
}
#[test]
fn finish_tool_output_sanitized_tail_survives_transcript_recap() {
let footer = "[VM: exited with code 0 in 100 cycles]";
let body = "\u{00ad}".repeat(super::MAX_TOOL_OUTPUT_BYTES / 2);
let finished = super::finish_tool_output_sanitized(&body, Some(footer.to_string()));
assert!(
finished.len() <= super::MAX_TOOL_OUTPUT_BYTES,
"body + tail must stay within the budget: {} bytes",
finished.len()
);
let recapped = super::truncate_tool_output(&super::sanitize_transcript(&finished));
assert_eq!(recapped, finished, "re-sanitize + re-cap must be a no-op");
assert!(
recapped.ends_with(footer),
"footer must survive the transcript re-cap"
);
assert!(
!recapped.contains('\u{00ad}'),
"soft hyphens must be escaped, not passed through"
);
std::str::from_utf8(recapped.as_bytes()).expect("capped output must be valid UTF-8");
}
#[test]
fn sanitize_text_len_matches_actual_sanitized_length() {
for s in [
"plain ascii",
"tab\there",
"new\nline",
"esc \u{1b}[31m",
"sep\u{2028}arator",
"bidi\u{202e}evil",
"mongolian\u{180e}vowel",
"café \u{200b} zwsp",
"",
] {
assert_eq!(
sanitize_text_len(s, true),
sanitize_content(s).len(),
"{s:?}"
);
assert_eq!(sanitize_text_len(s, false), sanitize_name(s).len(), "{s:?}");
}
}
#[test]
fn sanitize_keeps_matches_policy_for_all_chars() {
for c in '\u{0}'..=char::MAX {
let is_control = c.is_control();
let is_unsafe = is_unsafe_unicode(c);
assert_eq!(
sanitize_keeps(c, false),
!is_control && !is_unsafe,
"name-policy keep drift for U+{:04X}",
c as u32
);
assert_eq!(
sanitize_keeps(c, true),
(c == '\t') || (!is_control && !is_unsafe),
"content-policy keep drift for U+{:04X}",
c as u32
);
}
}
#[test]
fn truncation_marker_only_when_capped() {
assert_eq!(truncation_marker(false, 50, "results"), None);
assert_eq!(
truncation_marker(true, 50, "results").as_deref(),
Some("...[truncated at 50 results]")
);
assert_eq!(
truncation_marker(true, 200, "matches").as_deref(),
Some("...[truncated at 200 matches]")
);
}
}