use std::fmt::Write as _;
pub const DEFAULT_MIN_SAVINGS: usize = 128;
pub const NORMALIZE_TOOLS: &[&str] = &["bash", "shell", "exec_command"];
type Row = Vec<char>;
struct Screen {
rows: Vec<Row>,
row: usize,
col: usize,
}
impl Screen {
fn new() -> Self {
Screen {
rows: vec![Vec::new()],
row: 0,
col: 0,
}
}
fn ensure_row(&mut self, r: usize) {
while self.rows.len() <= r {
self.rows.push(Vec::new());
}
}
fn write_char(&mut self, c: char) {
let row = &mut self.rows[self.row];
match self.col.cmp(&row.len()) {
std::cmp::Ordering::Less => row[self.col] = c,
std::cmp::Ordering::Equal => row.push(c),
std::cmp::Ordering::Greater => {
row.resize(self.col, ' ');
row.push(c);
}
}
self.col += 1;
}
fn write_str(&mut self, s: &str) {
for c in s.chars() {
self.write_char(c);
}
}
fn carriage_return(&mut self) {
self.col = 0;
}
fn line_feed(&mut self) {
self.row += 1;
self.ensure_row(self.row);
self.col = 0;
}
fn cursor_up(&mut self, n: usize) {
self.row = self.row.saturating_sub(n);
}
fn cursor_down(&mut self, n: usize) {
self.row = (self.row + n).min(self.rows.len().saturating_sub(1));
self.ensure_row(self.row);
}
fn cursor_col_absolute(&mut self, n: usize) {
self.col = n.saturating_sub(1);
}
fn erase_line(&mut self, param: u32) {
let row = &mut self.rows[self.row];
match param {
0 => row.truncate(self.col.min(row.len())),
1 => {
let end = (self.col + 1).min(row.len());
for cell in row.iter_mut().take(end) {
*cell = ' ';
}
}
_ => row.clear(),
}
}
fn render(&self) -> String {
self.rows
.iter()
.map(|r| r.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
}
fn is_csi_param_byte(b: u8) -> bool {
(0x30..=0x3F).contains(&b)
}
fn is_csi_final_byte(b: u8) -> bool {
(0x40..=0x7E).contains(&b)
}
fn first_param(params: &str) -> Option<u32> {
let digits: String = params
.split(&[';', ':'][..])
.next()
.unwrap_or("")
.chars()
.filter(|c| c.is_ascii_digit())
.collect();
if digits.is_empty() {
None
} else {
digits.parse().ok()
}
}
pub fn normalize(input: &str) -> String {
let bytes = input.as_bytes();
let mut screen = Screen::new();
let mut i = 0usize;
let n = bytes.len();
while i < n {
match bytes[i] {
b'\r' => {
screen.carriage_return();
i += 1;
}
b'\n' => {
screen.line_feed();
i += 1;
}
0x1B => {
if i + 1 < n && bytes[i + 1] == b'[' {
i = consume_csi(input, &mut screen, i);
} else if i + 1 < n && bytes[i + 1] == b']' {
i = consume_osc(input, &mut screen, i);
} else {
screen.write_char('\u{1B}');
i += 1;
}
}
_ => {
let start = i;
while i < n && !matches!(bytes[i], b'\r' | b'\n' | 0x1B) {
i += 1;
}
screen.write_str(&input[start..i]);
}
}
}
screen.render()
}
fn consume_csi(input: &str, screen: &mut Screen, esc_pos: usize) -> usize {
let bytes = input.as_bytes();
let n = bytes.len();
let params_start = esc_pos + 2; let mut j = params_start;
while j < n && is_csi_param_byte(bytes[j]) {
j += 1;
}
if j >= n || !is_csi_final_byte(bytes[j]) {
screen.write_str(&input[esc_pos..]);
return n;
}
let params = &input[params_start..j];
let final_byte = bytes[j];
let private = params.starts_with('?');
match final_byte {
b'm' => {} b'K' => screen.erase_line(first_param(params).unwrap_or(0)),
b'A' => screen.cursor_up(first_param(params).unwrap_or(1).max(1) as usize),
b'B' => screen.cursor_down(first_param(params).unwrap_or(1).max(1) as usize),
b'G' => screen.cursor_col_absolute(first_param(params).unwrap_or(1) as usize),
b'h' | b'l' if private => {
}
_ => {
screen.write_str(&input[esc_pos..=j]);
}
}
j + 1
}
fn consume_osc(input: &str, screen: &mut Screen, esc_pos: usize) -> usize {
let bytes = input.as_bytes();
let n = bytes.len();
let mut j = esc_pos + 2; while j < n {
if bytes[j] == 0x07 {
screen.write_str(&input[esc_pos..=j]);
return j + 1;
}
if bytes[j] == 0x1B && j + 1 < n && bytes[j + 1] == b'\\' {
screen.write_str(&input[esc_pos..=(j + 1)]);
return j + 2;
}
j += 1;
}
screen.write_str(&input[esc_pos..]);
n
}
pub fn summary(original_bytes: usize, normalized_bytes: usize) -> String {
let mut s = String::new();
let _ = write!(
s,
"ANSI/redraw collapsed, {}B -> {}B - raw output in session sidecar",
crate::reduce::format_commas(original_bytes),
crate::reduce::format_commas(normalized_bytes),
);
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_sgr_color_and_style() {
let input = "\x1b[1m\x1b[32m Compiling\x1b[0m serde v1.0.0\r\n";
assert_eq!(normalize(input), " Compiling serde v1.0.0\n");
}
#[test]
fn cr_overwrite_collapses_to_last_frame() {
let input = "Progress: 10%\rProgress: 55%\rProgress: 100% done";
assert_eq!(normalize(input), "Progress: 100% done");
}
#[test]
fn cr_overwrite_shorter_frame_leaves_stale_tail_untouched() {
let input = "AAAAAAAAAA\rBB";
assert_eq!(normalize(input), "BBAAAAAAAA");
}
#[test]
fn el0_erase_to_end_then_overwrite_prefix() {
let input = "hello world\r\x1b[0Khi";
assert_eq!(normalize(input), "hi");
}
#[test]
fn el2_erases_whole_line_regardless_of_cursor() {
let input = "some stale content\x1b[5G\x1b[2Kfresh";
assert_eq!(normalize(input), " fresh");
}
#[test]
fn el_bare_defaults_to_param_zero() {
let input = "keep\x1b[3G\x1b[K?";
assert_eq!(normalize(input), "ke?");
}
#[test]
fn cursor_up_multiline_redraw_collapses_to_final_frame() {
let input = "layer-1: 10%\nlayer-2: 20%\n\x1b[2A\rlayer-1: 100%\x1b[K\x1b[2B";
assert_eq!(normalize(input), "layer-1: 100%\nlayer-2: 20%\n");
}
#[test]
fn cha_moves_to_absolute_column_like_npm_spinner() {
let input = "\u{280f}\x1b[1G\x1b[0Kdone";
assert_eq!(normalize(input), "done");
}
#[test]
fn cursor_hide_show_stripped_silently() {
let input = "\x1b[?25lworking\x1b[?25h";
assert_eq!(normalize(input), "working");
}
#[test]
fn unknown_csi_sequence_passes_through_verbatim() {
let input = "before\x1b[6nafter";
assert_eq!(normalize(input), "before\x1b[6nafter");
}
#[test]
fn unknown_osc_sequence_passes_through_verbatim() {
let input = "\x1b]0;window title\x07visible";
assert_eq!(normalize(input), "\x1b]0;window title\x07visible");
}
#[test]
fn plain_text_is_byte_identical() {
for input in [
"no escapes here at all\nsecond line\n",
"single line, no trailing newline",
"",
"unicode: caf\u{e9}, \u{1f980}, \u{4e2d}\u{6587}\n",
] {
assert_eq!(normalize(input), input, "input={input:?}");
}
}
#[test]
fn idempotent_on_already_normalized_text() {
let cases = [
"\x1b[1m\x1b[32m Compiling\x1b[0m serde v1.0.0\r\n",
"Progress: 10%\rProgress: 55%\rProgress: 100% done",
"layer-1: 10%\nlayer-2: 20%\n\x1b[2A\rlayer-1: 100%\x1b[K\x1b[2B",
"before\x1b[6nafter",
];
for input in cases {
let once = normalize(input);
let twice = normalize(&once);
assert_eq!(once, twice, "not idempotent for input={input:?}");
}
}
#[test]
fn deterministic_across_repeated_runs() {
let input = "\x1b[1mA\x1b[0m\rB\x1b[Khello\x1b[2Ax\x1b[2B\x1b[?25lY\x1b[?25h";
let first = normalize(input);
for _ in 0..20 {
assert_eq!(normalize(input), first);
}
}
#[test]
fn truncated_trailing_csi_does_not_panic() {
let full = "hello\x1b[1;32mworld\x1b[0m\r\nmore\x1b[38;5;196m!!";
for end in 0..=full.len() {
if !full.is_char_boundary(end) {
continue;
}
let slice = &full[..end];
let _ = normalize(slice); }
}
#[test]
fn truncated_trailing_osc_does_not_panic() {
let full = "before\x1b]0;some long title that never terminates";
for end in 0..=full.len() {
if !full.is_char_boundary(end) {
continue;
}
let _ = normalize(&full[..end]);
}
}
#[test]
fn never_panics_on_malformed_input() {
let seeds: &[&str] = &[
"\x1b",
"\x1b[",
"\x1b]",
"\x1b[?",
"\x1b[;;;;",
"\x1b[999999999999999999999999999999A",
"\x1bXY\x1b[Z\x1b]nope",
"\r\r\r\r\n\n\n\x1b[K\x1b[2A\x1b[500B",
"\x1b[?25h\x1b[?25l\x1b[?1049h",
"plain \x1b[38;2;255;0;0mtruecolor\x1b[0m text",
];
for s in seeds {
let _ = normalize(s);
}
let with_unicode = "\x1b[1m\u{1f680}\x1b[0m\r\u{1f525}\x1b[K";
let _ = normalize(with_unicode);
}
#[test]
fn summary_is_plain_ascii_one_line_no_bracket() {
let s = summary(41_203, 1_876);
assert!(s.is_ascii(), "{s:?}");
assert!(!s.contains('\n'), "{s:?}");
assert!(!s.contains(']'), "{s:?}");
assert!(s.contains("41,203B"), "{s:?}");
assert!(s.contains("1,876B"), "{s:?}");
}
}