Skip to main content

retch_cli/
display.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Formatting and display logic for terminal output.
5//!
6//! Handles text rendering, layout, and image/ASCII logo rendering.
7
8use crate::cli::{Cli, ColorChoice};
9use crate::config::Config;
10use crate::fetch::SystemInfo;
11use crate::fields::{self, Mode};
12use crate::logo;
13use crate::theme::{colorize_nested, Theme, ACTIVE_IFACE_PREFIX};
14use retch_sysinfo::network::NetworkInterface;
15
16/// Decide whether to render a logo at all.
17///
18/// In *auto* mode the logo is shown only when stdout is a TTY: the graphical and
19/// Chafa heuristics (and the side-by-side layout) are meaningless when output is
20/// piped or redirected, so we suppress the logo there. Two explicit overrides break
21/// that rule:
22/// - `no_logo` (from `--no-logo` or config) always wins → no logo.
23/// - `ascii_logo` (from `--ascii-logo`) forces the logo on **even without a TTY**:
24///   ASCII art is plain, pipe-safe text, so a caller (e.g. `retch --ascii-logo | cat`,
25///   or CI's `full-test` dry run) that explicitly asks for it should get it — mirroring
26///   how `--no-logo` is honored regardless of TTY. `--chafa-logo`/graphical modes are
27///   deliberately NOT forced here, since they emit terminal-specific control sequences
28///   that are only meaningful on a real terminal.
29fn should_show_logo(
30    config_show_logo: Option<bool>,
31    no_logo: bool,
32    ascii_logo: bool,
33    stdout_is_tty: bool,
34) -> bool {
35    if no_logo {
36        return false; // explicit suppression always wins
37    }
38    if ascii_logo {
39        return true; // explicit ASCII request forces the logo on, TTY or not, config or not
40    }
41    config_show_logo.unwrap_or(true) && stdout_is_tty // auto mode: default-on, but TTY-gated
42}
43
44/// Whether to draw the ASCII logo instead of an image or Chafa logo: the `--ascii-logo` flag
45/// or `ascii_only = true` in the config. Until v0.20.12 only the flag was read here, so the
46/// config key did nothing. (`should_show_logo` still keys on the flag alone: forcing a logo
47/// into a pipe is for an explicit command-line request, not a standing config preference.)
48fn wants_ascii_logo(cli_ascii_logo: bool, config_ascii_only: Option<bool>) -> bool {
49    cli_ascii_logo || config_ascii_only.unwrap_or(false)
50}
51
52/// Decide whether to emit ANSI colour.
53///
54/// `--color=always` and `--color=never` are final. Otherwise (the flag omitted, or `auto`)
55/// colour needs a terminal on stdout and a `NO_COLOR` that is unset or empty — the
56/// no-color.org rule is "present and not an empty string", so `NO_COLOR=` keeps colour on.
57///
58/// Before this existed retch coloured unconditionally, piped or not, which is why the
59/// Homebrew formula's test had to strip escapes itself before matching `OS:`.
60fn should_use_color(
61    choice: Option<ColorChoice>,
62    no_color: Option<&std::ffi::OsStr>,
63    stdout_is_tty: bool,
64) -> bool {
65    match choice {
66        Some(ColorChoice::Always) => true,
67        Some(ColorChoice::Never) => false,
68        Some(ColorChoice::Auto) | None => {
69            stdout_is_tty && no_color.map(|v| v.is_empty()).unwrap_or(true)
70        }
71    }
72}
73
74/// Remove every SGR (`ESC [ <params> m`) sequence from `s`, and nothing else.
75///
76/// This is how colour is turned off, rather than by teaching each colour source to stay
77/// quiet, because one of those sources is outside this crate: `retch-sysinfo` builds the
78/// green `Up` / red `Down` into the `Net` line itself. Stripping after the lines are
79/// formatted also keeps the layout byte-for-byte: `print_line` right-aligns the *coloured*
80/// label, so formatting plain labels instead would pad them differently.
81///
82/// SGR only, on purpose. Any other escape — a cursor move, or chafa's `\x1b[?25l` — is
83/// copied through untouched, since dropping part of one would leave the terminal in a
84/// state nobody asked for. A sequence whose parameters are not digits and `;` is not SGR
85/// and is kept whole.
86fn strip_sgr(s: &str) -> String {
87    let bytes = s.as_bytes();
88    let mut out = String::with_capacity(s.len());
89    let mut copied_to = 0;
90    let mut i = 0;
91    while i + 1 < bytes.len() {
92        if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
93            let mut j = i + 2;
94            while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == b';') {
95                j += 1;
96            }
97            if j < bytes.len() && bytes[j] == b'm' {
98                // Every byte of the sequence is ASCII, so these are char boundaries.
99                out.push_str(&s[copied_to..i]);
100                copied_to = j + 1;
101                i = j + 1;
102                continue;
103            }
104        }
105        i += 1;
106    }
107    out.push_str(&s[copied_to..]);
108    out
109}
110
111/// Result of [`plan_layout`]: whether the logo sits beside the text, the width the info
112/// lines beside the logo wrap to, and the column the logo itself is drawn at.
113///
114/// `text_column_width` and `logo_column` are deliberately **separate**. The first bounds how
115/// wide a beside-logo info line may grow; the second is where the logo block starts. Folding
116/// them into one value is what let the logo drift inward: the text column is clamped to 65
117/// columns, so on a wide terminal the logo was drawn at column 65 with the rest of the
118/// terminal left empty.
119struct LayoutPlan {
120    side_by_side: bool,
121    text_column_width: usize,
122    logo_column: usize,
123}
124
125/// Decide side-by-side vs. stacked layout, and the text-column width, from the geometry of
126/// the info block and the currently-selected logo.
127///
128/// Only the info lines that actually sit **beside** the logo — the first `logo_height` rows —
129/// constrain the layout. In `--long`/`--full` the widest lines (Wi-Fi, Network, Battery) fall
130/// *below* the logo, where nothing overlaps them, so they must neither widen the text column
131/// nor force a stacked layout. Basing the decision on every line (the previous behaviour) let
132/// a single 150+ char Wi-Fi line push the logo above the text on any normal-width terminal.
133///
134/// This is logo-type-agnostic: `logo_height`/`logo_width` are supplied by the caller from the
135/// active logo, so it works identically for ASCII art, Chafa (both rendered as text lines),
136/// and the graphical image protocols (Kitty/iTerm2/Sixel, whose cell footprint comes from
137/// [`logo::fit_logo_cells`] — the *same* call the emitters use to size the image, so the
138/// reserved area and the drawn area cannot disagree).
139///
140/// In side-by-side mode the logo is **flush against the right margin** (`logo_column =
141/// term_width - logo_width`), not butted up against the end of the text column. The two used
142/// to be the same number, which was only ever right by accident: before the text column was
143/// narrowed to the beside-logo lines (v0.6.8) and then clamped to 65 (v0.6.16), a long
144/// `Wi-Fi`/`Net` line inflated it far enough that the logo happened to land near the edge.
145/// Afterwards it sat at column 65 on every wide terminal, stranding the remainder.
146///
147/// Right-anchoring can never push the logo *left* of the text: `side_by_side` already
148/// requires `term_width >= text_column_width + logo_width`, so `term_width - logo_width` is
149/// at least `text_column_width`.
150///
151/// `info_widths` are the ANSI-stripped visible widths of the info lines, in render order.
152fn plan_layout(
153    info_widths: &[usize],
154    logo_height: usize,
155    logo_width: usize,
156    term_width: usize,
157    show_logo: bool,
158) -> LayoutPlan {
159    let beside_count = info_widths.len().min(logo_height);
160    let max_beside_width = info_widths[..beside_count]
161        .iter()
162        .copied()
163        .max()
164        .unwrap_or(0);
165    let text_column_width = if term_width >= 95 {
166        (term_width.saturating_sub(logo_width + 4))
167            .min(std::cmp::max(max_beside_width + 4, 45))
168            .clamp(45, 65)
169    } else {
170        std::cmp::max(max_beside_width + 4, 45)
171    };
172    let side_by_side =
173        show_logo && term_width >= 95 && term_width >= text_column_width + logo_width;
174    // Flush right. The `max(text_column_width)` floor is belt-and-braces: the `side_by_side`
175    // condition above already guarantees it, and the value is unused when stacked.
176    let logo_column = term_width.saturating_sub(logo_width).max(text_column_width);
177    LayoutPlan {
178        side_by_side,
179        text_column_width,
180        logo_column,
181    }
182}
183
184/// Strip ANSI escape sequences and return the string's width in **terminal columns**.
185///
186/// Not a character count. A CJK ideograph or a Hangul syllable occupies two columns, a
187/// combining mark occupies none, and an emoji followed by the variation selector U+FE0F
188/// (`☀️`) is two columns even though its base character alone would be one — none of which a
189/// `chars().count()` can express.
190///
191/// This matters because every layout decision in this module is denominated in columns: the
192/// padding that positions the logo, the wrap width for beside-logo lines, and the logo's own
193/// measured width. Counting characters undercounted `Media: 宇多田ヒカル - 花束を君に` by 11
194/// columns, so the line overran its column and pushed the logo out of alignment on that row.
195/// `media`/`player` (v0.8.0) read arbitrary track metadata, so non-Latin text is an ordinary
196/// input here, not an exotic one.
197///
198/// Escape handling is unchanged: `\x1b` opens a sequence that ends at the first ASCII letter,
199/// which covers the CSI (`\x1b[…m`), charset (`\x1b(B`) and private (`\x1b[?25l`) forms that
200/// `owo_colors` and `chafa` emit.
201///
202/// The visible characters are measured as one run rather than summed per character, because
203/// width is not a per-character property: variation-selector and zero-width-joiner sequences
204/// are only correct when the whole grapheme is measured together.
205pub fn visible_len(s: &str) -> usize {
206    use unicode_width::UnicodeWidthStr;
207
208    let mut visible = String::with_capacity(s.len());
209    let mut in_esc = false;
210    for c in s.chars() {
211        if c == '\x1b' {
212            in_esc = true;
213        } else if in_esc {
214            if c.is_ascii_alphabetic() {
215                in_esc = false;
216            }
217        } else {
218            visible.push(c);
219        }
220    }
221    visible.width()
222}
223
224/// Wrap a formatted info line (key: value) at logical boundaries to fit within `max_width`.
225///
226/// Continuation lines are indented to align with the start of the value portion.
227/// Prefers splitting on logical delimiters (e.g. `, `, ` - `) over arbitrary space boundaries,
228/// keeping atomic pairs (like `RX: ... TX: ...`) on the same line.
229pub fn wrap_info_line(line: &str, max_width: usize) -> Vec<String> {
230    let vis_len = visible_len(line);
231    if vis_len <= max_width || max_width < 20 {
232        return vec![line.to_string()];
233    }
234
235    let prefix_len = if let Some(idx) = line.find(':') {
236        let prefix_sub = &line[..=idx];
237        let extra_space = if line[idx + 1..].starts_with(' ') {
238            1
239        } else {
240            0
241        };
242        visible_len(prefix_sub) + extra_space
243    } else {
244        4
245    };
246
247    let indent = " ".repeat(prefix_len.min(max_width / 2));
248
249    // Try logical splitting by comma (", ") if present
250    if line.contains(", ") {
251        let parts: Vec<&str> = line.split(", ").collect();
252        let mut lines = Vec::new();
253        let mut current = String::new();
254
255        for (i, part) in parts.iter().enumerate() {
256            let item = if i == 0 {
257                part.to_string()
258            } else {
259                format!(", {}", part)
260            };
261            let item_vis = visible_len(&item);
262
263            if current.is_empty() || visible_len(&current) + item_vis <= max_width {
264                current.push_str(&item);
265            } else {
266                // Keep the separator we split on. Dropping it changed the *data*, not just
267                // its appearance: `American Megatrends International, LLC.` wrapped to
268                // `…International` / `LLC.`, which reads as two values rather than one
269                // company name. The comma stays on the preceding line, as in prose.
270                lines.push(format!("{current},"));
271                current = format!("{}{}", indent, part);
272            }
273        }
274        if !current.is_empty() {
275            lines.push(current);
276        }
277        if lines.iter().all(|l| visible_len(l) <= max_width + 10) {
278            return carry_sgr_across_lines(lines);
279        }
280    }
281
282    // Whitespace splitting fallback: group RX/TX headers with their values
283    let raw_words: Vec<&str> = line.split_whitespace().collect();
284    let mut words: Vec<String> = Vec::new();
285    let mut idx = 0;
286    while idx < raw_words.len() {
287        if raw_words[idx] == "RX:"
288            && idx + 3 < raw_words.len()
289            && raw_words.iter().skip(idx).any(|&w| w == "TX:")
290        {
291            let rx_tx = format!(
292                "{} {} {} {} {} {}",
293                raw_words[idx],
294                raw_words[idx + 1],
295                raw_words[idx + 2],
296                raw_words[idx + 3],
297                raw_words.get(idx + 4).copied().unwrap_or(""),
298                raw_words.get(idx + 5).copied().unwrap_or("")
299            );
300            words.push(rx_tx.trim().to_string());
301            idx += if idx + 5 < raw_words.len() { 6 } else { 4 };
302            continue;
303        }
304        words.push(raw_words[idx].to_string());
305        idx += 1;
306    }
307
308    let mut lines = Vec::new();
309    let mut current = String::new();
310
311    for word in words {
312        let word_vis = visible_len(&word);
313        if current.is_empty() {
314            current.push_str(&word);
315        } else if visible_len(&current) + 1 + word_vis <= max_width {
316            current.push(' ');
317            current.push_str(&word);
318        } else {
319            lines.push(current);
320            current = format!("{}{}", indent, word);
321        }
322    }
323    if !current.is_empty() {
324        lines.push(current);
325    }
326
327    if lines.is_empty() {
328        vec![line.to_string()]
329    } else {
330        // No separator to retain here — this branch breaks on whitespace, and a space at a
331        // line break needs no visible marker the way a comma does.
332        carry_sgr_across_lines(lines)
333    }
334}
335
336/// The foreground-colour SGR sequence still in effect at the end of `s`, given the sequence
337/// `entry` that was in effect when it started.
338///
339/// Only foreground colour is tracked, because that is all `Theme`/`owo_colors` emit here.
340/// A reset — `\x1b[0m` or `\x1b[39m` — clears it; any other `…m` sequence becomes the new
341/// state. Non-`m` sequences (cursor moves, chafa's `\x1b[?25l`) are ignored.
342fn active_sgr_after(s: &str, entry: Option<String>) -> Option<String> {
343    let mut active = entry;
344    let bytes = s.as_bytes();
345    let mut i = 0;
346    while i < bytes.len() {
347        if bytes[i] != 0x1b {
348            i += 1;
349            continue;
350        }
351        let start = i;
352        i += 1;
353        while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
354            i += 1;
355        }
356        if i < bytes.len() {
357            let seq = &s[start..=i];
358            if seq.ends_with('m') {
359                active = if seq == "\x1b[0m" || seq == "\x1b[39m" {
360                    None
361                } else {
362                    Some(seq.to_string())
363                };
364            }
365            i += 1;
366        }
367    }
368    active
369}
370
371/// Re-open the active colour on every continuation line, and close it at each line end.
372///
373/// Info lines are colourised **before** they are wrapped, so a value split across lines has
374/// its opening SGR on the first line and its closing `\x1b[39m` on the last: every line in
375/// between renders in the terminal's default colour. Reported against a wrapped `BIOS:` value,
376/// whose second line came out uncoloured while the first was cyan.
377///
378/// Fixing it at the wrap step rather than by colourising after wrapping is deliberate — the
379/// wrap points are chosen from *visible* width, so wrapping has to see the escapes anyway.
380///
381/// Only zero-width escape sequences are added, so [`visible_len`] of every line is unchanged
382/// and the widths the layout already computed still hold.
383fn carry_sgr_across_lines(lines: Vec<String>) -> Vec<String> {
384    let mut active: Option<String> = None;
385    let mut out = Vec::with_capacity(lines.len());
386    for line in lines {
387        let reopened = match &active {
388            Some(sgr) => format!("{sgr}{line}"),
389            None => line.clone(),
390        };
391        let end_state = active_sgr_after(&line, active.clone());
392        active = end_state.clone();
393        out.push(match end_state {
394            // Close the colour at the line end so it cannot bleed into the logo column.
395            Some(_) => format!("{reopened}\x1b[39m"),
396            None => reopened,
397        });
398    }
399    out
400}
401
402/// Split the Wi-Fi detail string into `(hardware, connection)` for two-line display.
403///
404/// The Linux `iw` path builds `"{adapter model} [{iface}] - {SSID} ({band/rate})"` — hardware
405/// and connection joined by `" - "`. Splitting on the first `" - "` puts the adapter on one
406/// line ("Wi-Fi") and the live connection on a second ("Wi-Fi Link"), so neither is the
407/// 150+ char line that used to wrap and collide with the logo. The fallback detectors
408/// (nmcli/iwgetid/macOS/Windows) return only the connection with no `" - "`, so those render
409/// as a single line (`connection` is `None`).
410fn split_wifi_line(wifi: &str) -> (&str, Option<&str>) {
411    match wifi.split_once(" - ") {
412        Some((hardware, connection)) => (hardware, Some(connection)),
413        None => (wifi, None),
414    }
415}
416
417/// Compose one row of the side-by-side layout: the info line, padded out to `logo_column`,
418/// followed by the logo line.
419///
420/// Padding goes to the **logo column** (the right margin), not merely to the end of the text
421/// column. Rows with no logo content get none at all — otherwise every line below the logo
422/// would carry ~90 trailing spaces.
423///
424/// Extracted from `display()`'s render loop deliberately. This arithmetic used to be inline
425/// there, alongside a local `visible_len` closure that shadowed the module function for the
426/// whole of `display()`; the shadow was a byte-for-byte copy of an older, character-counting
427/// implementation, so the layout silently measured characters while the module function —
428/// and its unit tests — measured columns. A free function cannot be shadowed by a local
429/// binding in another function's body, so the two can no longer diverge, and this is now
430/// directly testable without a pseudo-terminal.
431fn compose_side_by_side_row(info_line: &str, logo_line: &str, logo_column: usize) -> String {
432    let vis_len = visible_len(info_line);
433    if logo_line.is_empty() || vis_len >= logo_column {
434        return format!("{info_line}{logo_line}");
435    }
436    format!(
437        "{info_line}{}{logo_line}",
438        " ".repeat(logo_column - vis_len)
439    )
440}
441
442/// Escape prelude for [`render_graphical_side_by_side`]: reserve `logo_rows` rows with
443/// newlines, move back up to the image-top row, shift right to `logo_column`, and save the
444/// cursor (`\x1b7`).
445///
446/// The reservation is the scroll-safety mechanism: printing the newlines *first* forces any
447/// scrolling to happen before the cursor is saved, so nothing between the save and the
448/// restore can scroll. Without it, drawing the image with the cursor near the bottom margin
449/// scrolled the screen mid-draw, and `\x1b8` — which restores a *viewport-relative*
450/// position — landed on the row below the image instead of beside its top (text rendered
451/// under the logo; reproduced on Rio and kitty alike whenever the prompt sat near the
452/// bottom of a used terminal).
453///
454/// `logo_rows == 0` emits no reservation and no cursor-up (`CSI 0 A` would still move one
455/// row on real terminals).
456fn graphical_side_by_side_prelude(logo_column: usize, logo_rows: usize) -> String {
457    let mut prelude = String::new();
458    if logo_rows > 0 {
459        prelude.push_str(&"\n".repeat(logo_rows));
460        prelude.push_str(&format!("\x1b[{}A", logo_rows));
461    }
462    prelude.push_str(&format!("\x1b[{}C\x1b7", logo_column));
463    prelude
464}
465
466/// Render an image-protocol logo (Kitty/iTerm2/Sixel) beside the info text, scroll-safely.
467///
468/// The logo's rows are **reserved first** (newlines, then cursor-up — see
469/// [`graphical_side_by_side_prelude`]) so any scrolling happens up front; the image is then
470/// drawn at the top of the logo column bracketed by save/restore (`\x1b7`/`\x1b8`), which is
471/// only valid because no scroll can occur between the two. The info lines are then printed
472/// top-to-bottom at column 0, so the terminal scrolls naturally and carries the cell-anchored
473/// image with it.
474///
475/// This replaces two broken predecessors: "print all text, then `\x1b[{n}A` back up and draw"
476/// (clamped at the viewport top for tall `--long`/`--full` output, drawing the image
477/// mid-text) and the v0.6.8 unreserved save/draw/restore (correct on a fresh screen, but with
478/// the prompt near the bottom the draw scrolled the screen and the restore landed *below* the
479/// image). Residual risk: the draw can still scroll only if the image's real row count
480/// exceeds `logo_rows` — the same cell-height estimate the layout already trusts.
481fn render_graphical_side_by_side(
482    logo_column: usize,
483    info_lines: &[String],
484    logo_rows: usize,
485    draw: impl FnOnce(),
486) {
487    use std::io::Write;
488    // Reserve the logo rows (scroll now, if at all), return to the image-top row at the
489    // logo column, save, draw the image, restore, return to column 0.
490    print!("{}", graphical_side_by_side_prelude(logo_column, logo_rows));
491    draw(); // emits the image escape (and may move the cursor / print a newline)
492    print!("\x1b8\r");
493    for line in info_lines {
494        println!("{}", line);
495    }
496    // If the image is taller than the text block, advance past its bottom edge so a following
497    // shell prompt doesn't overlap it.
498    for _ in info_lines.len()..logo_rows {
499        println!();
500    }
501    let _ = std::io::stdout().flush();
502}
503
504/// Renders the collected system information to the terminal.
505///
506/// This function handles theme selection, logo rendering (including fallbacks
507/// between graphics, Chafa, and ASCII), and field filtering based on
508/// CLI flags and configuration.
509/// Splits interfaces into (active, everything else), preserving order within each group.
510///
511/// **The active interface is identified by an exact NAME match**, which is the whole point
512/// of this function existing. It replaces `line.contains(active)` over the rendered line,
513/// which matched any interface whose *presentation* happened to contain the active name:
514/// on Windows `Wi-Fi` matched the `Wi-Fi-Native WiFi Filter Driver-0000` pseudo-interface,
515/// so both were printed as the active interface; on Linux `eth0` matches an `eth0.100`
516/// VLAN or a `veth0…` pair. Substring-matching a formatted string cannot answer a question
517/// about identity, and it also read the addresses and byte counts, not just the name.
518fn partition_net_lines<'a>(
519    nets: &'a [NetworkInterface],
520    active: Option<&str>,
521) -> (Vec<&'a NetworkInterface>, Vec<&'a NetworkInterface>) {
522    nets.iter().partition(|n| active == Some(n.name.as_str()))
523}
524
525/// Chooses the single interface standard mode shows: the active one, else the first that
526/// is up.
527///
528/// The fallback is the part that was broken. It used to test `line.contains("[Up]")`, but
529/// the status is colourised before the line is built, so the bytes are
530/// `[` + `ESC[32m` + `Up` + `ESC[39m` + `]` and the literal `[Up]` **never appears** —
531/// the branch could not fire, and standard mode printed no `Net` line at all whenever the
532/// active interface could not be resolved. Keyed on the interface's own `is_up` now.
533fn choose_net_line<'a>(
534    nets: &'a [NetworkInterface],
535    active: Option<&str>,
536) -> Option<&'a NetworkInterface> {
537    nets.iter()
538        .find(|n| active == Some(n.name.as_str()))
539        .or_else(|| nets.iter().find(|n| n.is_up))
540}
541
542pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result<()> {
543    let _config = config;
544    let theme_name = _config.theme.as_deref().or(cli.theme.as_deref());
545    let mut theme = match theme_name {
546        Some(name) => Theme::from_name(name),
547        None => Theme::detect_system_theme(), // Default to system preference
548    };
549
550    // Apply custom theme overrides from config if present
551    if let Some(custom) = &_config.custom_theme {
552        theme = Theme::with_custom_overrides(theme, custom);
553    }
554
555    // Determine terminal width.
556    let term_size = terminal_size::terminal_size();
557    let term_width = if let Some((terminal_size::Width(w), _)) = term_size {
558        w as usize
559    } else {
560        80
561    };
562    // Use isatty() directly — terminal_size() can return Some() when a pager
563    // (e.g. bat) allocates a PTY, giving a false positive.
564    let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
565    let use_color = should_use_color(
566        cli.color,
567        std::env::var_os("NO_COLOR").as_deref(),
568        stdout_is_tty,
569    );
570
571    let show_logo = should_show_logo(
572        _config.show_logo,
573        cli.no_logo,
574        cli.ascii_logo,
575        stdout_is_tty,
576    );
577
578    // Determine which fields to show. Strata allow-lists are derived from the
579    // single field registry (src/fields.rs) — the same source `main.rs` uses for
580    // collection, so display and collection can no longer drift apart. An explicit
581    // `config.fields` list bypasses the strata.
582    let allowed_fields: Option<Vec<String>> = if cli.full {
583        Some(fields::fields_for(Mode::Full))
584    } else if cli.long {
585        Some(fields::fields_for(Mode::Long))
586    } else if cli.short {
587        Some(fields::fields_for(Mode::Short))
588    } else if let Some(fields) = &_config.fields {
589        Some(fields.iter().map(|s| s.to_lowercase()).collect())
590    } else {
591        Some(fields::fields_for(Mode::Standard))
592    };
593
594    let should_show = |label: &str| -> bool {
595        match &allowed_fields {
596            Some(fields) => {
597                let norm_label = label.to_lowercase().replace(['-', '_'], " ");
598                let norm_label_no_spaces = norm_label.replace(' ', "");
599                fields.iter().any(|f| {
600                    let norm_f = f.to_lowercase().replace(['-', '_'], " ");
601                    norm_f == norm_label
602                        || norm_f.replace(' ', "") == norm_label_no_spaces
603                        // "dns" field key matches "DNS Server" display label
604                        || (norm_label == "dns server" && norm_f == "dns")
605                        // "memory" field key matches "Memory Usage" display label
606                        || (norm_label == "memory usage" && norm_f == "memory")
607                        // "Wi-Fi Link" (the connection line) maps to the "wifi" field key
608                        || (norm_label == "wi fi link" && norm_f == "wifi")
609                })
610            }
611            None => true,
612        }
613    };
614
615    // Helper for right-aligned labels
616    let label_width = 10;
617    let mut info_lines = Vec::new();
618    let mut print_line = |label: &str, value: &str| {
619        if should_show(label) {
620            info_lines.push(format!(
621                "{:>width$}{} {}",
622                theme.color_label(label),
623                theme.color_separator(":"),
624                theme.color_value(value),
625                width = label_width
626            ));
627        }
628    };
629
630    // OS / system identity.
631    // Host leads: it names *which machine* this output describes, which is the first thing a
632    // reader needs when comparing runs from several boxes or reading a pasted screenshot.
633    if let Some(host) = &info.hostname {
634        print_line("Host", host);
635    }
636    print_line("OS", &info.os);
637    if let Some(kernel) = &info.kernel {
638        print_line("Kernel", kernel);
639    }
640    if let Some(domain) = &info.domain {
641        print_line("Domain", domain);
642    }
643    if should_show("domain-search") {
644        for entry in &info.domain_search {
645            print_line("Domain Search", entry);
646        }
647    }
648    if let Some(chassis) = &info.chassis {
649        print_line("Chassis", chassis);
650    }
651    if let Some(init) = &info.init_system {
652        print_line("Init", init);
653    }
654    if let Some(locale) = &info.locale {
655        print_line("Locale", locale);
656    }
657    print_line("Arch", &info.arch);
658    // Suppress "Users: 0" — a 0 means the count couldn't be determined (e.g. the Unix
659    // uid>=1000 heuristic on a platform that keys users differently), not that nobody is
660    // logged in. Mirrors the `packages` guard below.
661    if info.users > 0 {
662        print_line("Users", &info.users.to_string());
663    }
664    if let Some(pkgs) = info.packages {
665        if pkgs > 0 {
666            print_line("Packages", &pkgs.to_string());
667        }
668    }
669    if let Some(user) = &info.current_user {
670        print_line("User", user);
671    }
672    // Uptime belongs with system identity, not hardware
673    let uptime_str = format_uptime(&info.uptime);
674    let boot_display = format!("{} since {}", uptime_str, info.boot_time);
675    print_line("Uptime", &boot_display);
676
677    // Hardware
678    print_line("CPU", &format!("{} ({})", info.cpu, info.cpu_core_info));
679    if let Some(freq) = &info.cpu_freq {
680        print_line("CPU Freq", freq);
681    }
682    if let Some(cache) = &info.cpu_cache {
683        print_line("CPU Cache", cache);
684    }
685    if let Some(usage) = &info.cpu_usage {
686        print_line("CPU Usage", usage);
687    }
688    if let Some(motherboard) = &info.motherboard {
689        print_line("Motherboard", motherboard);
690    }
691    if let Some(bios) = &info.bios {
692        print_line("BIOS", bios);
693    }
694    if let Some(bootmgr) = &info.bootmgr {
695        print_line("Bootmgr", bootmgr);
696    }
697    if let Some(tpm) = &info.tpm {
698        print_line("TPM", tpm);
699    }
700    if should_show("GPU") {
701        for gpu in &info.gpu {
702            print_line("GPU", gpu);
703        }
704    }
705    if should_show("Display") {
706        for display in &info.displays {
707            print_line("Display", display);
708        }
709    }
710    if let Some(vulkan) = &info.vulkan {
711        print_line("Vulkan", vulkan);
712    }
713    if let Some(opengl) = &info.opengl {
714        print_line("OpenGL", opengl);
715    }
716    if let Some(opencl) = &info.opencl {
717        print_line("OpenCL", opencl);
718    }
719    if let Some(brightness) = &info.brightness {
720        print_line("Brightness", brightness);
721    }
722    if let Some(audio) = &info.audio {
723        print_line("Audio", audio);
724    }
725    if should_show("Camera") {
726        for cam in &info.camera {
727            print_line("Camera", cam);
728        }
729    }
730    if should_show("Gamepad") {
731        for gp in &info.gamepad {
732            print_line("Gamepad", gp);
733        }
734    }
735    if should_show("Keyboard") {
736        for kb in &info.keyboard {
737            print_line("Keyboard", kb);
738        }
739    }
740    if should_show("Mouse") {
741        for m in &info.mouse {
742            print_line("Mouse", m);
743        }
744    }
745    if let Some(wifi) = &info.wifi {
746        // Split the (often 150+ char) Wi-Fi string into a hardware line and a connection line
747        // so neither wraps and collides with the logo. See `split_wifi_line`.
748        let (hardware, connection) = split_wifi_line(wifi);
749        print_line("Wi-Fi", hardware);
750        if let Some(conn) = connection {
751            print_line("Wi-Fi Link", conn);
752        }
753    }
754    if let Some(bt) = &info.bluetooth {
755        print_line("Bluetooth", bt);
756    }
757    if let Some(bat) = &info.battery {
758        print_line("Battery", bat);
759    }
760    if let Some(power) = &info.power_adapter {
761        print_line("Power Adapter", power);
762    }
763    print_line("Memory Usage", &info.memory);
764    if let Some(phys_mem) = &info.physical_memory {
765        print_line("Phys Mem", phys_mem);
766    }
767    print_line("Swap", &info.swap);
768    print_line("Procs", &info.processes.to_string());
769    if let Some(load) = &info.load_avg {
770        print_line("Load", load);
771    }
772    if should_show("Disk") {
773        for disk in &info.disks {
774            print_line("Disk", disk);
775        }
776    }
777    if should_show("Phys Disk") {
778        for disk in &info.physical_disks {
779            print_line("Phys Disk", disk);
780        }
781    }
782    if should_show("Disk IO") {
783        for io in &info.disk_io {
784            print_line("Disk IO", io);
785        }
786    }
787    if should_show("Btrfs") {
788        for vol in &info.btrfs {
789            print_line("Btrfs", vol);
790        }
791    }
792    if should_show("Zpool") {
793        for pool in &info.zpool {
794            print_line("Zpool", pool);
795        }
796    }
797    if should_show("Temp") {
798        if cli.full {
799            for temp in &info.temps {
800                print_line("Temp", temp);
801            }
802        } else {
803            for temp in consolidate_temps(&info.temps) {
804                print_line("Temp", &temp);
805            }
806        }
807    }
808
809    // Network
810    if should_show("Net") {
811        let active = info.active_interface.as_deref();
812        if cli.long || cli.full {
813            let (active_nets, others) = partition_net_lines(&info.networks, active);
814            for net in active_nets {
815                // Re-assert bright blue after the nested green "Up" /
816                // red "Down" reset so the whole active line stays blue
817                // (brackets and RX/TX included), not just up to "[".
818                print_line("Net", &colorize_nested(&net.line, ACTIVE_IFACE_PREFIX));
819            }
820            for net in others {
821                print_line("Net", &net.line);
822            }
823        } else if let Some(net) = choose_net_line(&info.networks, active) {
824            print_line("Net", &net.line);
825        }
826    }
827    if should_show("Net IO") {
828        for io in &info.net_io {
829            print_line("Net IO", io);
830        }
831    }
832    if let Some(ip) = &info.public_ip {
833        print_line("Public IP", ip);
834    }
835    if !info.dns.is_empty() {
836        print_line("DNS Server", &info.dns.join(", "));
837    }
838
839    // Environment
840    if let Some(shell) = &info.shell {
841        print_line("Shell", shell);
842    }
843    if let Some(editor) = &info.editor {
844        print_line("Editor", editor);
845    }
846    if let Some(term) = &info.terminal {
847        print_line("Terminal", term);
848    }
849    if let Some(ts) = &info.terminal_size {
850        print_line("Terminal Size", ts);
851    }
852    if let Some(de) = &info.desktop {
853        print_line("Desktop", de);
854    }
855    if let Some(wm) = &info.wm {
856        let duplicate = info
857            .desktop
858            .as_deref()
859            .map(|de| de.to_lowercase() == wm.to_lowercase())
860            .unwrap_or(false);
861        if !duplicate {
862            print_line("WM", wm);
863        }
864    }
865    if let Some(wm_theme) = &info.wm_theme {
866        print_line("WM Theme", wm_theme);
867    }
868    if let Some(wallpaper) = &info.wallpaper {
869        print_line("Wallpaper", wallpaper);
870    }
871    if let Some(lm) = &info.login_manager {
872        print_line("Login Manager", lm);
873    }
874    if let Some(player) = &info.player {
875        print_line("Player", player);
876    }
877    if let Some(media) = &info.media {
878        print_line("Media", media);
879    }
880    if let Some(ui_theme) = &info.ui_theme {
881        print_line("Theme", ui_theme);
882    }
883    if let Some(icons) = &info.icons {
884        print_line("Icons", icons);
885    }
886    if let Some(cursor) = &info.cursor {
887        print_line("Cursor", cursor);
888    }
889    if let Some(font) = &info.font {
890        print_line("Font", font);
891    }
892    if let Some(term_font) = &info.terminal_font {
893        print_line("Terminal Font", term_font);
894    }
895    if let Some(term_theme) = &info.terminal_theme {
896        print_line("Terminal Theme", term_theme);
897    }
898    if let Some(weather) = &info.weather {
899        print_line("Weather", weather);
900    }
901
902    // Setup logo representation. The three image variants are constructed only with the
903    // `graphics` feature; the code that lays them out is shared, so it stays compiled and the
904    // variants are allowed to be unused in a `--no-default-features` build.
905    #[cfg_attr(not(feature = "graphics"), allow(dead_code))]
906    enum ActiveLogo {
907        Lines(Vec<String>),
908        Kitty(Vec<u8>, usize, usize), // bytes, cols, rows
909        Iterm2(Vec<u8>, usize, usize),
910        Sixel(Vec<u8>, usize, usize),
911        None,
912    }
913
914    let mut active_logo = ActiveLogo::None;
915
916    if show_logo {
917        let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
918        let user_logo = if let Some(config_dir) = dirs::config_dir() {
919            let p = config_dir.join("retch").join("logo.png");
920            if p.exists() {
921                Some(p)
922            } else {
923                None
924            }
925        } else {
926            None
927        };
928
929        if wants_ascii_logo(cli.ascii_logo, _config.ascii_only) {
930            active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
931        } else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
932            let mut resolved = false;
933            // Chafa art is drawn *with* colour; stripped, it is noise. Without colour an
934            // explicit chafa request gets the plain ASCII logo instead.
935            if use_color && logo::chafa_available() {
936                if let Some(path) = &user_logo {
937                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
938                        active_logo = ActiveLogo::Lines(lines);
939                        resolved = true;
940                    }
941                } else if let Some(distro) = &distro_hint {
942                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
943                        let temp_path = std::env::temp_dir()
944                            .join(format!("retch_logo_{}.png", std::process::id()));
945                        if std::fs::write(&temp_path, bytes).is_ok() {
946                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
947                                active_logo = ActiveLogo::Lines(lines);
948                                resolved = true;
949                            }
950                            let _ = std::fs::remove_file(&temp_path);
951                        }
952                    }
953                }
954            }
955            if !resolved {
956                active_logo =
957                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
958            }
959        } else {
960            let mut resolved = false;
961
962            // Kitty
963            #[cfg(feature = "graphics")]
964            if !resolved && logo::supports_kitty() {
965                if let Some(path) = &user_logo {
966                    if let Ok(bytes) = std::fs::read(path) {
967                        let (cols, rows) = graphical_logo_cells(&bytes);
968                        active_logo = ActiveLogo::Kitty(bytes, cols, rows);
969                        resolved = true;
970                    }
971                } else if let Some(distro) = &distro_hint {
972                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
973                        let (cols, rows) = graphical_logo_cells(bytes);
974                        active_logo = ActiveLogo::Kitty(bytes.to_vec(), cols, rows);
975                        resolved = true;
976                    }
977                }
978            }
979
980            // iTerm2
981            #[cfg(feature = "graphics")]
982            if !resolved && logo::supports_iterm2() {
983                if let Some(path) = &user_logo {
984                    if let Ok(bytes) = std::fs::read(path) {
985                        let (cols, rows) = graphical_logo_cells(&bytes);
986                        active_logo = ActiveLogo::Iterm2(bytes, cols, rows);
987                        resolved = true;
988                    }
989                } else if let Some(distro) = &distro_hint {
990                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
991                        let (cols, rows) = graphical_logo_cells(bytes);
992                        active_logo = ActiveLogo::Iterm2(bytes.to_vec(), cols, rows);
993                        resolved = true;
994                    }
995                }
996            }
997
998            // Sixel
999            #[cfg(feature = "graphics")]
1000            if !resolved && logo::supports_sixel() {
1001                if let Some(path) = &user_logo {
1002                    if let Ok(bytes) = std::fs::read(path) {
1003                        let (cols, rows) = graphical_logo_cells(&bytes);
1004                        active_logo = ActiveLogo::Sixel(bytes, cols, rows);
1005                        resolved = true;
1006                    }
1007                } else if let Some(distro) = &distro_hint {
1008                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
1009                        let (cols, rows) = graphical_logo_cells(bytes);
1010                        active_logo = ActiveLogo::Sixel(bytes.to_vec(), cols, rows);
1011                        resolved = true;
1012                    }
1013                }
1014            }
1015
1016            // Chafa — skipped without colour, for the reason given at the explicit branch.
1017            // The image protocols above are not: a picture is not text colour.
1018            if !resolved && use_color && logo::chafa_available() {
1019                if let Some(path) = &user_logo {
1020                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
1021                        active_logo = ActiveLogo::Lines(lines);
1022                        resolved = true;
1023                    }
1024                } else if let Some(distro) = &distro_hint {
1025                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
1026                        // Write temp logo and read lines via chafa
1027                        let temp_path = std::env::temp_dir()
1028                            .join(format!("retch_logo_{}.png", std::process::id()));
1029                        if std::fs::write(&temp_path, bytes).is_ok() {
1030                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
1031                                active_logo = ActiveLogo::Lines(lines);
1032                                resolved = true;
1033                            }
1034                            let _ = std::fs::remove_file(&temp_path);
1035                        }
1036                    }
1037                }
1038            }
1039
1040            // Fallback to ASCII lines
1041            if !resolved {
1042                active_logo =
1043                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
1044            }
1045        }
1046    }
1047
1048    // Colour off: strip here, after every line is formatted and before anything is measured,
1049    // so the layout below sees exactly the widths it would have seen with colour on.
1050    if !use_color {
1051        info_lines = info_lines.iter().map(|line| strip_sgr(line)).collect();
1052        if let ActiveLogo::Lines(logo_lines) = &mut active_logo {
1053            *logo_lines = logo_lines.iter().map(|line| strip_sgr(line)).collect();
1054        }
1055    }
1056
1057    // NOTE: `display()` previously defined a local `visible_len` closure here that was a
1058    // byte-for-byte copy of the module-level [`visible_len`] and shadowed it for this entire
1059    // function — which is where every layout decision is made. It has been removed so there
1060    // is one implementation. Do not reintroduce a local helper by this name: the shadow was
1061    // invisible at every call site (the calls below read identically either way), and it
1062    // silently reverted this module's width handling for the layout while the module
1063    // function's own unit tests kept passing.
1064    let info_widths: Vec<usize> = info_lines.iter().map(|line| visible_len(line)).collect();
1065
1066    // Height (row count) and width of the active logo, whatever its kind. ASCII and Chafa are
1067    // both `Lines`; the graphical protocols carry their pixel-derived row count and use the
1068    // fixed image column width.
1069    let (logo_height, max_logo_width) = match &active_logo {
1070        ActiveLogo::Lines(logo_lines) => (
1071            logo_lines.len(),
1072            logo_lines
1073                .iter()
1074                .map(|line| visible_len(line))
1075                .max()
1076                .unwrap_or(0),
1077        ),
1078        ActiveLogo::Kitty(_, cols, rows)
1079        | ActiveLogo::Iterm2(_, cols, rows)
1080        | ActiveLogo::Sixel(_, cols, rows) => (*rows, *cols),
1081        ActiveLogo::None => (0, 0),
1082    };
1083
1084    // Only the lines beside the logo constrain placement — a long Wi-Fi/Network line below it
1085    // must not force a stacked layout. See `plan_layout`.
1086    let LayoutPlan {
1087        side_by_side,
1088        text_column_width,
1089        logo_column,
1090    } = plan_layout(
1091        &info_widths,
1092        logo_height,
1093        max_logo_width,
1094        term_width,
1095        show_logo,
1096    );
1097
1098    println!(); // leading newline
1099
1100    let formatted_info_lines: Vec<String> = if side_by_side && text_column_width > 15 {
1101        let mut result = Vec::new();
1102        for (i, line) in info_lines.iter().enumerate() {
1103            // Beside-logo rows may use every column up to the logo, not just the text
1104            // column. Those were the same number until the logo was anchored to the right
1105            // margin; afterwards, wrapping at the text column left a wrapped line with the
1106            // whole gap to the logo unused — a 283-column terminal wrapped `BIOS:` at 55
1107            // columns with ~177 free to its right. Below-logo rows already use the full
1108            // terminal width, so this makes the two consistent.
1109            let max_w = if i < logo_height {
1110                logo_column.saturating_sub(2)
1111            } else {
1112                term_width.saturating_sub(2)
1113            };
1114            result.extend(wrap_info_line(line, max_w));
1115        }
1116        result
1117    } else {
1118        info_lines.clone()
1119    };
1120
1121    if side_by_side {
1122        match active_logo {
1123            ActiveLogo::Lines(logo_lines) => {
1124                let max_lines = std::cmp::max(formatted_info_lines.len(), logo_lines.len());
1125                for i in 0..max_lines {
1126                    let info_line = formatted_info_lines.get(i).cloned().unwrap_or_default();
1127                    let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
1128                    println!(
1129                        "{}",
1130                        compose_side_by_side_row(&info_line, &logo_line, logo_column)
1131                    );
1132                }
1133            }
1134            ActiveLogo::Kitty(bytes, _, logo_rows) => {
1135                render_graphical_side_by_side(
1136                    logo_column,
1137                    &formatted_info_lines,
1138                    logo_rows,
1139                    || logo::print_graphical_logo(&bytes),
1140                );
1141            }
1142            ActiveLogo::Iterm2(bytes, _, logo_rows) => {
1143                render_graphical_side_by_side(
1144                    logo_column,
1145                    &formatted_info_lines,
1146                    logo_rows,
1147                    || logo::print_iterm2_logo(&bytes),
1148                );
1149            }
1150            ActiveLogo::Sixel(bytes, _, logo_rows) => {
1151                render_graphical_side_by_side(
1152                    logo_column,
1153                    &formatted_info_lines,
1154                    logo_rows,
1155                    || logo::print_sixel_logo(&bytes),
1156                );
1157            }
1158            ActiveLogo::None => {
1159                for line in &formatted_info_lines {
1160                    println!("{}", line);
1161                }
1162            }
1163        }
1164    } else {
1165        // Narrow or no-logo fallback: print logo, then print data
1166        match active_logo {
1167            ActiveLogo::Lines(logo_lines) => {
1168                for line in logo_lines {
1169                    println!("{}", line);
1170                }
1171                println!();
1172            }
1173            ActiveLogo::Kitty(bytes, _, _) => {
1174                logo::print_graphical_logo(&bytes);
1175                println!();
1176            }
1177            ActiveLogo::Iterm2(bytes, _, _) => {
1178                logo::print_iterm2_logo(&bytes);
1179                println!();
1180            }
1181            ActiveLogo::Sixel(bytes, _, _) => {
1182                logo::print_sixel_logo(&bytes);
1183                println!();
1184            }
1185            ActiveLogo::None => {}
1186        }
1187        for line in &info_lines {
1188            println!("{}", line);
1189        }
1190    }
1191
1192    Ok(())
1193}
1194
1195/// Returns the highest temperature per physical category from a raw sensor list.
1196///
1197/// Input strings are formatted as `"label: 83°C"`. Output is one entry per
1198/// detected category (CPU / GPU / NVMe / WiFi / Battery / System), ordered
1199/// from most to least specific. Used by `--long` mode; `--full` shows the raw list.
1200fn consolidate_temps(temps: &[String]) -> Vec<String> {
1201    fn categorize(label: &str) -> &'static str {
1202        let l = label.to_lowercase();
1203        if l.contains("cpu")
1204            || l.contains("core")
1205            || l.contains("k10temp")
1206            || l.contains("k8temp")
1207            || l.contains("coretemp")
1208            || l.contains("tctl")
1209            || l.contains("tdie")
1210            || l.contains("tccd")
1211            || l.contains("package")
1212        {
1213            "CPU"
1214        } else if l.contains("gpu")
1215            || l.contains("nouveau")
1216            || l.contains("radeon")
1217            || l.contains("amdgpu")
1218        {
1219            "GPU"
1220        } else if l.contains("nvme") || l.contains("nand") {
1221            "NVMe"
1222        } else if l.contains("ath")
1223            || l.contains("wifi")
1224            || l.contains("wireless")
1225            || l.contains("wlan")
1226            || l.contains("iwl")
1227        {
1228            "WiFi"
1229        } else if l.contains("bat") {
1230            "Battery"
1231        } else {
1232            "System"
1233        }
1234    }
1235
1236    let mut max: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
1237    for s in temps {
1238        // Parse "some label: 83°C"
1239        if let Some((label_part, val_part)) = s.rsplit_once(':') {
1240            let val_str = val_part.trim().trim_end_matches("°C");
1241            if let Ok(val) = val_str.parse::<f32>() {
1242                let cat = categorize(label_part.trim());
1243                let entry = max.entry(cat).or_insert(f32::NEG_INFINITY);
1244                if val > *entry {
1245                    *entry = val;
1246                }
1247            }
1248        }
1249    }
1250
1251    const ORDER: &[&str] = &["CPU", "GPU", "NVMe", "WiFi", "Battery", "System"];
1252    ORDER
1253        .iter()
1254        .filter_map(|cat| max.get(cat).map(|v| format!("{}: {:.0}°C", cat, v)))
1255        .collect()
1256}
1257
1258/// Formats a raw uptime string (in seconds) into a human-readable duration.
1259///
1260/// Example: "45224s" -> "12h 33m 44s"
1261fn format_uptime(uptime: &str) -> String {
1262    // Parse the uptime string (e.g. "45224s")
1263    let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
1264
1265    let years = seconds / (365 * 24 * 3600);
1266    let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
1267    let hours = (seconds % (24 * 3600)) / 3600;
1268    let minutes = (seconds % 3600) / 60;
1269    let secs = seconds % 60;
1270
1271    let mut parts = Vec::new();
1272    if years > 0 {
1273        parts.push(format!("{}y", years));
1274    }
1275    if days > 0 {
1276        parts.push(format!("{}d", days));
1277    }
1278    if hours > 0 {
1279        parts.push(format!("{}h", hours));
1280    }
1281    if minutes > 0 {
1282        parts.push(format!("{}m", minutes));
1283    }
1284    if secs > 0 || parts.is_empty() {
1285        parts.push(format!("{}s", secs));
1286    }
1287
1288    parts.join(" ")
1289}
1290
1291/// Returns the `(columns, rows)` a graphical logo image will occupy on this terminal.
1292///
1293/// Delegates to [`logo::logo_cells_for`], which is also what the Kitty/iTerm2/Sixel emitters
1294/// use to size the image itself — so the footprint reserved by [`plan_layout`] and the
1295/// footprint actually drawn are the same numbers by construction. They used to be computed
1296/// independently (rows here from the pixel height, width hardcoded to 40, and the Kitty
1297/// escape hardcoding a third answer), which is how the logo ended up stretched *and*
1298/// mis-positioned.
1299#[cfg(feature = "graphics")]
1300fn graphical_logo_cells(bytes: &[u8]) -> (usize, usize) {
1301    let (img_w, img_h) = image::load_from_memory(bytes)
1302        .map(|img| (img.width(), img.height()))
1303        .unwrap_or((0, 0));
1304    let fit = logo::logo_cells_for(img_w, img_h);
1305    (fit.cols, fit.rows)
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310    use super::*;
1311
1312    // ── net line selection ────────────────────────────────────────────────────
1313
1314    fn net(name: &str, is_up: bool) -> NetworkInterface {
1315        // The status is COLOURISED, exactly as `detect_networks` builds it, so the literal
1316        // "[Up]" does not appear in the line. That is not incidental detail: the bug being
1317        // guarded is a `line.contains("[Up]")` test that could never match, and a fixture
1318        // with a plain "[Up]" would let that broken predicate pass and prove nothing.
1319        let status = if is_up {
1320            "\x1b[32mUp\x1b[39m"
1321        } else {
1322            "\x1b[31mDown\x1b[39m"
1323        };
1324        NetworkInterface {
1325            name: name.to_string(),
1326            is_up,
1327            line: format!("{name} (10.0.0.1) [{status}] RX: 1.0 MB TX: 1.0 MB"),
1328        }
1329    }
1330
1331    #[test]
1332    fn test_active_interface_is_matched_by_exact_name_not_substring() {
1333        // The Windows case that shipped: an NDIS filter pseudo-interface whose name has
1334        // the real adapter's name as a prefix. Both were previously printed as active.
1335        let nets = vec![
1336            net("Wi-Fi-Native WiFi Filter Driver-0000", true),
1337            net("Wi-Fi", true),
1338        ];
1339        let (active, others) = partition_net_lines(&nets, Some("Wi-Fi"));
1340        assert_eq!(active.len(), 1);
1341        assert_eq!(active[0].name, "Wi-Fi");
1342        assert_eq!(others.len(), 1);
1343        assert_eq!(others[0].name, "Wi-Fi-Native WiFi Filter Driver-0000");
1344    }
1345
1346    #[test]
1347    fn test_active_interface_does_not_match_a_vlan_or_veth_sibling() {
1348        // The same defect on Linux, where it is not hidden by any filtering: a VLAN and a
1349        // veth pair both carry the parent's name as a prefix.
1350        let nets = vec![
1351            net("eth0", true),
1352            net("eth0.100", true),
1353            net("veth0a1b2c3", true),
1354        ];
1355        let (active, others) = partition_net_lines(&nets, Some("eth0"));
1356        assert_eq!(active.len(), 1);
1357        assert_eq!(active[0].name, "eth0");
1358        assert_eq!(others.len(), 2);
1359    }
1360
1361    #[test]
1362    fn test_no_active_interface_means_no_line_is_highlighted() {
1363        let nets = vec![net("eth0", true), net("wlan0", true)];
1364        let (active, others) = partition_net_lines(&nets, None);
1365        assert!(active.is_empty());
1366        assert_eq!(others.len(), 2);
1367    }
1368
1369    #[test]
1370    fn test_standard_mode_prefers_the_active_interface() {
1371        let nets = vec![net("docker0", true), net("wlan0", true)];
1372        let chosen = choose_net_line(&nets, Some("wlan0")).expect("a line");
1373        assert_eq!(chosen.name, "wlan0");
1374    }
1375
1376    #[test]
1377    fn test_standard_mode_falls_back_to_the_first_up_interface() {
1378        // This is the branch that could never fire: it tested the rendered line for the
1379        // literal "[Up]", which is never present because the status is colourised. With
1380        // no active interface, standard mode printed NO Net line at all.
1381        let nets = vec![net("eth0", false), net("wlan0", true), net("eth1", true)];
1382        let chosen = choose_net_line(&nets, None).expect("a line, not None");
1383        assert_eq!(chosen.name, "wlan0");
1384
1385        // Same fallback when the active interface is known but absent from the list.
1386        let chosen = choose_net_line(&nets, Some("ppp0")).expect("a line, not None");
1387        assert_eq!(chosen.name, "wlan0");
1388    }
1389
1390    #[test]
1391    fn test_standard_mode_reports_nothing_when_every_interface_is_down() {
1392        // Under-reporting beats asserting something false: no up interface means no line,
1393        // rather than presenting a down one as the connection.
1394        let nets = vec![net("eth0", false), net("eth1", false)];
1395        assert!(choose_net_line(&nets, None).is_none());
1396    }
1397
1398    // ── wants_ascii_logo ──────────────────────────────────────────────────────
1399
1400    #[test]
1401    fn test_ascii_only_config_selects_the_ascii_logo() {
1402        // The regression: `ascii_only = true` in config.toml was never read here.
1403        assert!(wants_ascii_logo(false, Some(true)));
1404        assert!(wants_ascii_logo(true, None));
1405        assert!(
1406            wants_ascii_logo(true, Some(false)),
1407            "the flag wins over a config false"
1408        );
1409        assert!(!wants_ascii_logo(false, Some(false)));
1410        assert!(!wants_ascii_logo(false, None));
1411    }
1412
1413    // ── should_show_logo ──────────────────────────────────────────────────────
1414
1415    #[test]
1416    fn test_show_logo_auto_requires_tty() {
1417        // Auto mode (no explicit flags): logo only on a TTY.
1418        assert!(should_show_logo(None, false, false, true));
1419        assert!(!should_show_logo(None, false, false, false));
1420    }
1421
1422    #[test]
1423    fn test_show_logo_ascii_forces_without_tty() {
1424        // --ascii-logo forces the logo even when stdout is not a TTY (pipe / CI).
1425        assert!(should_show_logo(None, false, true, false));
1426        assert!(should_show_logo(None, false, true, true));
1427    }
1428
1429    #[test]
1430    fn test_show_logo_no_logo_always_wins() {
1431        // --no-logo suppresses even when --ascii-logo is set or on a TTY.
1432        assert!(!should_show_logo(None, true, true, true));
1433        assert!(!should_show_logo(None, true, false, true));
1434    }
1435
1436    #[test]
1437    fn test_show_logo_config_disable() {
1438        // config show_logo=false suppresses in auto mode...
1439        assert!(!should_show_logo(Some(false), false, false, true));
1440        // ...but an explicit --ascii-logo still forces it on (CLI overrides config default).
1441        assert!(should_show_logo(Some(false), false, true, false));
1442    }
1443
1444    // ── visible_len ───────────────────────────────────────────────────────────
1445
1446    #[test]
1447    fn test_visible_len_strips_every_escape_form_retch_emits() {
1448        // owo_colors' SGR, its default-reset, chafa's private-mode cursor hide, and the
1449        // charset designator. `\x1b[?25l` is the one that bit a measurement harness during
1450        // this work: it is 6 characters and an SGR-only stripper leaves all of them.
1451        assert_eq!(visible_len("plain"), 5);
1452        assert_eq!(visible_len("\x1b[38;2;1;2;3mabc\x1b[39m"), 3);
1453        assert_eq!(visible_len("\x1b[?25labc"), 3);
1454        assert_eq!(visible_len("\x1b(Babc"), 3);
1455        assert_eq!(visible_len("\x1b[0m \x1b[38;2;0;0;0m\u{2582}"), 2);
1456    }
1457
1458    #[test]
1459    fn test_visible_len_counts_columns_not_characters() {
1460        // Regression: this returned a char count, so every wide glyph was undercounted by
1461        // one column. `media`/`player` (v0.8.0) surface arbitrary track metadata, so CJK and
1462        // Hangul are ordinary inputs.
1463        assert_eq!(visible_len("宇多田ヒカル"), 12); // 6 ideographs, 2 columns each
1464        assert_eq!(visible_len("아이유"), 6); // 3 Hangul syllables
1465        assert_eq!(visible_len("Media: 宇多田ヒカル - 花束を君に"), 32);
1466        assert_eq!(visible_len("Media: 아이유 - 밤편지"), 22);
1467
1468        // Combining marks add no width: "cafe" + U+0301 renders as four columns.
1469        assert_eq!(visible_len("cafe\u{301}"), 4);
1470        // Precomposed form measures the same, so the two spellings cannot disagree.
1471        assert_eq!(visible_len("café"), 4);
1472
1473        // A colour-wrapped wide value must measure the same as the bare one — the layout
1474        // sees the wrapped form.
1475        assert_eq!(
1476            visible_len("\x1b[38;2;1;2;3m宇多田\x1b[39m"),
1477            visible_len("宇多田")
1478        );
1479    }
1480
1481    #[test]
1482    fn test_visible_len_ascii_art_and_chafa_symbols_are_one_column_each() {
1483        // Every shipped logo is ASCII or narrow block-drawing, which is why the char-count
1484        // bug never showed on a logo. Pin that, so a future wide-glyph asset fails here
1485        // rather than silently overflowing the right margin.
1486        for line in logo::get_ascii_logo(Some("fedora")) {
1487            let stripped: String = strip_for_test(&line);
1488            assert_eq!(
1489                visible_len(&line),
1490                stripped.chars().count(),
1491                "fedora ASCII logo line is not one column per character: {stripped:?}"
1492            );
1493        }
1494        // Chafa's half-block/quadrant symbols are all narrow.
1495        for sym in [
1496            '\u{2580}', '\u{2584}', '\u{2588}', '\u{258c}', '\u{2596}', '\u{2582}',
1497        ] {
1498            assert_eq!(visible_len(&sym.to_string()), 1, "{sym:?} is not 1 column");
1499        }
1500    }
1501
1502    /// Test-only escape stripper, deliberately independent of [`visible_len`] so the test
1503    /// above compares two different implementations rather than one against itself.
1504    fn strip_for_test(s: &str) -> String {
1505        let mut out = String::new();
1506        let mut in_esc = false;
1507        for c in s.chars() {
1508            if c == '\x1b' {
1509                in_esc = true;
1510            } else if in_esc {
1511                if c.is_ascii_alphabetic() {
1512                    in_esc = false;
1513                }
1514            } else {
1515                out.push(c);
1516            }
1517        }
1518        out
1519    }
1520
1521    // ── wrap_info_line: separator retention and colour carry ──────────────────
1522
1523    /// The shape `Theme::color_value` produces: `<SGR>value<reset>`.
1524    const CYAN: &str = "\x1b[38;2;0;255;255m";
1525    const RESET: &str = "\x1b[39m";
1526
1527    #[test]
1528    fn test_wrap_keeps_the_comma_it_split_on() {
1529        // Regression: the comma was dropped at the break, so a wrapped
1530        // `American Megatrends International, LLC.` read as two separate values. That is a
1531        // change to the data, not to its presentation.
1532        let out = wrap_info_line(
1533            "BIOS: American Megatrends International, LLC. HN7306EAC.310 (8//20/07/0)",
1534            40,
1535        );
1536        assert!(out.len() > 1, "expected a wrap, got {out:?}");
1537        assert!(
1538            out[0].ends_with(','),
1539            "separator lost at the break: {:?}",
1540            out[0]
1541        );
1542        // And nothing is invented or dropped: rejoining recovers the original text.
1543        let rejoined: String = out
1544            .iter()
1545            .map(|l| l.trim_start().to_string())
1546            .collect::<Vec<_>>()
1547            .join(" ");
1548        assert_eq!(
1549            rejoined,
1550            "BIOS: American Megatrends International, LLC. HN7306EAC.310 (8//20/07/0)"
1551        );
1552    }
1553
1554    #[test]
1555    fn test_wrap_reopens_the_colour_on_every_continuation_line() {
1556        // Reported symptom: a wrapped BIOS value rendered its second line in the terminal
1557        // default because the opening SGR stayed on line 1 and the closing reset landed on
1558        // the last line.
1559        let line =
1560            format!("BIOS: {CYAN}American Megatrends International, LLC. HN7306EAC.310{RESET}");
1561        let out = wrap_info_line(&line, 40);
1562        assert!(out.len() > 1, "expected a wrap, got {out:?}");
1563        for (i, l) in out.iter().enumerate().skip(1) {
1564            assert!(
1565                l.contains(CYAN),
1566                "continuation line {i} has no colour: {l:?}"
1567            );
1568        }
1569        // Every line that opens a colour also closes it, so none can bleed into the logo.
1570        for l in &out {
1571            if l.contains(CYAN) {
1572                assert!(l.ends_with(RESET), "colour left open on {l:?}");
1573            }
1574        }
1575    }
1576
1577    #[test]
1578    fn test_wrap_colour_carry_does_not_change_visible_width() {
1579        // The escapes added must be zero-width, or every layout number computed from these
1580        // lines would be wrong.
1581        let plain = "BIOS: American Megatrends International, LLC. HN7306EAC.310";
1582        let coloured =
1583            format!("BIOS: {CYAN}American Megatrends International, LLC. HN7306EAC.310{RESET}");
1584        let a = wrap_info_line(plain, 40);
1585        let b = wrap_info_line(&coloured, 40);
1586        assert_eq!(a.len(), b.len());
1587        for (x, y) in a.iter().zip(b.iter()) {
1588            assert_eq!(visible_len(x), visible_len(y), "{x:?} vs {y:?}");
1589        }
1590    }
1591
1592    #[test]
1593    fn test_wrap_uncoloured_line_is_untouched_by_the_carry() {
1594        let out = wrap_info_line("Disk: aaaa, bbbb, cccc, dddd, eeee, ffff, gggg, hhhh", 24);
1595        assert!(out.len() > 1);
1596        assert!(
1597            out.iter().all(|l| !l.contains('\x1b')),
1598            "carry injected escapes into an uncoloured line: {out:?}"
1599        );
1600    }
1601
1602    #[test]
1603    fn test_active_sgr_after_tracks_open_and_reset() {
1604        assert_eq!(active_sgr_after("plain", None), None);
1605        assert_eq!(active_sgr_after(CYAN, None), Some(CYAN.to_string()));
1606        assert_eq!(active_sgr_after(&format!("{CYAN}x{RESET}"), None), None);
1607        assert_eq!(active_sgr_after("\x1b[0m", Some(CYAN.into())), None);
1608        // Carried in from the previous line and never reset here.
1609        assert_eq!(
1610            active_sgr_after("more text", Some(CYAN.into())),
1611            Some(CYAN.to_string())
1612        );
1613        // A non-`m` sequence (chafa's cursor hide) must not disturb the colour state.
1614        assert_eq!(
1615            active_sgr_after("\x1b[?25l", Some(CYAN.into())),
1616            Some(CYAN.to_string())
1617        );
1618    }
1619
1620    #[test]
1621    fn test_active_sgr_after_takes_the_last_colour_when_nested() {
1622        // The `Net` line embeds a green Up inside the value colour (v0.5.1). Whatever the
1623        // nesting, the state at end-of-line is simply the last sequence seen.
1624        let green = "\x1b[32m";
1625        let s = format!("{CYAN}[{green}Up{RESET}] RX: 1 MB");
1626        assert_eq!(active_sgr_after(&s, None), None); // last was the reset
1627        let s2 = format!("{CYAN}[{green}Up{RESET}]{CYAN} RX: 1 MB");
1628        assert_eq!(active_sgr_after(&s2, None), Some(CYAN.to_string()));
1629    }
1630
1631    // ── compose_side_by_side_row ──────────────────────────────────────────────
1632
1633    #[test]
1634    fn test_row_places_the_logo_at_the_logo_column() {
1635        let row = compose_side_by_side_row("OS: Fedora", "###", 20);
1636        assert_eq!(row, format!("OS: Fedora{}###", " ".repeat(10)));
1637        assert_eq!(visible_len(&row), 23);
1638    }
1639
1640    #[test]
1641    fn test_row_aligns_wide_characters_by_column_not_character_count() {
1642        // The regression that hid behind a shadowed `visible_len`: the layout measured
1643        // characters while the module function measured columns, so a CJK value pushed the
1644        // logo right by one column per wide glyph. Both rows below must put the logo at
1645        // exactly the same column.
1646        let latin = compose_side_by_side_row("Locale: en_US.UTF-8", "###", 40);
1647        let cjk = compose_side_by_side_row("Locale: ja_JP.宇多田ヒカル", "###", 40);
1648        assert_eq!(visible_len(&latin), 43);
1649        assert_eq!(
1650            visible_len(&cjk),
1651            43,
1652            "a wide-character info line must not shift the logo column"
1653        );
1654        // And the logo really is at column 40 in both, not merely the same total width.
1655        assert!(latin.ends_with("   ###") && cjk.ends_with("  ###"));
1656    }
1657
1658    #[test]
1659    fn test_row_without_a_logo_gets_no_trailing_padding() {
1660        // Lines below the logo would otherwise carry ~90 trailing spaces each.
1661        assert_eq!(compose_side_by_side_row("Net: eth0", "", 40), "Net: eth0");
1662    }
1663
1664    #[test]
1665    fn test_row_with_overlong_info_does_not_underflow() {
1666        // An info line wider than the logo column must not panic on the subtraction.
1667        let row = compose_side_by_side_row("x".repeat(50).as_str(), "###", 40);
1668        assert_eq!(row, format!("{}###", "x".repeat(50)));
1669    }
1670
1671    #[test]
1672    fn test_row_ignores_ansi_colour_when_measuring() {
1673        let plain = compose_side_by_side_row("abc", "###", 10);
1674        let coloured = compose_side_by_side_row("\x1b[31mabc\x1b[39m", "###", 10);
1675        assert_eq!(visible_len(&plain), visible_len(&coloured));
1676    }
1677
1678    // ── plan_layout ───────────────────────────────────────────────────────────
1679
1680    // A ~20-row logo with the widest beside-logo line = 54 (e.g. the CPU line), then a very
1681    // long Wi-Fi line (158) far below it — the real --full shape on this hardware.
1682    fn realistic_full_widths() -> Vec<usize> {
1683        let mut w = vec![40; 20]; // rows 0..20 sit beside the logo
1684        w[13] = 54; // CPU line, still beside the logo
1685        w.extend([158, 91, 79, 60, 45, 62]); // Wi-Fi/Net/Battery/etc., all BELOW the logo
1686        w
1687    }
1688
1689    #[test]
1690    fn test_layout_long_line_below_logo_stays_side_by_side() {
1691        // The 158-wide Wi-Fi line is below the 20-row logo, so it must NOT force a stack.
1692        let p = plan_layout(&realistic_full_widths(), 20, 40, 120, true);
1693        assert!(p.side_by_side);
1694        // Text column is driven by the widest BESIDE-logo line (54), not the 158 below it.
1695        assert_eq!(p.text_column_width, 58); // 54 + 4
1696    }
1697
1698    #[test]
1699    fn test_layout_old_behavior_would_have_stacked() {
1700        // Sanity: the pre-fix rule (widest of ALL lines) would need 158+4+40 = 202 cols and
1701        // stack at 120. Confirm the *new* rule does not, on the same inputs.
1702        let widths = realistic_full_widths();
1703        let old_text_col = std::cmp::max(widths.iter().copied().max().unwrap() + 4, 45);
1704        assert!(120 < old_text_col + 40); // old rule: stacked
1705        assert!(plan_layout(&widths, 20, 40, 120, true).side_by_side); // new rule: side-by-side
1706    }
1707
1708    #[test]
1709    fn test_layout_long_line_within_logo_wraps_and_stays_side_by_side() {
1710        // A 158-wide line among the first `logo_height` rows no longer breaks side-by-side layout
1711        // because text_column_width is clamped and the line is wrapped.
1712        let mut w = vec![40; 20];
1713        w[5] = 158;
1714        let p = plan_layout(&w, 20, 40, 120, true);
1715        assert!(p.side_by_side);
1716        assert_eq!(p.text_column_width, 65);
1717    }
1718
1719    #[test]
1720    fn test_layout_narrow_terminal_stacks() {
1721        assert!(!plan_layout(&[40; 30], 20, 40, 94, true).side_by_side); // < 95 hard floor
1722        assert!(!plan_layout(&[40; 30], 20, 40, 80, true).side_by_side);
1723    }
1724
1725    #[test]
1726    fn test_layout_show_logo_false_stacks() {
1727        assert!(!plan_layout(&[40; 30], 20, 40, 200, false).side_by_side);
1728    }
1729
1730    #[test]
1731    fn test_layout_column_floor_and_graphical_width() {
1732        // Tiny lines → text column floored at 45; graphical logo width (40) still applies.
1733        let p = plan_layout(&[10; 25], 20, 40, 100, true);
1734        assert!(p.side_by_side);
1735        assert_eq!(p.text_column_width, 45); // max(10+4, 45)
1736    }
1737
1738    #[test]
1739    fn test_layout_widened_logo_box_still_fits_at_the_side_by_side_threshold() {
1740        // The logo cell box grew from 28 to `logo::LOGO_MAX_COLS` (45) so wide-aspect logos get
1741        // enough rows to stay legible. That must not cost the side-by-side layout at the 95-col
1742        // threshold: the text column floors at 45, and 45 + 45 = 90 <= 95.
1743        let p = plan_layout(&[10; 25], 10, logo::LOGO_MAX_COLS, 95, true);
1744        assert!(
1745            p.side_by_side,
1746            "a full-width logo must still sit beside the text at 95 columns"
1747        );
1748        assert!(p.text_column_width + logo::LOGO_MAX_COLS <= 95);
1749
1750        // And a wide terminal is unaffected — the text column still reaches its 65 cap.
1751        let wide = plan_layout(&[120; 25], 10, logo::LOGO_MAX_COLS, 169, true);
1752        assert!(wide.side_by_side);
1753        assert_eq!(wide.text_column_width, 65);
1754    }
1755
1756    #[test]
1757    fn test_layout_logo_taller_than_text() {
1758        // Fewer info lines than logo rows: all lines are beside the logo (no panic on slice).
1759        let p = plan_layout(&[50, 30, 54], 20, 40, 120, true);
1760        assert!(p.side_by_side);
1761        assert_eq!(p.text_column_width, 58); // widest of the 3 (54) + 4
1762    }
1763
1764    #[test]
1765    fn test_layout_logo_is_flush_with_the_right_margin() {
1766        // The drift this fixes: on a wide terminal the logo used to be drawn at
1767        // `text_column_width` (capped at 65), stranding everything to its right. Measured on
1768        // arrakis at 138 columns with the 49-wide Windows ASCII logo: output stopped at
1769        // column 103, leaving 35 dead columns.
1770        let p = plan_layout(&realistic_full_widths(), 20, 49, 138, true);
1771        assert!(p.side_by_side);
1772        assert_eq!(p.text_column_width, 58); // unchanged: still driven by the beside lines
1773        assert_eq!(p.logo_column, 138 - 49); // logo now ends exactly at the right margin
1774        assert!(
1775            p.logo_column > p.text_column_width,
1776            "the pre-fix behaviour was logo_column == text_column_width"
1777        );
1778    }
1779
1780    #[test]
1781    fn test_layout_right_anchor_never_overlaps_the_text_column() {
1782        // At the 95-column threshold with a full-width logo the two columns meet exactly;
1783        // the logo must never be pulled left of where beside-logo text can reach.
1784        for term_width in 95..200 {
1785            let p = plan_layout(&[120; 25], 10, logo::LOGO_MAX_COLS, term_width, true);
1786            if p.side_by_side {
1787                assert!(
1788                    p.logo_column >= p.text_column_width,
1789                    "logo_column {} < text_column_width {} at {} cols",
1790                    p.logo_column,
1791                    p.text_column_width,
1792                    term_width
1793                );
1794                assert_eq!(p.logo_column + logo::LOGO_MAX_COLS, term_width);
1795            }
1796        }
1797    }
1798
1799    #[test]
1800    fn test_layout_logo_column_does_not_underflow_on_an_oversized_logo() {
1801        // A logo wider than the terminal stacks, and the (unused) column must not underflow.
1802        let p = plan_layout(&[40; 10], 10, 200, 100, true);
1803        assert!(!p.side_by_side);
1804        assert_eq!(p.logo_column, p.text_column_width);
1805    }
1806
1807    // ── graphical_side_by_side_prelude ────────────────────────────────────────
1808
1809    #[test]
1810    fn test_prelude_reserves_rows_before_saving_cursor() {
1811        // Regression for the below-the-logo bug (Rio/kitty, prompt at the bottom row): the
1812        // scroll-forcing reservation (newlines) and the cursor-up must both come BEFORE the
1813        // cursor save, so nothing between save and restore can scroll.
1814        let p = graphical_side_by_side_prelude(52, 3);
1815        assert_eq!(p, "\n\n\n\x1b[3A\x1b[52C\x1b7");
1816    }
1817
1818    #[test]
1819    fn test_prelude_v068_shape_only_differs_by_reservation() {
1820        // With the reservation stripped, the prelude is exactly the v0.6.8 bytes — the fresh
1821        // top-of-screen rendering (where no scroll happens) is unchanged.
1822        let p = graphical_side_by_side_prelude(45, 20);
1823        assert_eq!(
1824            p.replace(&format!("{}\x1b[20A", "\n".repeat(20)), ""),
1825            "\x1b[45C\x1b7"
1826        );
1827    }
1828
1829    #[test]
1830    fn test_prelude_zero_rows_skips_reservation_and_cursor_up() {
1831        // CSI 0 A still moves one row on real terminals, so logo_rows == 0 must emit
1832        // neither the reservation nor the cursor-up.
1833        let p = graphical_side_by_side_prelude(45, 0);
1834        assert_eq!(p, "\x1b[45C\x1b7");
1835    }
1836
1837    // ── split_wifi_line ───────────────────────────────────────────────────────
1838
1839    #[test]
1840    fn test_split_wifi_hardware_and_connection() {
1841        // The real `iw`-path shape: "{adapter} [{iface}] - {ssid} ({details})".
1842        let s = "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0] - myssid (5.0 GHz ch36 [↓866 ↑866])";
1843        let (hw, conn) = split_wifi_line(s);
1844        assert_eq!(
1845            hw,
1846            "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0]"
1847        );
1848        assert_eq!(conn, Some("myssid (5.0 GHz ch36 [↓866 ↑866])"));
1849    }
1850
1851    #[test]
1852    fn test_split_wifi_splits_on_first_separator() {
1853        // Only the first " - " (the hardware|connection boundary) splits; a " - " inside the
1854        // SSID/details stays with the connection.
1855        let (hw, conn) = split_wifi_line("Card X [wlan0] - Guest - 5G (5 GHz)");
1856        assert_eq!(hw, "Card X [wlan0]");
1857        assert_eq!(conn, Some("Guest - 5G (5 GHz)"));
1858    }
1859
1860    #[test]
1861    fn test_split_wifi_connection_only_fallback() {
1862        // Fallback detectors (nmcli/iwgetid/macOS/Windows) have no " - " → single line.
1863        let (hw, conn) = split_wifi_line("myssid (300 Mbps)");
1864        assert_eq!(hw, "myssid (300 Mbps)");
1865        assert_eq!(conn, None);
1866    }
1867
1868    #[test]
1869    fn test_consolidate_temps_basic() {
1870        let raw = vec![
1871            "k10temp Tctl: 83°C".to_string(),
1872            "amdgpu edge: 65°C".to_string(),
1873            "nvme Composite: 62°C".to_string(),
1874            "ath11k_hwmon temp1: 58°C".to_string(),
1875            "acpitz temp1: 77°C".to_string(),
1876        ];
1877        let result = consolidate_temps(&raw);
1878        assert_eq!(
1879            result,
1880            vec![
1881                "CPU: 83°C",
1882                "GPU: 65°C",
1883                "NVMe: 62°C",
1884                "WiFi: 58°C",
1885                "System: 77°C"
1886            ]
1887        );
1888    }
1889
1890    #[test]
1891    fn test_consolidate_temps_highest_wins() {
1892        let raw = vec![
1893            "thinkpad CPU: 83°C".to_string(),
1894            "k10temp Tctl: 79°C".to_string(),
1895            "nvme Composite: 62°C".to_string(),
1896            "nvme Sensor 1: 59°C".to_string(),
1897            "nvme Sensor 2: 56°C".to_string(),
1898        ];
1899        let result = consolidate_temps(&raw);
1900        assert!(result.contains(&"CPU: 83°C".to_string()));
1901        assert!(result.contains(&"NVMe: 62°C".to_string()));
1902        assert!(!result
1903            .iter()
1904            .any(|s| s.contains("79") || s.contains("59") || s.contains("56")));
1905    }
1906
1907    #[test]
1908    fn test_consolidate_temps_order() {
1909        let raw = vec![
1910            "acpitz: 60°C".to_string(),
1911            "nvme: 55°C".to_string(),
1912            "amdgpu edge: 65°C".to_string(),
1913            "k10temp Tctl: 80°C".to_string(),
1914        ];
1915        let result = consolidate_temps(&raw);
1916        let cpu_pos = result.iter().position(|s| s.starts_with("CPU"));
1917        let gpu_pos = result.iter().position(|s| s.starts_with("GPU"));
1918        let nvme_pos = result.iter().position(|s| s.starts_with("NVMe"));
1919        let sys_pos = result.iter().position(|s| s.starts_with("System"));
1920        assert!(cpu_pos < gpu_pos);
1921        assert!(gpu_pos < nvme_pos);
1922        assert!(nvme_pos < sys_pos);
1923    }
1924
1925    #[test]
1926    fn test_consolidate_temps_empty() {
1927        assert!(consolidate_temps(&[]).is_empty());
1928    }
1929
1930    #[test]
1931    fn test_format_uptime() {
1932        assert_eq!(format_uptime("60s"), "1m");
1933        assert_eq!(format_uptime("3600s"), "1h");
1934        assert_eq!(format_uptime("3661s"), "1h 1m 1s");
1935        assert_eq!(format_uptime("86400s"), "1d");
1936        assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
1937        assert_eq!(format_uptime("31536000s"), "1y");
1938        assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
1939        assert_eq!(format_uptime("0s"), "0s");
1940    }
1941
1942    #[test]
1943    fn test_wrap_info_line_short_line_unchanged() {
1944        let line = "Audio: Windows Audio (USB Audio Device)";
1945        let wrapped = wrap_info_line(line, 50);
1946        assert_eq!(wrapped, vec![line.to_string()]);
1947    }
1948
1949    #[test]
1950    fn test_wrap_info_line_wraps_and_indents() {
1951        let line = "Audio: Windows Audio (USB Audio Device, AMD High Definition Audio Device, AMD SoundWire Device)";
1952        let wrapped = wrap_info_line(line, 45);
1953        assert!(wrapped.len() > 1);
1954        assert!(wrapped[0].starts_with("Audio: Windows Audio"));
1955        assert!(wrapped[1].starts_with("       "));
1956    }
1957
1958    // ── colour on/off ─────────────────────────────────────────────────────────
1959
1960    #[test]
1961    fn test_should_use_color_explicit_choice_beats_everything() {
1962        let set = Some(std::ffi::OsStr::new("1"));
1963        for tty in [true, false] {
1964            for env in [None, set] {
1965                assert!(should_use_color(Some(ColorChoice::Always), env, tty));
1966                assert!(!should_use_color(Some(ColorChoice::Never), env, tty));
1967            }
1968        }
1969    }
1970
1971    #[test]
1972    fn test_should_use_color_auto_needs_tty_and_no_no_color() {
1973        for choice in [None, Some(ColorChoice::Auto)] {
1974            assert!(should_use_color(choice, None, true));
1975            assert!(!should_use_color(choice, None, false), "piped output");
1976            assert!(
1977                !should_use_color(choice, Some(std::ffi::OsStr::new("1")), true),
1978                "NO_COLOR set"
1979            );
1980            // no-color.org: "present and not an empty string". An empty value is not a
1981            // request, so colour stays on.
1982            assert!(
1983                should_use_color(choice, Some(std::ffi::OsStr::new("")), true),
1984                "NO_COLOR empty"
1985            );
1986        }
1987    }
1988
1989    #[test]
1990    fn test_strip_sgr_removes_every_colour_form() {
1991        let theme = Theme::neutral();
1992        // A real print_line row: truecolor label, separator and value, with the library's
1993        // basic-ANSI green "Up" nested inside and the bright-blue active-interface wrap.
1994        let net = colorize_nested(
1995            &format!("eth0 [{}] RX: 1 GB", "\x1b[32mUp\x1b[39m"),
1996            ACTIVE_IFACE_PREFIX,
1997        );
1998        let row = format!(
1999            "{}{} {}",
2000            theme.color_label("Net"),
2001            theme.color_separator(":"),
2002            theme.color_value(&net)
2003        );
2004        assert_eq!(strip_sgr(&row), "Net: eth0 [Up] RX: 1 GB");
2005        // The ASCII logo's forms: 256-colour, and the bare `ESC[m` / `ESC[0m` resets.
2006        assert_eq!(strip_sgr("\x1b[38;5;252m/\\\x1b[0m\x1b[m"), "/\\");
2007    }
2008
2009    #[test]
2010    fn test_strip_sgr_keeps_everything_that_is_not_sgr() {
2011        // Not colour, so not ours to remove: a cursor move, chafa's cursor-hide (the `?`
2012        // makes it non-SGR), and a lone ESC at the end of the string.
2013        for s in ["\x1b[5Cx", "\x1b[?25lx", "x\x1b", "\x1b[", "plain 宇多田"] {
2014            assert_eq!(strip_sgr(s), s);
2015        }
2016    }
2017
2018    #[test]
2019    fn test_strip_sgr_preserves_visible_width() {
2020        // Stripping must not move anything: the layout is computed from visible widths.
2021        let theme = Theme::neutral();
2022        let row = format!(
2023            "{:>10}{} {}",
2024            theme.color_label("Media"),
2025            theme.color_separator(":"),
2026            theme.color_value("宇多田ヒカル - 花束を君に")
2027        );
2028        assert_eq!(visible_len(&strip_sgr(&row)), visible_len(&row));
2029    }
2030}