use colored::Colorize;
use serde::Serialize;
use crate::squeeze::{squeeze, Squeezed};
pub const JSON_SCHEMA_SQUEEZE: &str = "ast-bro.squeeze.v1";
pub struct SqueezeReport<'a> {
pub path: &'a str,
pub range: Option<(usize, usize)>,
pub raw: &'a str,
pub raw_requested: bool,
}
struct Emit {
squeezed: bool,
body: String,
legend: Vec<(String, String)>,
raw_bytes: usize,
emitted_bytes: usize,
}
const LEGEND_MARKER: &str = "# legend:\n";
fn legend_block(legend: &[(String, String)]) -> String {
let mut s = String::new();
for (tag, value) in legend {
s.push_str("# ");
s.push_str(tag);
s.push_str(" = ");
s.push_str(value);
s.push('\n');
}
s
}
fn decide(r: &SqueezeReport) -> Emit {
let raw_bytes = r.raw.len();
if r.raw_requested {
return Emit {
squeezed: false,
body: r.raw.to_string(),
legend: Vec::new(),
raw_bytes,
emitted_bytes: raw_bytes,
};
}
let Squeezed { body, legend } = squeeze(r.raw);
let legend_bytes = LEGEND_MARKER.len() + legend_block(&legend).len();
let squeezed_total_bytes = legend_bytes + body.len();
if legend.is_empty() || squeezed_total_bytes >= raw_bytes {
Emit {
squeezed: false,
body: r.raw.to_string(),
legend: Vec::new(),
raw_bytes,
emitted_bytes: raw_bytes,
}
} else {
Emit {
squeezed: true,
body,
legend,
raw_bytes,
emitted_bytes: squeezed_total_bytes,
}
}
}
fn fmt_bytes(n: usize) -> String {
const KB: f64 = 1000.0;
const MB: f64 = 1000.0 * 1000.0;
let f = n as f64;
if f < KB {
format!("{}B", n)
} else if f < MB {
format!("{:.1}KB", f / KB)
} else {
format!("{:.1}MB", f / MB)
}
}
fn savings_pct(raw_bytes: usize, emitted_bytes: usize) -> f64 {
if raw_bytes == 0 {
return 0.0;
}
(1.0 - (emitted_bytes as f64 / raw_bytes as f64)) * 100.0
}
pub fn slice_lines(text: &str, range: Option<(usize, usize)>) -> String {
let (start, end) = match range {
None => return text.to_string(),
Some(r) => r,
};
let start = start.max(1);
if end < start {
return String::new();
}
text.split_inclusive('\n')
.skip(start - 1)
.take(end - start + 1)
.collect()
}
pub fn render_text(r: &SqueezeReport) -> String {
let e = decide(r);
let header_plain = if e.squeezed {
let pct = savings_pct(e.raw_bytes, e.emitted_bytes);
format!(
"# {} [squeezed {} -> {}, {:.1}%]",
r.path,
fmt_bytes(e.raw_bytes),
fmt_bytes(e.emitted_bytes),
-pct, )
} else if r.raw_requested {
format!("# {} [raw {}]", r.path, fmt_bytes(e.raw_bytes))
} else {
format!(
"# {} [raw {}; squeeze would be larger, emitting original]",
r.path,
fmt_bytes(e.raw_bytes),
)
};
let header = header_plain.truecolor(150, 150, 150).to_string();
let mut out = String::new();
out.push_str(&header);
out.push('\n');
if e.squeezed && !e.legend.is_empty() {
out.push_str(LEGEND_MARKER);
out.push_str(&legend_block(&e.legend));
}
out.push_str("---\n");
out.push_str(&e.body);
out
}
#[derive(Serialize)]
struct JsonSqueezeDoc<'a> {
schema: &'static str,
path: &'a str,
range: Option<JsonRange>,
raw_bytes: usize,
squeezed_bytes: usize,
savings_pct: f64,
emitted: &'static str,
legend: Vec<JsonLegendEntry<'a>>,
body: &'a str,
}
#[derive(Serialize)]
struct JsonRange {
start: usize,
end: usize,
}
#[derive(Serialize)]
struct JsonLegendEntry<'a> {
tag: &'a str,
value: &'a str,
}
pub fn render_json(r: &SqueezeReport, pretty: bool) -> String {
let e = decide(r);
let pct = (savings_pct(e.raw_bytes, e.emitted_bytes) * 10.0).round() / 10.0;
let doc = JsonSqueezeDoc {
schema: JSON_SCHEMA_SQUEEZE,
path: r.path,
range: r.range.map(|(start, end)| JsonRange { start, end }),
raw_bytes: e.raw_bytes,
squeezed_bytes: e.emitted_bytes,
savings_pct: pct,
emitted: if e.squeezed { "squeezed" } else { "raw" },
legend: e
.legend
.iter()
.map(|(tag, value)| JsonLegendEntry { tag, value })
.collect(),
body: &e.body,
};
let res = if pretty {
serde_json::to_string_pretty(&doc)
} else {
serde_json::to_string(&doc)
};
res.unwrap_or_else(|err| serde_json::json!({ "error": err.to_string() }).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tiny_input_falls_back_to_raw() {
let raw = "hi\n";
let r = SqueezeReport {
path: "tiny.log",
range: None,
raw,
raw_requested: false,
};
let txt = render_text(&r);
assert!(
txt.contains("squeeze would be larger") || txt.contains("[raw "),
"tiny input should report raw fallback, got:\n{txt}"
);
assert!(txt.ends_with("---\nhi\n"));
}
#[test]
fn marginal_gain_does_not_falsely_claim_squeezed() {
let raw = "xy12 xy12 zz\n".repeat(3);
let r = SqueezeReport {
path: "marginal.log",
range: None,
raw: &raw,
raw_requested: false,
};
let json = render_json(&r, false);
assert!(
!json.contains("\"emitted\":\"squeezed\""),
"marginal input falsely claimed a squeeze win: {json}"
);
}
#[test]
fn raw_requested_skips_compression() {
let raw = "ab\n".repeat(500);
let r = SqueezeReport {
path: "forced.log",
range: None,
raw: &raw,
raw_requested: true,
};
let json = render_json(&r, false);
assert!(json.contains("\"emitted\":\"raw\""));
assert!(json.contains("\"legend\":[]"));
let txt = render_text(&r);
assert!(txt.contains("[raw "));
assert!(!txt.contains("# legend:"));
}
#[test]
fn repetitive_input_emits_squeezed() {
let line = "2026-05-30T11:54:19.557 [WinFocusMonitor] hwnd=0x1234 focus changed event\n";
let raw = line.repeat(200);
let r = SqueezeReport {
path: "app.log",
range: None,
raw: &raw,
raw_requested: false,
};
let txt = render_text(&r);
assert!(
txt.contains("[squeezed "),
"repetitive input should squeeze, got header in:\n{}",
txt.lines().next().unwrap_or("")
);
assert!(txt.contains("# legend:"), "squeezed output must have a legend");
let json = render_json(&r, false);
assert!(json.contains("\"emitted\":\"squeezed\""));
assert!(json.contains(JSON_SCHEMA_SQUEEZE));
assert!(
json.contains("ast-bro.squeeze.v1"),
"JSON must carry the schema id"
);
}
#[test]
fn json_always_contains_schema() {
let r = SqueezeReport {
path: "x.log",
range: Some((1, 1)),
raw: "only one line, no newline",
raw_requested: false,
};
let pretty = render_json(&r, true);
assert!(pretty.contains(JSON_SCHEMA_SQUEEZE));
let compact = render_json(&r, false);
assert!(!compact.contains('\n'));
assert!(pretty.contains('\n'));
assert!(compact.contains("\"range\":{\"start\":1,\"end\":1}"));
}
#[test]
fn slice_lines_clamps_and_is_inclusive() {
let text = "a\nb\nc\nd\n";
assert_eq!(slice_lines(text, None), text);
assert_eq!(slice_lines(text, Some((2, 3))), "b\nc\n");
assert_eq!(slice_lines(text, Some((3, 999))), "c\nd\n");
assert_eq!(slice_lines(text, Some((10, 20))), "");
assert_eq!(slice_lines("x\ny\nz", Some((3, 3))), "z");
}
#[test]
fn fmt_bytes_units() {
assert_eq!(fmt_bytes(412), "412B");
assert_eq!(fmt_bytes(45_000), "45.0KB");
assert_eq!(fmt_bytes(1_200_000), "1.2MB");
}
}