1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
//! Shared terminal presentation: colour detection, human-readable sizes, aligned
//! columns, severity styling, and unified error rendering.
//!
//! Colour is enabled only when stdout is a terminal, the `NO_COLOR` environment variable
//! is unset, and `--no-color` was not passed. When colour is off every method below
//! emits plain text, so piped or CI output stays parseable and byte-identical to the
//! uncoloured baseline.
//!
//! `--verbose` traces (formats detected, handlers, timings) go to stderr so they never
//! contaminate the stdout stream.
use anyhow::Error;
use std::io::IsTerminal;
/// Whether colour (and other terminal escapes) should be emitted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Style {
color: bool,
}
impl Style {
/// Detect colour support once at program start. Colour is on when:
/// - `--no-color` was not passed, **and**
/// - `NO_COLOR` is unset, **and**
/// - stdout or stderr is a terminal.
pub fn detect(no_color: bool) -> Self {
let stdout_tty = std::io::stdout().is_terminal();
let stderr_tty = std::io::stderr().is_terminal();
let no_color_env = std::env::var_os("NO_COLOR").is_some();
let color = !no_color && !no_color_env && (stdout_tty || stderr_tty);
Style { color }
}
/// Force colour on or off. Tests need this; nothing else should.
#[cfg(test)]
pub fn fixed(color: bool) -> Self {
Style { color }
}
/// Whether colour will be emitted. Callers use this to decide whether to build a
/// plain-text or coloured string at all, instead of styling and stripping later.
pub fn color(self) -> bool {
self.color
}
/// Wrap `s` in the given ANSI colour code when colour is enabled, otherwise return `s`
/// unchanged so piped output has no escape sequences.
pub fn colorize(self, code: &str, s: &str) -> String {
if self.color {
format!("\x1b[{code}m{s}\x1b[0m")
} else {
s.to_string()
}
}
}
// ---------------------------------------------------------------------------
// Human-readable byte sizes
// ---------------------------------------------------------------------------
/// Format a byte count as a human-readable string: `1234567` → `1.2 MiB`.
/// Uses binary units (1024-based). Values under 1024 B are shown as `N bytes`.
pub fn human_size(bytes: u64) -> String {
const UNITS: &[(&str, u64)] = &[
("TiB", 1 << 40),
("GiB", 1 << 30),
("MiB", 1 << 20),
("KiB", 1 << 10),
];
if bytes < 1024 {
return format!("{bytes} bytes");
}
for (unit, threshold) in UNITS {
if bytes >= *threshold {
let val = bytes as f64 / *threshold as f64;
let s = format!("{val:.1}");
return format!("{} {}", s.trim_end_matches(".0"), unit);
}
}
format!("{bytes} bytes")
}
// ---------------------------------------------------------------------------
// Severity styling and the shared renderer
// ---------------------------------------------------------------------------
/// How serious a finding is. Ordered so `max` gives the worst of a set.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Info,
Warn,
Error,
}
impl Severity {
/// The lowercase label used in JSON and in the left-hand column of text output.
pub fn label(self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warn => "warn",
Severity::Error => "error",
}
}
/// A colour code suited to this severity.
fn code(self) -> &'static str {
match self {
Severity::Info => "36", // cyan
Severity::Warn => "33", // yellow
Severity::Error => "31", // red
}
}
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
/// Everything that prints: colour decisions, sizing, severity styling and error layout.
#[derive(Debug, Clone, Copy)]
pub struct Renderer {
pub style: Style,
}
impl Renderer {
pub fn new(style: Style) -> Self {
Renderer { style }
}
/// Colour `s` according to `severity`.
pub fn severity(self, sev: Severity, s: &str) -> String {
self.style.colorize(sev.code(), s)
}
/// Render an error the same way from every command: a one-line headline, then the
/// cause chain indented beneath it. Chains longer than [`MAX_CAUSES`] lines are
/// truncated so a pathological error cannot flood the terminal.
pub fn error(self, err: &Error) -> String {
const MAX_CAUSES: usize = 6;
let mut out = self.style.colorize("1;31", &err.to_string());
let mut causes: Vec<String> = err.chain().skip(1).map(|c| c.to_string()).collect();
if causes.len() > MAX_CAUSES {
causes.truncate(MAX_CAUSES);
causes.push(format!(
"... and {} more",
err.chain().count() - 1 - MAX_CAUSES
));
}
for cause in causes {
out.push_str(&format!("\n caused by: {cause}"));
}
out
}
}
/// Strip ANSI escape sequences, so a caller can measure or compare plain text.
#[cfg(test)]
pub fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
for c2 in chars.by_ref() {
if c2 == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn human_sizes_are_binary_and_trim_trailing_zeros() {
assert_eq!(human_size(0), "0 bytes");
assert_eq!(human_size(1023), "1023 bytes");
assert_eq!(human_size(1024), "1 KiB");
assert_eq!(human_size(1536), "1.5 KiB");
assert_eq!(human_size(1048576), "1 MiB");
assert_eq!(human_size(1073741824), "1 GiB");
}
#[test]
fn colour_off_emits_no_escape_sequences() {
let s = Style::fixed(false);
assert!(!s.color());
let r = Renderer::new(s);
assert_eq!(r.severity(Severity::Error, "boom"), "boom");
assert_eq!(r.error(&anyhow::anyhow!("boom")), "boom");
}
#[test]
fn colour_on_wraps_and_strips_cleanly() {
let r = Renderer::new(Style::fixed(true));
let painted = r.severity(Severity::Error, "boom");
assert!(painted.contains("\x1b["));
assert_eq!(strip_ansi(&painted), "boom");
}
#[test]
fn severity_orders_from_info_to_error() {
assert!(Severity::Error > Severity::Warn);
assert!(Severity::Warn > Severity::Info);
assert_eq!(Severity::Warn.to_string(), "warn");
}
#[test]
fn an_error_renders_its_cause_chain_indented() {
let r = Renderer::new(Style::fixed(false));
let err = anyhow::anyhow!("outer").context("middle").context("inner");
let text = r.error(&err);
assert!(text.starts_with("inner"), "{text}");
assert!(text.contains(" caused by: middle"), "{text}");
assert!(text.contains(" caused by: outer"), "{text}");
}
}