1use 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
16fn 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; }
38 if ascii_logo {
39 return true; }
41 config_show_logo.unwrap_or(true) && stdout_is_tty }
43
44struct LayoutPlan {
53 side_by_side: bool,
54 text_column_width: usize,
55 logo_column: usize,
56}
57
58fn 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 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
117pub 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
157pub 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 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(¤t) + item_vis <= max_width {
197 current.push_str(&item);
198 } else {
199 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 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(¤t) + 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 carry_sgr_across_lines(lines)
266 }
267}
268
269fn 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
304fn 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 Some(_) => format!("{reopened}\x1b[39m"),
329 None => reopened,
330 });
331 }
332 out
333}
334
335fn 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
350fn 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
375fn 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
399fn 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 print!("{}", graphical_side_by_side_prelude(logo_column, logo_rows));
424 draw(); print!("\x1b8\r");
426 for line in info_lines {
427 println!("{}", line);
428 }
429 for _ in info_lines.len()..logo_rows {
432 println!();
433 }
434 let _ = std::io::stdout().flush();
435}
436
437fn 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
458fn 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(), };
482
483 if let Some(custom) = &_config.custom_theme {
485 theme = Theme::with_custom_overrides(theme, custom);
486 }
487
488 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 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 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 || (norm_label == "dns server" && norm_f == "dns")
533 || (norm_label == "memory usage" && norm_f == "memory")
535 || (norm_label == "wi fi link" && norm_f == "wifi")
537 })
538 }
539 None => true,
540 }
541 };
542
543 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 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 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 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 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 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 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 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 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 enum ActiveLogo {
832 Lines(Vec<String>),
833 Kitty(Vec<u8>, usize, usize), 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 #[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 #[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 #[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 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 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 if !resolved {
964 active_logo =
965 ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
966 }
967 }
968 }
969
970 let info_widths: Vec<usize> = info_lines.iter().map(|line| visible_len(line)).collect();
978
979 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 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!(); 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 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 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
1108fn 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 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
1171fn format_uptime(uptime: &str) -> String {
1175 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#[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 fn net(name: &str, is_up: bool) -> NetworkInterface {
1228 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 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 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 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 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 let nets = vec![net("eth0", false), net("eth1", false)];
1308 assert!(choose_net_line(&nets, None).is_none());
1309 }
1310
1311 #[test]
1314 fn test_show_logo_auto_requires_tty() {
1315 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 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 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 assert!(!should_show_logo(Some(false), false, false, true));
1338 assert!(should_show_logo(Some(false), false, true, false));
1340 }
1341
1342 #[test]
1345 fn test_visible_len_strips_every_escape_form_retch_emits() {
1346 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 assert_eq!(visible_len("宇多田ヒカル"), 12); assert_eq!(visible_len("아이유"), 6); assert_eq!(visible_len("Media: 宇多田ヒカル - 花束を君に"), 32);
1364 assert_eq!(visible_len("Media: 아이유 - 밤편지"), 22);
1365
1366 assert_eq!(visible_len("cafe\u{301}"), 4);
1368 assert_eq!(visible_len("café"), 4);
1370
1371 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 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 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 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 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 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 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 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 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 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 assert_eq!(
1508 active_sgr_after("more text", Some(CYAN.into())),
1509 Some(CYAN.to_string())
1510 );
1511 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 let green = "\x1b[32m";
1523 let s = format!("{CYAN}[{green}Up{RESET}] RX: 1 MB");
1524 assert_eq!(active_sgr_after(&s, None), None); 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 #[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 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 assert!(latin.ends_with(" ###") && cjk.ends_with(" ###"));
1554 }
1555
1556 #[test]
1557 fn test_row_without_a_logo_gets_no_trailing_padding() {
1558 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 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 fn realistic_full_widths() -> Vec<usize> {
1581 let mut w = vec![40; 20]; w[13] = 54; w.extend([158, 91, 79, 60, 45, 62]); w
1585 }
1586
1587 #[test]
1588 fn test_layout_long_line_below_logo_stays_side_by_side() {
1589 let p = plan_layout(&realistic_full_widths(), 20, 40, 120, true);
1591 assert!(p.side_by_side);
1592 assert_eq!(p.text_column_width, 58); }
1595
1596 #[test]
1597 fn test_layout_old_behavior_would_have_stacked() {
1598 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); assert!(plan_layout(&widths, 20, 40, 120, true).side_by_side); }
1605
1606 #[test]
1607 fn test_layout_long_line_within_logo_wraps_and_stays_side_by_side() {
1608 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); 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 let p = plan_layout(&[10; 25], 20, 40, 100, true);
1632 assert!(p.side_by_side);
1633 assert_eq!(p.text_column_width, 45); }
1635
1636 #[test]
1637 fn test_layout_widened_logo_box_still_fits_at_the_side_by_side_threshold() {
1638 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 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 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); }
1661
1662 #[test]
1663 fn test_layout_logo_is_flush_with_the_right_margin() {
1664 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); assert_eq!(p.logo_column, 138 - 49); 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 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 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 #[test]
1708 fn test_prelude_reserves_rows_before_saving_cursor() {
1709 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 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 let p = graphical_side_by_side_prelude(45, 0);
1732 assert_eq!(p, "\x1b[45C\x1b7");
1733 }
1734
1735 #[test]
1738 fn test_split_wifi_hardware_and_connection() {
1739 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 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 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}