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};
14
15/// Decide whether to render a logo at all.
16///
17/// In *auto* mode the logo is shown only when stdout is a TTY: the graphical and
18/// Chafa heuristics (and the side-by-side layout) are meaningless when output is
19/// piped or redirected, so we suppress the logo there. Two explicit overrides break
20/// that rule:
21/// - `no_logo` (from `--no-logo` or config) always wins → no logo.
22/// - `ascii_logo` (from `--ascii-logo`) forces the logo on **even without a TTY**:
23///   ASCII art is plain, pipe-safe text, so a caller (e.g. `retch --ascii-logo | cat`,
24///   or CI's `full-test` dry run) that explicitly asks for it should get it — mirroring
25///   how `--no-logo` is honored regardless of TTY. `--chafa-logo`/graphical modes are
26///   deliberately NOT forced here, since they emit terminal-specific control sequences
27///   that are only meaningful on a real terminal.
28fn should_show_logo(
29    config_show_logo: Option<bool>,
30    no_logo: bool,
31    ascii_logo: bool,
32    stdout_is_tty: bool,
33) -> bool {
34    if no_logo {
35        return false; // explicit suppression always wins
36    }
37    if ascii_logo {
38        return true; // explicit ASCII request forces the logo on, TTY or not, config or not
39    }
40    config_show_logo.unwrap_or(true) && stdout_is_tty // auto mode: default-on, but TTY-gated
41}
42
43/// Renders the collected system information to the terminal.
44///
45/// This function handles theme selection, logo rendering (including fallbacks
46/// between graphics, Chafa, and ASCII), and field filtering based on
47/// CLI flags and configuration.
48pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result<()> {
49    let _config = config;
50    let theme_name = _config.theme.as_deref().or(cli.theme.as_deref());
51    let mut theme = match theme_name {
52        Some(name) => Theme::from_name(name),
53        None => Theme::detect_system_theme(), // Default to system preference
54    };
55
56    // Apply custom theme overrides from config if present
57    if let Some(custom) = &_config.custom_theme {
58        theme = Theme::with_custom_overrides(theme, custom);
59    }
60
61    // Determine terminal width.
62    let term_size = terminal_size::terminal_size();
63    let term_width = if let Some((terminal_size::Width(w), _)) = term_size {
64        w as usize
65    } else {
66        80
67    };
68    // Use isatty() directly — terminal_size() can return Some() when a pager
69    // (e.g. bat) allocates a PTY, giving a false positive.
70    let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
71
72    let show_logo = should_show_logo(
73        _config.show_logo,
74        cli.no_logo,
75        cli.ascii_logo,
76        stdout_is_tty,
77    );
78
79    // Determine which fields to show. Strata allow-lists are derived from the
80    // single field registry (src/fields.rs) — the same source `main.rs` uses for
81    // collection, so display and collection can no longer drift apart. An explicit
82    // `config.fields` list bypasses the strata.
83    let allowed_fields: Option<Vec<String>> = if cli.full {
84        Some(fields::fields_for(Mode::Full))
85    } else if cli.long {
86        Some(fields::fields_for(Mode::Long))
87    } else if cli.short {
88        Some(fields::fields_for(Mode::Short))
89    } else if let Some(fields) = &_config.fields {
90        Some(fields.iter().map(|s| s.to_lowercase()).collect())
91    } else {
92        Some(fields::fields_for(Mode::Standard))
93    };
94
95    let should_show = |label: &str| -> bool {
96        match &allowed_fields {
97            Some(fields) => {
98                let norm_label = label.to_lowercase().replace(['-', '_'], " ");
99                let norm_label_no_spaces = norm_label.replace(' ', "");
100                fields.iter().any(|f| {
101                    let norm_f = f.to_lowercase().replace(['-', '_'], " ");
102                    norm_f == norm_label
103                        || norm_f.replace(' ', "") == norm_label_no_spaces
104                        // "dns" field key matches "DNS Server" display label
105                        || (norm_label == "dns server" && norm_f == "dns")
106                        // "memory" field key matches "Memory Usage" display label
107                        || (norm_label == "memory usage" && norm_f == "memory")
108                })
109            }
110            None => true,
111        }
112    };
113
114    // Helper for right-aligned labels
115    let label_width = 10;
116    let mut info_lines = Vec::new();
117    let mut print_line = |label: &str, value: &str| {
118        if should_show(label) {
119            info_lines.push(format!(
120                "{:>width$}{} {}",
121                theme.color_label(label),
122                theme.color_separator(":"),
123                theme.color_value(value),
124                width = label_width
125            ));
126        }
127    };
128
129    // OS / system identity
130    print_line("OS", &info.os);
131    if let Some(kernel) = &info.kernel {
132        print_line("Kernel", kernel);
133    }
134    if let Some(host) = &info.hostname {
135        print_line("Host", host);
136    }
137    if let Some(domain) = &info.domain {
138        print_line("Domain", domain);
139    }
140    if should_show("domain-search") {
141        for entry in &info.domain_search {
142            print_line("Domain Search", entry);
143        }
144    }
145    if let Some(chassis) = &info.chassis {
146        print_line("Chassis", chassis);
147    }
148    if let Some(init) = &info.init_system {
149        print_line("Init", init);
150    }
151    if let Some(locale) = &info.locale {
152        print_line("Locale", locale);
153    }
154    print_line("Arch", &info.arch);
155    // Suppress "Users: 0" — a 0 means the count couldn't be determined (e.g. the Unix
156    // uid>=1000 heuristic on a platform that keys users differently), not that nobody is
157    // logged in. Mirrors the `packages` guard below.
158    if info.users > 0 {
159        print_line("Users", &info.users.to_string());
160    }
161    if let Some(pkgs) = info.packages {
162        if pkgs > 0 {
163            print_line("Packages", &pkgs.to_string());
164        }
165    }
166    if let Some(user) = &info.current_user {
167        print_line("User", user);
168    }
169    // Uptime belongs with system identity, not hardware
170    let uptime_str = format_uptime(&info.uptime);
171    let boot_display = format!("{} since {}", uptime_str, info.boot_time);
172    print_line("Uptime", &boot_display);
173
174    // Hardware
175    print_line("CPU", &format!("{} ({})", info.cpu, info.cpu_core_info));
176    if let Some(freq) = &info.cpu_freq {
177        print_line("CPU Freq", freq);
178    }
179    if let Some(cache) = &info.cpu_cache {
180        print_line("CPU Cache", cache);
181    }
182    if let Some(usage) = &info.cpu_usage {
183        print_line("CPU Usage", usage);
184    }
185    if let Some(motherboard) = &info.motherboard {
186        print_line("Motherboard", motherboard);
187    }
188    if let Some(bios) = &info.bios {
189        print_line("BIOS", bios);
190    }
191    if let Some(bootmgr) = &info.bootmgr {
192        print_line("Bootmgr", bootmgr);
193    }
194    if should_show("GPU") {
195        for gpu in &info.gpu {
196            print_line("GPU", gpu);
197        }
198    }
199    if should_show("Display") {
200        for display in &info.displays {
201            print_line("Display", display);
202        }
203    }
204    if let Some(brightness) = &info.brightness {
205        print_line("Brightness", brightness);
206    }
207    if let Some(audio) = &info.audio {
208        print_line("Audio", audio);
209    }
210    if should_show("Camera") {
211        for cam in &info.camera {
212            print_line("Camera", cam);
213        }
214    }
215    if should_show("Gamepad") {
216        for gp in &info.gamepad {
217            print_line("Gamepad", gp);
218        }
219    }
220    if let Some(wifi) = &info.wifi {
221        print_line("Wi-Fi", wifi);
222    }
223    if let Some(bt) = &info.bluetooth {
224        print_line("Bluetooth", bt);
225    }
226    if let Some(bat) = &info.battery {
227        print_line("Battery", bat);
228    }
229    if let Some(power) = &info.power_adapter {
230        print_line("Power Adapter", power);
231    }
232    print_line("Memory Usage", &info.memory);
233    if let Some(phys_mem) = &info.physical_memory {
234        print_line("Phys Mem", phys_mem);
235    }
236    print_line("Swap", &info.swap);
237    print_line("Procs", &info.processes.to_string());
238    if let Some(load) = &info.load_avg {
239        print_line("Load", load);
240    }
241    if should_show("Disk") {
242        for disk in &info.disks {
243            print_line("Disk", disk);
244        }
245    }
246    if should_show("Phys Disk") {
247        for disk in &info.physical_disks {
248            print_line("Phys Disk", disk);
249        }
250    }
251    if should_show("Btrfs") {
252        for vol in &info.btrfs {
253            print_line("Btrfs", vol);
254        }
255    }
256    if should_show("Zpool") {
257        for pool in &info.zpool {
258            print_line("Zpool", pool);
259        }
260    }
261    if should_show("Temp") {
262        if cli.full {
263            for temp in &info.temps {
264                print_line("Temp", temp);
265            }
266        } else {
267            for temp in consolidate_temps(&info.temps) {
268                print_line("Temp", &temp);
269            }
270        }
271    }
272
273    // Network
274    if should_show("Net") {
275        if cli.long || cli.full {
276            for net in &info.networks {
277                if let Some(ref active) = info.active_interface {
278                    if net.contains(active) {
279                        // Re-assert bright blue after the nested green "Up" /
280                        // red "Down" reset so the whole active line stays blue
281                        // (brackets and RX/TX included), not just up to "[".
282                        print_line("Net", &colorize_nested(net, ACTIVE_IFACE_PREFIX));
283                    }
284                }
285            }
286            for net in &info.networks {
287                if let Some(ref active) = info.active_interface {
288                    if net.contains(active) {
289                        continue;
290                    }
291                }
292                print_line("Net", net);
293            }
294        } else {
295            let mut printed = false;
296            if let Some(ref active) = info.active_interface {
297                for net in &info.networks {
298                    if net.contains(active) {
299                        print_line("Net", net);
300                        printed = true;
301                        break;
302                    }
303                }
304            }
305            if !printed {
306                for net in &info.networks {
307                    if net.contains("[Up]") {
308                        print_line("Net", net);
309                        break;
310                    }
311                }
312            }
313        }
314    }
315    if let Some(ip) = &info.public_ip {
316        print_line("Public IP", ip);
317    }
318    if !info.dns.is_empty() {
319        print_line("DNS Server", &info.dns.join(", "));
320    }
321
322    // Environment
323    if let Some(shell) = &info.shell {
324        print_line("Shell", shell);
325    }
326    if let Some(editor) = &info.editor {
327        print_line("Editor", editor);
328    }
329    if let Some(term) = &info.terminal {
330        print_line("Terminal", term);
331    }
332    if let Some(ts) = &info.terminal_size {
333        print_line("Terminal Size", ts);
334    }
335    if let Some(de) = &info.desktop {
336        print_line("Desktop", de);
337    }
338    if let Some(wm) = &info.wm {
339        let duplicate = info
340            .desktop
341            .as_deref()
342            .map(|de| de.to_lowercase() == wm.to_lowercase())
343            .unwrap_or(false);
344        if !duplicate {
345            print_line("WM", wm);
346        }
347    }
348    if let Some(lm) = &info.login_manager {
349        print_line("Login Manager", lm);
350    }
351    if let Some(ui_theme) = &info.ui_theme {
352        print_line("Theme", ui_theme);
353    }
354    if let Some(icons) = &info.icons {
355        print_line("Icons", icons);
356    }
357    if let Some(cursor) = &info.cursor {
358        print_line("Cursor", cursor);
359    }
360    if let Some(font) = &info.font {
361        print_line("Font", font);
362    }
363    if let Some(term_font) = &info.terminal_font {
364        print_line("Terminal Font", term_font);
365    }
366    if let Some(weather) = &info.weather {
367        print_line("Weather", weather);
368    }
369
370    // Setup logo representation
371    enum ActiveLogo {
372        Lines(Vec<String>),
373        Kitty(Vec<u8>, usize), // bytes, height_lines
374        Iterm2(Vec<u8>, usize),
375        Sixel(Vec<u8>, usize),
376        None,
377    }
378
379    let mut active_logo = ActiveLogo::None;
380
381    if show_logo {
382        let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
383        let user_logo = if let Some(config_dir) = dirs::config_dir() {
384            let p = config_dir.join("retch").join("logo.png");
385            if p.exists() {
386                Some(p)
387            } else {
388                None
389            }
390        } else {
391            None
392        };
393
394        if cli.ascii_logo {
395            active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
396        } else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
397            let mut resolved = false;
398            if logo::chafa_available() {
399                if let Some(path) = &user_logo {
400                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
401                        active_logo = ActiveLogo::Lines(lines);
402                        resolved = true;
403                    }
404                } else if let Some(distro) = &distro_hint {
405                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
406                        let temp_path = std::env::temp_dir()
407                            .join(format!("retch_logo_{}.png", std::process::id()));
408                        if std::fs::write(&temp_path, bytes).is_ok() {
409                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
410                                active_logo = ActiveLogo::Lines(lines);
411                                resolved = true;
412                            }
413                            let _ = std::fs::remove_file(&temp_path);
414                        }
415                    }
416                }
417            }
418            if !resolved {
419                active_logo =
420                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
421            }
422        } else {
423            let mut resolved = false;
424
425            // Kitty
426            #[cfg(feature = "graphics")]
427            if !resolved && logo::supports_kitty() {
428                if let Some(path) = &user_logo {
429                    if let Ok(bytes) = std::fs::read(path) {
430                        let h = graphical_logo_height_lines(&bytes);
431                        active_logo = ActiveLogo::Kitty(bytes, h);
432                        resolved = true;
433                    }
434                } else if let Some(distro) = &distro_hint {
435                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
436                        let h = graphical_logo_height_lines(bytes);
437                        active_logo = ActiveLogo::Kitty(bytes.to_vec(), h);
438                        resolved = true;
439                    }
440                }
441            }
442
443            // iTerm2
444            #[cfg(feature = "graphics")]
445            if !resolved && logo::supports_iterm2() {
446                if let Some(path) = &user_logo {
447                    if let Ok(bytes) = std::fs::read(path) {
448                        let h = graphical_logo_height_lines(&bytes);
449                        active_logo = ActiveLogo::Iterm2(bytes, h);
450                        resolved = true;
451                    }
452                } else if let Some(distro) = &distro_hint {
453                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
454                        let h = graphical_logo_height_lines(bytes);
455                        active_logo = ActiveLogo::Iterm2(bytes.to_vec(), h);
456                        resolved = true;
457                    }
458                }
459            }
460
461            // Sixel
462            #[cfg(feature = "graphics")]
463            if !resolved && logo::supports_sixel() {
464                if let Some(path) = &user_logo {
465                    if let Ok(bytes) = std::fs::read(path) {
466                        let h = graphical_logo_height_lines(&bytes);
467                        active_logo = ActiveLogo::Sixel(bytes, h);
468                        resolved = true;
469                    }
470                } else if let Some(distro) = &distro_hint {
471                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
472                        let h = graphical_logo_height_lines(bytes);
473                        active_logo = ActiveLogo::Sixel(bytes.to_vec(), h);
474                        resolved = true;
475                    }
476                }
477            }
478
479            // Chafa
480            if !resolved && logo::chafa_available() {
481                if let Some(path) = &user_logo {
482                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
483                        active_logo = ActiveLogo::Lines(lines);
484                        resolved = true;
485                    }
486                } else if let Some(distro) = &distro_hint {
487                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
488                        // Write temp logo and read lines via chafa
489                        let temp_path = std::env::temp_dir()
490                            .join(format!("retch_logo_{}.png", std::process::id()));
491                        if std::fs::write(&temp_path, bytes).is_ok() {
492                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
493                                active_logo = ActiveLogo::Lines(lines);
494                                resolved = true;
495                            }
496                            let _ = std::fs::remove_file(&temp_path);
497                        }
498                    }
499                }
500            }
501
502            // Fallback to ASCII lines
503            if !resolved {
504                active_logo =
505                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
506            }
507        }
508    }
509
510    // Helper to strip ANSI codes and calculate visible length
511    let visible_len = |s: &str| -> usize {
512        let mut count = 0;
513        let mut in_esc = false;
514        for c in s.chars() {
515            if c == '\x1b' {
516                in_esc = true;
517            } else if in_esc {
518                if c.is_ascii_alphabetic() {
519                    in_esc = false;
520                }
521            } else {
522                count += 1;
523            }
524        }
525        count
526    };
527
528    let max_text_width = info_lines
529        .iter()
530        .map(|line| visible_len(line))
531        .max()
532        .unwrap_or(0);
533    let text_column_width = std::cmp::max(max_text_width + 4, 45);
534
535    let max_logo_width = match &active_logo {
536        ActiveLogo::Lines(logo_lines) => logo_lines
537            .iter()
538            .map(|line| visible_len(line))
539            .max()
540            .unwrap_or(0),
541        ActiveLogo::Kitty(_, _) | ActiveLogo::Iterm2(_, _) | ActiveLogo::Sixel(_, _) => 40,
542        ActiveLogo::None => 0,
543    };
544
545    let side_by_side =
546        show_logo && term_width >= 95 && term_width >= (text_column_width + max_logo_width);
547
548    println!(); // leading newline
549
550    if side_by_side {
551        match active_logo {
552            ActiveLogo::Lines(logo_lines) => {
553                let max_lines = std::cmp::max(info_lines.len(), logo_lines.len());
554                for i in 0..max_lines {
555                    let info_line = info_lines.get(i).cloned().unwrap_or_default();
556                    let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
557                    let vis_len = visible_len(&info_line);
558                    let padding = if vis_len < text_column_width {
559                        " ".repeat(text_column_width - vis_len)
560                    } else {
561                        String::new()
562                    };
563                    println!("{}{}{}", info_line, padding, logo_line);
564                }
565            }
566            ActiveLogo::Kitty(bytes, height_lines) => {
567                for line in &info_lines {
568                    println!("{}", line);
569                }
570                let num_lines = info_lines.len();
571                print!("\x1b7"); // DEC save cursor
572                if num_lines > 0 {
573                    print!("\x1b[{}A", num_lines); // Move up
574                }
575                print!("\x1b[{}C", text_column_width); // Move right
576                logo::print_graphical_logo(&bytes);
577                print!("\x1b8"); // DEC restore cursor
578                                 // Advance past the logo's bottom edge if it extends below the text.
579                let overflow = height_lines.saturating_sub(num_lines);
580                if overflow > 0 {
581                    print!("\x1b[{}B", overflow);
582                }
583            }
584            ActiveLogo::Iterm2(bytes, height_lines) => {
585                for line in &info_lines {
586                    println!("{}", line);
587                }
588                let num_lines = info_lines.len();
589                print!("\x1b7"); // DEC save cursor
590                if num_lines > 0 {
591                    print!("\x1b[{}A", num_lines); // Move up
592                }
593                print!("\x1b[{}C", text_column_width); // Move right
594                logo::print_iterm2_logo(&bytes);
595                print!("\x1b8"); // DEC restore cursor
596                let overflow = height_lines.saturating_sub(num_lines);
597                if overflow > 0 {
598                    print!("\x1b[{}B", overflow);
599                }
600            }
601            ActiveLogo::Sixel(bytes, height_lines) => {
602                for line in &info_lines {
603                    println!("{}", line);
604                }
605                let num_lines = info_lines.len();
606                print!("\x1b7"); // DEC save cursor
607                if num_lines > 0 {
608                    print!("\x1b[{}A", num_lines); // Move up
609                }
610                print!("\x1b[{}C", text_column_width); // Move right
611                logo::print_sixel_logo(&bytes);
612                print!("\x1b8"); // DEC restore cursor
613                let overflow = height_lines.saturating_sub(num_lines);
614                if overflow > 0 {
615                    print!("\x1b[{}B", overflow);
616                }
617            }
618            ActiveLogo::None => {
619                for line in &info_lines {
620                    println!("{}", line);
621                }
622            }
623        }
624    } else {
625        // Narrow or no-logo fallback: print logo, then print data
626        match active_logo {
627            ActiveLogo::Lines(logo_lines) => {
628                for line in logo_lines {
629                    println!("{}", line);
630                }
631                println!();
632            }
633            ActiveLogo::Kitty(bytes, _) => {
634                logo::print_graphical_logo(&bytes);
635                println!();
636            }
637            ActiveLogo::Iterm2(bytes, _) => {
638                logo::print_iterm2_logo(&bytes);
639                println!();
640            }
641            ActiveLogo::Sixel(bytes, _) => {
642                logo::print_sixel_logo(&bytes);
643                println!();
644            }
645            ActiveLogo::None => {}
646        }
647        for line in &info_lines {
648            println!("{}", line);
649        }
650    }
651
652    Ok(())
653}
654
655/// Returns the highest temperature per physical category from a raw sensor list.
656///
657/// Input strings are formatted as `"label: 83°C"`. Output is one entry per
658/// detected category (CPU / GPU / NVMe / WiFi / Battery / System), ordered
659/// from most to least specific. Used by `--long` mode; `--full` shows the raw list.
660fn consolidate_temps(temps: &[String]) -> Vec<String> {
661    fn categorize(label: &str) -> &'static str {
662        let l = label.to_lowercase();
663        if l.contains("cpu")
664            || l.contains("core")
665            || l.contains("k10temp")
666            || l.contains("k8temp")
667            || l.contains("coretemp")
668            || l.contains("tctl")
669            || l.contains("tdie")
670            || l.contains("tccd")
671            || l.contains("package")
672        {
673            "CPU"
674        } else if l.contains("gpu")
675            || l.contains("nouveau")
676            || l.contains("radeon")
677            || l.contains("amdgpu")
678        {
679            "GPU"
680        } else if l.contains("nvme") || l.contains("nand") {
681            "NVMe"
682        } else if l.contains("ath")
683            || l.contains("wifi")
684            || l.contains("wireless")
685            || l.contains("wlan")
686            || l.contains("iwl")
687        {
688            "WiFi"
689        } else if l.contains("bat") {
690            "Battery"
691        } else {
692            "System"
693        }
694    }
695
696    let mut max: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
697    for s in temps {
698        // Parse "some label: 83°C"
699        if let Some((label_part, val_part)) = s.rsplit_once(':') {
700            let val_str = val_part.trim().trim_end_matches("°C");
701            if let Ok(val) = val_str.parse::<f32>() {
702                let cat = categorize(label_part.trim());
703                let entry = max.entry(cat).or_insert(f32::NEG_INFINITY);
704                if val > *entry {
705                    *entry = val;
706                }
707            }
708        }
709    }
710
711    const ORDER: &[&str] = &["CPU", "GPU", "NVMe", "WiFi", "Battery", "System"];
712    ORDER
713        .iter()
714        .filter_map(|cat| max.get(cat).map(|v| format!("{}: {:.0}°C", cat, v)))
715        .collect()
716}
717
718/// Formats a raw uptime string (in seconds) into a human-readable duration.
719///
720/// Example: "45224s" -> "12h 33m 44s"
721fn format_uptime(uptime: &str) -> String {
722    // Parse the uptime string (e.g. "45224s")
723    let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
724
725    let years = seconds / (365 * 24 * 3600);
726    let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
727    let hours = (seconds % (24 * 3600)) / 3600;
728    let minutes = (seconds % 3600) / 60;
729    let secs = seconds % 60;
730
731    let mut parts = Vec::new();
732    if years > 0 {
733        parts.push(format!("{}y", years));
734    }
735    if days > 0 {
736        parts.push(format!("{}d", days));
737    }
738    if hours > 0 {
739        parts.push(format!("{}h", hours));
740    }
741    if minutes > 0 {
742        parts.push(format!("{}m", minutes));
743    }
744    if secs > 0 || parts.is_empty() {
745        parts.push(format!("{}s", secs));
746    }
747
748    parts.join(" ")
749}
750
751/// Returns the height in terminal rows a graphical logo image will occupy.
752///
753/// Uses TIOCGWINSZ pixel dimensions on Unix to get the real cell height.
754/// Falls back to 20px per cell when the terminal doesn't report pixel dims.
755#[cfg(feature = "graphics")]
756fn graphical_logo_height_lines(bytes: &[u8]) -> usize {
757    let img_h = image::load_from_memory(bytes)
758        .map(|img| img.height() as usize)
759        .unwrap_or(384);
760    let cell_h = terminal_cell_height_px();
761    img_h.div_ceil(cell_h)
762}
763
764/// Returns the terminal cell height in pixels via TIOCGWINSZ, or 20 as fallback.
765fn terminal_cell_height_px() -> usize {
766    #[cfg(unix)]
767    {
768        use std::mem::MaybeUninit;
769        let mut ws: libc::winsize = unsafe { MaybeUninit::zeroed().assume_init() };
770        let ret = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) };
771        if ret == 0 && ws.ws_row > 0 && ws.ws_ypixel > 0 {
772            return ws.ws_ypixel as usize / ws.ws_row as usize;
773        }
774    }
775    20
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    // ── should_show_logo ──────────────────────────────────────────────────────
783
784    #[test]
785    fn test_show_logo_auto_requires_tty() {
786        // Auto mode (no explicit flags): logo only on a TTY.
787        assert!(should_show_logo(None, false, false, true));
788        assert!(!should_show_logo(None, false, false, false));
789    }
790
791    #[test]
792    fn test_show_logo_ascii_forces_without_tty() {
793        // --ascii-logo forces the logo even when stdout is not a TTY (pipe / CI).
794        assert!(should_show_logo(None, false, true, false));
795        assert!(should_show_logo(None, false, true, true));
796    }
797
798    #[test]
799    fn test_show_logo_no_logo_always_wins() {
800        // --no-logo suppresses even when --ascii-logo is set or on a TTY.
801        assert!(!should_show_logo(None, true, true, true));
802        assert!(!should_show_logo(None, true, false, true));
803    }
804
805    #[test]
806    fn test_show_logo_config_disable() {
807        // config show_logo=false suppresses in auto mode...
808        assert!(!should_show_logo(Some(false), false, false, true));
809        // ...but an explicit --ascii-logo still forces it on (CLI overrides config default).
810        assert!(should_show_logo(Some(false), false, true, false));
811    }
812
813    #[test]
814    fn test_consolidate_temps_basic() {
815        let raw = vec![
816            "k10temp Tctl: 83°C".to_string(),
817            "amdgpu edge: 65°C".to_string(),
818            "nvme Composite: 62°C".to_string(),
819            "ath11k_hwmon temp1: 58°C".to_string(),
820            "acpitz temp1: 77°C".to_string(),
821        ];
822        let result = consolidate_temps(&raw);
823        assert_eq!(
824            result,
825            vec![
826                "CPU: 83°C",
827                "GPU: 65°C",
828                "NVMe: 62°C",
829                "WiFi: 58°C",
830                "System: 77°C"
831            ]
832        );
833    }
834
835    #[test]
836    fn test_consolidate_temps_highest_wins() {
837        let raw = vec![
838            "thinkpad CPU: 83°C".to_string(),
839            "k10temp Tctl: 79°C".to_string(),
840            "nvme Composite: 62°C".to_string(),
841            "nvme Sensor 1: 59°C".to_string(),
842            "nvme Sensor 2: 56°C".to_string(),
843        ];
844        let result = consolidate_temps(&raw);
845        assert!(result.contains(&"CPU: 83°C".to_string()));
846        assert!(result.contains(&"NVMe: 62°C".to_string()));
847        assert!(!result
848            .iter()
849            .any(|s| s.contains("79") || s.contains("59") || s.contains("56")));
850    }
851
852    #[test]
853    fn test_consolidate_temps_order() {
854        let raw = vec![
855            "acpitz: 60°C".to_string(),
856            "nvme: 55°C".to_string(),
857            "amdgpu edge: 65°C".to_string(),
858            "k10temp Tctl: 80°C".to_string(),
859        ];
860        let result = consolidate_temps(&raw);
861        let cpu_pos = result.iter().position(|s| s.starts_with("CPU"));
862        let gpu_pos = result.iter().position(|s| s.starts_with("GPU"));
863        let nvme_pos = result.iter().position(|s| s.starts_with("NVMe"));
864        let sys_pos = result.iter().position(|s| s.starts_with("System"));
865        assert!(cpu_pos < gpu_pos);
866        assert!(gpu_pos < nvme_pos);
867        assert!(nvme_pos < sys_pos);
868    }
869
870    #[test]
871    fn test_consolidate_temps_empty() {
872        assert!(consolidate_temps(&[]).is_empty());
873    }
874
875    #[test]
876    fn test_format_uptime() {
877        assert_eq!(format_uptime("60s"), "1m");
878        assert_eq!(format_uptime("3600s"), "1h");
879        assert_eq!(format_uptime("3661s"), "1h 1m 1s");
880        assert_eq!(format_uptime("86400s"), "1d");
881        assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
882        assert_eq!(format_uptime("31536000s"), "1y");
883        assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
884        assert_eq!(format_uptime("0s"), "0s");
885    }
886}