1use std::io::IsTerminal;
13use std::sync::OnceLock;
14
15const FALLBACK_WIDTH: usize = 100;
16const MIN_WIDTH: usize = 50;
17const MAX_WIDTH: usize = 200;
18const GAP: usize = 2;
19
20pub fn width() -> usize {
22 static W: OnceLock<usize> = OnceLock::new();
23 *W.get_or_init(|| {
24 if std::io::stdout().is_terminal() {
25 terminal_size::terminal_size()
26 .map(|(w, _)| (w.0 as usize).clamp(MIN_WIDTH, MAX_WIDTH))
27 .unwrap_or(FALLBACK_WIDTH)
28 } else {
29 FALLBACK_WIDTH
30 }
31 })
32}
33
34#[derive(Clone, Copy, PartialEq)]
36pub enum Style {
37 Plain,
38 Bold,
39 Dim,
40 Cyan,
41 Green,
42 Yellow,
43 Red,
44 BoldGreen,
45 BoldCyan,
46 Status,
48}
49
50fn color_enabled() -> bool {
51 static C: OnceLock<bool> = OnceLock::new();
52 *C.get_or_init(|| {
53 std::io::stdout().is_terminal()
54 && std::env::var_os("NO_COLOR").is_none()
55 && std::env::var_os("TERM").is_none_or(|t| t != "dumb")
56 })
57}
58
59pub fn paint(style: Style, s: &str) -> String {
61 if !color_enabled() || style == Style::Plain {
62 return s.to_string();
63 }
64 let code = match style {
65 Style::Plain => unreachable!(),
66 Style::Bold => "1",
67 Style::Dim => "2",
68 Style::Cyan => "36",
69 Style::Green => "32",
70 Style::Yellow => "33",
71 Style::Red => "31",
72 Style::BoldGreen => "1;32",
73 Style::BoldCyan => "1;36",
74 Style::Status => {
75 return match s.trim() {
76 "ok" | "active" => paint(Style::Green, s),
77 "warn" | "disabled" => paint(Style::Yellow, s),
78 "fail" | "error" => paint(Style::Red, s),
79 _ => s.to_string(),
80 }
81 }
82 };
83 format!("\x1b[{code}m{s}\x1b[0m")
84}
85
86pub fn title(text: &str) {
87 println!("{}", paint(Style::BoldGreen, text));
88 println!("{}", paint(Style::Green, &"=".repeat(text.chars().count())));
89}
90
91pub fn section(text: &str) {
92 println!();
93 println!("{}", paint(Style::BoldCyan, text));
94 println!("{}", paint(Style::Dim, &"-".repeat(text.chars().count())));
95}
96
97pub fn meter(label: &str, pct: f64) -> String {
100 const CELLS: usize = 20;
101 let clamped = pct.clamp(0.0, 100.0);
102 let filled = ((clamped / 100.0) * CELLS as f64).round() as usize;
103 let style = if clamped >= 60.0 {
104 Style::Green
105 } else if clamped >= 30.0 {
106 Style::Yellow
107 } else {
108 Style::Red
109 };
110 let bar = format!(
111 "{}{}",
112 paint(style, &"█".repeat(filled)),
113 paint(Style::Dim, &"░".repeat(CELLS - filled))
114 );
115 format!("{label} {bar} {}", paint(style, &format!("{clamped:.1}%")))
116}
117
118pub fn truncate_middle(s: &str, max: usize) -> String {
121 let n = s.chars().count();
122 if n <= max || max < 8 {
123 return s.to_string();
124 }
125 let keep = max - 1;
126 let head = keep / 3;
127 let tail = keep - head;
128 let head_s: String = s.chars().take(head).collect();
129 let tail_s: String = s.chars().skip(n - tail).collect();
130 format!("{head_s}…{tail_s}")
131}
132
133pub fn wrap(text: &str, max: usize) -> Vec<String> {
136 let max = max.max(8);
137 let mut lines = Vec::new();
138 let mut current = String::new();
139 let mut current_len = 0usize;
140 for word in text.split_whitespace() {
141 let mut word_chars = word.chars().count();
142 let mut word = word.to_string();
143 while word_chars > max {
145 if current_len > 0 {
146 lines.push(std::mem::take(&mut current));
147 current_len = 0;
148 }
149 let piece: String = word.chars().take(max).collect();
150 word = word.chars().skip(max).collect();
151 word_chars -= max;
152 lines.push(piece);
153 }
154 let needed = if current_len == 0 {
155 word_chars
156 } else {
157 current_len + 1 + word_chars
158 };
159 if needed > max && current_len > 0 {
160 lines.push(std::mem::take(&mut current));
161 current.push_str(&word);
162 current_len = word_chars;
163 } else {
164 if current_len > 0 {
165 current.push(' ');
166 }
167 current.push_str(&word);
168 current_len = needed;
169 }
170 }
171 if !current.is_empty() {
172 lines.push(current);
173 }
174 if lines.is_empty() {
175 lines.push(String::new());
176 }
177 lines
178}
179
180pub fn kv(rows: &[(&str, String)]) {
182 let key_w = rows
183 .iter()
184 .map(|(k, _)| k.chars().count())
185 .max()
186 .unwrap_or(0);
187 let value_w = width().saturating_sub(key_w + GAP).max(16);
188 for (key, value) in rows {
189 let lines = wrap(value, value_w);
190 let padded = format!("{key:<key_w$}");
191 println!(
192 "{}{}{}",
193 paint(Style::Dim, &padded),
194 " ".repeat(GAP),
195 lines[0]
196 );
197 for line in &lines[1..] {
198 println!("{}{line}", " ".repeat(key_w + GAP));
199 }
200 }
201}
202
203pub fn record(bullet: Style, main: &str, details: &[String]) {
207 let w = width();
208 println!(
209 "{} {}",
210 paint(bullet, "●"),
211 paint(Style::Bold, &truncate_middle(main, w.saturating_sub(2)))
212 );
213 let detail = details.join(" · ");
214 if detail.is_empty() {
215 return;
216 }
217 for line in wrap(&detail, w.saturating_sub(GAP)) {
218 println!("{}{}", " ".repeat(GAP), paint(Style::Dim, &line));
219 }
220}
221
222pub fn table(headers: &[&str], rows: &[Vec<String>]) {
225 table_styled(headers, rows, &[]);
226}
227
228pub fn table_styled(headers: &[&str], rows: &[Vec<String>], styles: &[Style]) {
231 if headers.is_empty() {
232 return;
233 }
234 let w = width();
235 let cols = headers.len();
236 let mut widths: Vec<usize> = headers.iter().map(|h| h.chars().count()).collect();
237 for row in rows {
238 for (idx, cell) in row.iter().enumerate().take(cols) {
239 widths[idx] = widths[idx].max(cell.chars().count());
240 }
241 }
242
243 let fixed: usize = widths[..cols - 1].iter().sum::<usize>() + GAP * (cols - 1);
244 let natural = fixed + widths[cols - 1];
245 if natural > w {
246 widths[cols - 1] = w
248 .saturating_sub(fixed)
249 .max(16)
250 .max(headers[cols - 1].chars().count());
251 }
252
253 let dim_all = vec![Style::Dim; cols];
254 let headers_owned: Vec<String> = headers.iter().map(|h| h.to_string()).collect();
255 print_wrapped_row(&headers_owned, &widths, &dim_all);
256 let separators: Vec<String> = widths.iter().map(|cw| "-".repeat(*cw)).collect();
257 print_wrapped_row(&separators, &widths, &dim_all);
258 for row in rows {
259 print_wrapped_row(row, &widths, styles);
260 }
261}
262
263fn print_wrapped_row(cells: &[String], widths: &[usize], styles: &[Style]) {
267 let style_of = |idx: usize| styles.get(idx).copied().unwrap_or(Style::Plain);
268 let cols = widths.len();
269 let mut line = String::new();
270 let mut indent = 0usize;
271 for (idx, cw) in widths.iter().enumerate().take(cols - 1) {
272 let cell = cells.get(idx).map(String::as_str).unwrap_or("");
273 line.push_str(&paint(style_of(idx), &format!("{cell:<cw$}")));
274 line.push_str(&" ".repeat(GAP));
275 indent += cw + GAP;
276 }
277 let last = cells.get(cols - 1).map(String::as_str).unwrap_or("");
278 let wrapped = wrap(last, widths[cols - 1]);
279 println!("{line}{}", paint(style_of(cols - 1), &wrapped[0]));
280 for cont in &wrapped[1..] {
281 println!("{}{}", " ".repeat(indent), paint(style_of(cols - 1), cont));
282 }
283}
284
285pub fn human_time(rfc3339: &str) -> String {
287 if rfc3339.len() >= 16 && rfc3339.as_bytes()[10] == b'T' {
288 let mut s: String = rfc3339.chars().take(16).collect();
289 s.replace_range(10..11, " ");
290 s
291 } else {
292 rfc3339.to_string()
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn truncate_middle_keeps_head_and_tail() {
302 let s = "/Users/alexis/workspace/perso/projects/software/dejavu/some/deep/dir";
303 let t = truncate_middle(s, 30);
304 assert_eq!(t.chars().count(), 30);
305 assert!(t.ends_with("deep/dir"));
306 assert!(t.contains('…'));
307 assert_eq!(truncate_middle("short", 30), "short");
308 }
309
310 #[test]
311 fn wrap_breaks_on_words_and_hard_breaks_long_tokens() {
312 let lines = wrap("alpha beta gamma delta", 11);
313 assert_eq!(lines, vec!["alpha beta", "gamma delta"]);
314 let lines = wrap(&"x".repeat(25), 10);
315 assert_eq!(lines, vec!["x".repeat(10), "x".repeat(10), "x".repeat(5)]);
316 }
317
318 #[test]
319 fn human_time_shortens_rfc3339() {
320 assert_eq!(
321 human_time("2026-07-06T18:25:25.713220Z"),
322 "2026-07-06 18:25"
323 );
324 assert_eq!(human_time("never"), "never");
325 }
326}