const TAB_REPLACEMENT: &str = " ";
pub fn needs_terminal_sanitization(s: &str) -> bool {
s.chars()
.any(|c| matches!(c, '\t' | '\r' | '\x1b') || (c.is_control() && c != '\n'))
}
pub fn normalize_terminal_text(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\t' => result.push_str(TAB_REPLACEMENT),
'\n' => result.push('\n'),
c if c.is_control() => { }
c => result.push(c),
}
}
result
}
pub fn sanitize_terminal_text(s: &str) -> String {
let stripped = strip_ansi_codes(s);
normalize_terminal_text(&stripped)
}
pub fn sanitize_single_line_text(s: &str) -> String {
sanitize_terminal_text(s)
.chars()
.map(|c| if c == '\n' { ' ' } else { c })
.collect()
}
pub fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
let normalized;
let text = if needs_terminal_sanitization(text) {
normalized = sanitize_terminal_text(text);
normalized.as_str()
} else {
text
};
let max_width = max_width.max(2);
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
for ch in text.chars() {
if ch == '\n' {
result.push(current_line.clone());
current_line.clear();
current_width = 0;
continue;
}
let ch_width = char_width(ch);
if current_width + ch_width > max_width && !current_line.is_empty() {
result.push(current_line.clone());
current_line.clear();
current_width = 0;
}
current_line.push(ch);
current_width += ch_width;
}
if !current_line.is_empty() {
result.push(current_line);
}
if result.is_empty() {
result.push(String::new());
}
result
}
pub fn display_width(s: &str) -> usize {
s.chars().map(char_width).sum()
}
pub fn char_width(c: char) -> usize {
if c == '\t' {
return TAB_REPLACEMENT.len();
}
if c.is_control() {
return 0;
}
use unicode_width::UnicodeWidthChar;
UnicodeWidthChar::width(c).unwrap_or(0)
}
pub fn strip_ansi_codes(s: &str) -> String {
use regex::Regex;
use std::sync::OnceLock;
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
Regex::new(r"\x1b\[[\x20-\x3f]*[\x40-\x7e]|\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b[^\[\]()]")
.expect("正则表达式编译失败,这是一个静态模式,应该总是有效的")
});
re.replace_all(s, "").into_owned()
}
pub fn sanitize_tool_output(s: &str) -> String {
sanitize_terminal_text(s)
}
pub fn remove_quotes(s: &str) -> String {
let s = s.trim();
if s.len() >= 2
&& ((s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')))
{
return s[1..s.len() - 1].to_string();
}
s.to_string()
}
#[cfg(test)]
mod tests {
use super::{
needs_terminal_sanitization, normalize_terminal_text, sanitize_single_line_text,
sanitize_terminal_text, sanitize_tool_output, wrap_text,
};
#[test]
fn needs_terminal_sanitization_detects_ansi_and_control_chars() {
assert!(needs_terminal_sanitization("a\tb"));
assert!(needs_terminal_sanitization("a\r\nb"));
assert!(needs_terminal_sanitization("\x1b[31mred\x1b[0m"));
assert!(!needs_terminal_sanitization("plain\ntext"));
}
#[test]
fn normalize_terminal_text_expands_tabs_and_removes_cr() {
assert_eq!(normalize_terminal_text("a\tb\r\nc"), "a b\nc");
}
#[test]
fn normalize_terminal_text_strips_control_chars() {
assert_eq!(
normalize_terminal_text("hello\x07world\x1b[0m"),
"helloworld[0m"
);
assert_eq!(normalize_terminal_text("\x00\x01\x02"), "");
assert_eq!(normalize_terminal_text("a\x7fb"), "ab");
}
#[test]
fn normalize_terminal_text_preserves_newline() {
assert_eq!(normalize_terminal_text("line1\nline2"), "line1\nline2");
}
#[test]
fn normalize_terminal_text_preserves_tab_expansion() {
assert_eq!(normalize_terminal_text("\titem"), " item");
}
#[test]
fn sanitize_tool_output_strips_ansi_and_controls() {
assert_eq!(sanitize_tool_output("\x1b[32mok\x1b[0m\x07"), "ok");
}
#[test]
fn sanitize_terminal_text_strips_full_ansi_sequences() {
assert_eq!(sanitize_terminal_text("a\x1b[31mred\x1b[0m\x07b"), "aredb");
}
#[test]
fn sanitize_single_line_text_flattens_newlines() {
assert_eq!(
sanitize_single_line_text("a\x1b[31mred\x1b[0m\nb"),
"ared b"
);
}
#[test]
fn wrap_text_outputs_spaces_instead_of_tabs() {
let wrapped = wrap_text("ab\tcd", 4);
assert_eq!(wrapped, vec!["ab ".to_string(), " cd".to_string()]);
assert!(wrapped.iter().all(|line| !line.contains('\t')));
}
#[test]
fn wrap_text_strips_ansi_sequences_instead_of_leaking_fragments() {
let wrapped = wrap_text("x\x1b[31mred\x1b[0my", 80);
assert_eq!(wrapped, vec!["xredy".to_string()]);
}
}