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