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