1use console::{measure_text_width, style};
14
15use crate::harness::CorpusSummary;
16use crate::report::EcsReport;
17
18const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
20
21pub fn colors_on() -> bool {
23 console::colors_enabled()
24}
25
26fn paint(s: &str, color: bool, f: impl Fn(console::StyledObject<&str>) -> console::StyledObject<&str>) -> String {
32 if color {
33 f(style(s).force_styling(true)).to_string()
34 } else {
35 s.to_string()
36 }
37}
38
39fn grade_paint(grade: char, color: bool, text: &str) -> String {
41 if !color {
42 return text.to_string();
43 }
44 let st = style(text).force_styling(true).bold();
45 match grade {
46 'L' => st.green(),
47 'N' => st.green().bright(),
48 'C' => st.cyan(),
49 'M' => st.yellow(),
50 'A' => st.magenta(),
51 _ => st.red(),
52 }
53 .to_string()
54}
55
56pub fn grade_badge(grade: char, color: bool) -> String {
58 let label = match grade {
59 '\0' => "— below floor".to_string(),
60 g => format!("ECS-{g}"),
61 };
62 grade_paint(grade, color, &label)
63}
64
65pub fn grade_short(grade: char, color: bool) -> String {
68 let label = if grade == '\0' {
69 "—".to_string()
70 } else {
71 format!("ECS-{grade}")
72 };
73 grade_paint(grade, color, &label)
74}
75
76pub fn sparkline(vals: &[f64]) -> String {
81 if vals.is_empty() {
82 return String::new();
83 }
84 let finite: Vec<f64> = vals.iter().map(|v| if v.is_finite() { *v } else { f64::NEG_INFINITY }).collect();
85 let lo = finite.iter().cloned().filter(|v| v.is_finite()).fold(f64::INFINITY, f64::min);
86 let hi = finite.iter().cloned().filter(|v| v.is_finite()).fold(f64::NEG_INFINITY, f64::max);
87 if !lo.is_finite() || !hi.is_finite() {
88 return BLOCKS[0].to_string().repeat(vals.len());
89 }
90 let span = hi - lo;
91 finite
92 .iter()
93 .map(|&v| {
94 if span <= 0.0 || !v.is_finite() {
95 BLOCKS[BLOCKS.len() / 2]
96 } else {
97 let idx = (((v - lo) / span) * (BLOCKS.len() - 1) as f64).round() as usize;
98 BLOCKS[idx.min(BLOCKS.len() - 1)]
99 }
100 })
101 .collect()
102}
103
104pub fn bar(frac: f64, width: usize) -> String {
106 let frac = frac.clamp(0.0, 1.0);
107 let filled = (frac * width as f64).round() as usize;
108 let filled = filled.min(width);
109 let mut s = String::with_capacity(width * 3);
110 for _ in 0..filled {
111 s.push('█');
112 }
113 for _ in filled..width {
114 s.push('░');
115 }
116 s
117}
118
119pub fn boxed(title: &str, body: &[String], color: bool) -> String {
123 let title_w = measure_text_width(title);
124 let content_w = body.iter().map(|l| measure_text_width(l)).max().unwrap_or(0);
125 let inner = content_w.max(title_w).max(20);
126
127 let titled = paint(title, color, |s| s.bold());
128 let mut out = String::new();
129 out.push_str("╭─ ");
131 out.push_str(&titled);
132 out.push(' ');
133 let used = 3 + title_w + 1;
134 let total = inner + 4; for _ in used..total.max(used) {
136 out.push('─');
137 }
138 out.push_str("╮\n");
139 for line in body {
140 let pad = inner - measure_text_width(line);
141 out.push_str("│ ");
142 out.push_str(line);
143 for _ in 0..pad {
144 out.push(' ');
145 }
146 out.push_str(" │\n");
147 }
148 out.push('╰');
149 for _ in 0..inner + 2 {
150 out.push('─');
151 }
152 out.push('╯');
153 out
154}
155
156pub fn render_report(rep: &EcsReport, color: bool) -> String {
158 let badge = grade_badge(rep.grade, color);
159 let compliant = if rep.passed() {
160 paint("COMPLIANT", color, |s| s.green().bold())
161 } else {
162 paint("NON-COMPLIANT", color, |s| s.red().bold())
163 };
164 let dim = |s: &str| paint(s, color, |x| x.dim());
165
166 let mut body = Vec::new();
167 body.push(format!("{} {} {}", dim("grade"), badge, compliant));
168 body.push(format!(
169 "{} {:>9.2}:1 {} {:>7.3}% {} {:>7.4}",
170 dim("CR "),
171 rep.cr,
172 dim("PRD"),
173 rep.prd,
174 dim("R"),
175 rep.r
176 ));
177 body.push(format!(
178 "{} {:>7.2} dB {} {:>9.2} MiB/s {} {:>6.2} MiB",
179 dim("SNR "),
180 rep.snr_db,
181 dim("thrpt"),
182 rep.throughput_mibs,
183 dim("peak"),
184 rep.peak_bytes as f64 / (1024.0 * 1024.0),
185 ));
186 let band_r: Vec<f64> = rep.per_band.iter().map(|b| b.r).collect();
188 if !band_r.is_empty() {
189 let names: String = rep
190 .per_band
191 .iter()
192 .map(|b| b.band.chars().next().unwrap_or('?'))
193 .collect();
194 body.push(format!("{} {} {}", dim("band R"), sparkline(&band_r), dim(&names)));
195 }
196 if !rep.violations.is_empty() {
197 body.push(dim("to climb a tier:"));
198 for v in &rep.violations {
199 body.push(format!(" {} {}", paint("·", color, |s| s.red()), v));
200 }
201 }
202
203 let title = format!(
204 "OpenECS report · {} · {} ({} file{})",
205 rep.codec,
206 rep.dataset,
207 rep.n_files,
208 if rep.n_files == 1 { "" } else { "s" }
209 );
210 boxed(&title, &body, color)
211}
212
213pub fn render_leaderboard(reports: &[&EcsReport], color: bool) -> String {
216 let dim = |s: &str| paint(s, color, |x| x.dim());
217 let mut s = String::new();
218 s.push_str(&paint("OpenECS leaderboard (best first)\n", color, |x| x.bold()));
219 s.push_str(&dim(" # codec grade CR PRD% R QS\n"));
220 s.push_str(&dim(" ─────────────────────────────────────────────────────────────────\n"));
221 if reports.is_empty() {
222 s.push_str(" (no codecs)\n");
223 return s;
224 }
225 for (i, r) in reports.iter().enumerate() {
226 let g = if r.grade == '\0' {
227 grade_paint('\0', color, " — ")
228 } else {
229 grade_paint(r.grade, color, &format!("ECS-{}", r.grade))
230 };
231 s.push_str(&format!(
232 " {:>2} {:<22} {:>6} {:>7.2} {:>7.3} {:>8.4} {:>8.3}\n",
233 i + 1,
234 truncate(&r.codec, 22),
235 g,
236 r.cr,
237 r.prd,
238 r.r,
239 r.qs,
240 ));
241 }
242 s
243}
244
245pub fn render_corpus_summary(summary: &CorpusSummary, color: bool) -> String {
247 let dim = |s: &str| paint(s, color, |x| x.dim());
248 let worst = grade_badge(summary.worst_grade, color);
249 format!(
250 "{} {}\n{} {:.2}:1 {} {:.4} {} {:.3}% {} {}",
251 dim("worst grade"),
252 worst,
253 dim("pooled CR"),
254 summary.mean_cr,
255 dim("mean R"),
256 summary.mean_r,
257 dim("mean PRD"),
258 summary.mean_prd,
259 dim("bit-exact"),
260 summary.all_bit_exact,
261 )
262}
263
264fn truncate(s: &str, n: usize) -> String {
266 if s.chars().count() <= n {
267 s.to_string()
268 } else {
269 let mut t: String = s.chars().take(n.saturating_sub(1)).collect();
270 t.push('…');
271 t
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::report::BandResult;
279
280 fn rep() -> EcsReport {
281 EcsReport {
282 spec_version: "1.0".into(),
283 codec: "store".into(),
284 dataset: "ecs-smoke".into(),
285 n_files: 3,
286 bit_exact: true,
287 grade: 'L',
288 cr: 24.57,
289 prd: 0.0,
290 prdn: 0.0,
291 r: 1.0,
292 snr_db: 120.0,
293 qs: 24.57,
294 per_band: vec![
295 BandResult::new("delta", 1.0, 0.0, 120.0),
296 BandResult::new("gamma", 0.9, 5.0, 30.0),
297 ],
298 throughput_mibs: 88.5,
299 peak_bytes: 2 * 1024 * 1024,
300 violations: vec![],
301 }
302 }
303
304 #[test]
305 fn sparkline_maps_range() {
306 let s = sparkline(&[0.0, 1.0, 2.0, 3.0]);
307 assert_eq!(s.chars().count(), 4);
308 assert_eq!(s.chars().next(), Some('▁'));
309 assert_eq!(s.chars().last(), Some('█'));
310 assert_eq!(sparkline(&[5.0, 5.0]).chars().count(), 2);
312 assert!(sparkline(&[]).is_empty());
313 }
314
315 #[test]
316 fn bar_fills_proportionally() {
317 assert_eq!(bar(0.0, 4), "░░░░");
318 assert_eq!(bar(1.0, 4), "████");
319 assert_eq!(bar(0.5, 4), "██░░");
320 assert_eq!(bar(2.0, 4), "████"); }
322
323 #[test]
324 fn plain_report_has_no_ansi() {
325 let out = render_report(&rep(), false);
326 assert!(!out.contains('\u{1b}'), "color=false must emit no ANSI escapes");
327 assert!(out.contains("ECS-L"));
328 assert!(out.contains("store"));
329 assert!(out.contains("ecs-smoke"));
330 assert!(out.contains('╭') && out.contains('╰'));
332 }
333
334 #[test]
335 fn colored_report_has_ansi() {
336 let out = render_report(&rep(), true);
337 assert!(out.contains('\u{1b}'), "color=true should emit ANSI escapes");
338 }
339
340 #[test]
341 fn leaderboard_plain_lists_rows() {
342 let r = rep();
343 let out = render_leaderboard(&[&r], false);
344 assert!(out.contains("leaderboard"));
345 assert!(out.contains("store"));
346 assert!(!out.contains('\u{1b}'));
347 assert!(render_leaderboard(&[], false).contains("(no codecs)"));
348 }
349}