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