Skip to main content

netwatch_rs/
system.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::process::Command;
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SystemInfo {
10    pub hostname: String,
11    pub os_name: String,
12    pub os_version: String,
13    pub kernel_version: String,
14    pub architecture: String,
15    pub cpu_model: String,
16    pub cpu_cores: u32,
17    pub cpu_threads: u32,
18    pub total_memory: u64, // bytes
19    pub boot_time: SystemTime,
20    pub uptime: Duration,
21}
22
23#[derive(Debug, Clone)]
24pub struct SystemStats {
25    pub cpu_usage_percent: f64,
26    pub memory_usage_percent: f64,
27    pub memory_used: u64,              // bytes
28    pub memory_available: u64,         // bytes
29    pub load_average: (f64, f64, f64), // 1min, 5min, 15min
30    pub disk_usage: HashMap<String, DiskUsage>,
31    pub top_processes: Vec<ProcessInfo>,
32    pub timestamp: SystemTime,
33}
34
35#[derive(Debug, Clone)]
36pub struct DiskUsage {
37    pub total: u64,     // bytes
38    pub used: u64,      // bytes
39    pub available: u64, // bytes
40    pub usage_percent: f64,
41    pub filesystem: String,
42}
43
44#[derive(Debug, Clone)]
45pub struct ProcessInfo {
46    pub pid: u32,
47    pub name: String,
48    pub cpu_percent: f64,
49    pub memory_percent: f64,
50    pub memory_rss: u64, // bytes
51    pub memory_vms: u64, // bytes
52    pub command: String,
53    pub user: String,
54    pub state: String,
55}
56
57pub struct SystemMonitor {
58    system_info: SystemInfo,
59    last_cpu_stats: Option<CpuStats>,
60    last_update: SystemTime,
61}
62
63#[derive(Debug, Clone)]
64struct CpuStats {
65    user: u64,
66    nice: u64,
67    system: u64,
68    idle: u64,
69    iowait: u64,
70    irq: u64,
71    softirq: u64,
72    steal: u64,
73}
74
75impl SystemMonitor {
76    pub fn new() -> Result<Self> {
77        let system_info = Self::collect_system_info()?;
78
79        Ok(Self {
80            system_info,
81            last_cpu_stats: None,
82            last_update: SystemTime::now(),
83        })
84    }
85
86    pub fn get_system_info(&self) -> &SystemInfo {
87        &self.system_info
88    }
89
90    pub fn get_current_stats(&mut self) -> Result<SystemStats> {
91        let now = SystemTime::now();
92
93        // Update CPU usage - with fallback
94        let cpu_usage = self.calculate_cpu_usage().unwrap_or(0.0);
95
96        // Get memory stats - with fallback
97        let (memory_usage_percent, memory_used, memory_available) =
98            self.get_memory_stats().unwrap_or((0.0, 0, 0));
99
100        // Get load average - with fallback
101        let load_average = self.get_load_average().unwrap_or((0.0, 0.0, 0.0));
102
103        // Get disk usage - with fallback
104        let disk_usage = self
105            .get_disk_usage()
106            .unwrap_or_else(|_| std::collections::HashMap::new());
107
108        // Get top processes - with fallback
109        let top_processes = self.get_top_processes().unwrap_or_else(|_| Vec::new());
110
111        self.last_update = now;
112
113        Ok(SystemStats {
114            cpu_usage_percent: cpu_usage,
115            memory_usage_percent,
116            memory_used,
117            memory_available,
118            load_average,
119            disk_usage,
120            top_processes,
121            timestamp: now,
122        })
123    }
124
125    fn collect_system_info() -> Result<SystemInfo> {
126        let hostname = Self::get_hostname()?;
127        let (os_name, os_version) = Self::get_os_info()?;
128        let kernel_version = Self::get_kernel_version()?;
129        let architecture = Self::get_architecture()?;
130        let (cpu_model, cpu_cores, cpu_threads) = Self::get_cpu_info()?;
131        let total_memory = Self::get_total_memory()?;
132        let boot_time = Self::get_boot_time()?;
133        let uptime = SystemTime::now()
134            .duration_since(boot_time)
135            .unwrap_or_default();
136
137        Ok(SystemInfo {
138            hostname,
139            os_name,
140            os_version,
141            kernel_version,
142            architecture,
143            cpu_model,
144            cpu_cores,
145            cpu_threads,
146            total_memory,
147            boot_time,
148            uptime,
149        })
150    }
151
152    fn get_hostname() -> Result<String> {
153        let output = Command::new("hostname").output()?;
154        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
155    }
156
157    fn get_os_info() -> Result<(String, String)> {
158        #[cfg(target_os = "macos")]
159        {
160            let name_output = Command::new("sw_vers").arg("-productName").output()?;
161            let version_output = Command::new("sw_vers").arg("-productVersion").output()?;
162
163            let os_name = String::from_utf8_lossy(&name_output.stdout)
164                .trim()
165                .to_string();
166            let os_version = String::from_utf8_lossy(&version_output.stdout)
167                .trim()
168                .to_string();
169
170            Ok((os_name, os_version))
171        }
172
173        #[cfg(target_os = "linux")]
174        {
175            // Try to read from /etc/os-release
176            if let Ok(content) = fs::read_to_string("/etc/os-release") {
177                let mut name = "Linux".to_string();
178                let mut version = "Unknown".to_string();
179
180                for line in content.lines() {
181                    if line.starts_with("PRETTY_NAME=") {
182                        name = line
183                            .trim_start_matches("PRETTY_NAME=")
184                            .trim_matches('"')
185                            .to_string();
186                    } else if line.starts_with("VERSION=") {
187                        version = line
188                            .trim_start_matches("VERSION=")
189                            .trim_matches('"')
190                            .to_string();
191                    }
192                }
193
194                Ok((name, version))
195            } else {
196                Ok(("Linux".to_string(), "Unknown".to_string()))
197            }
198        }
199
200        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
201        {
202            Ok(("Unknown OS".to_string(), "Unknown".to_string()))
203        }
204    }
205
206    fn get_kernel_version() -> Result<String> {
207        let output = Command::new("uname").arg("-r").output()?;
208        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
209    }
210
211    fn get_architecture() -> Result<String> {
212        let output = Command::new("uname").arg("-m").output()?;
213        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
214    }
215
216    fn get_cpu_info() -> Result<(String, u32, u32)> {
217        #[cfg(target_os = "macos")]
218        {
219            let model_output = Command::new("sysctl")
220                .arg("-n")
221                .arg("machdep.cpu.brand_string")
222                .output()?;
223            let cores_output = Command::new("sysctl")
224                .arg("-n")
225                .arg("hw.physicalcpu")
226                .output()?;
227            let threads_output = Command::new("sysctl")
228                .arg("-n")
229                .arg("hw.logicalcpu")
230                .output()?;
231
232            let model = String::from_utf8_lossy(&model_output.stdout)
233                .trim()
234                .to_string();
235            let cores = String::from_utf8_lossy(&cores_output.stdout)
236                .trim()
237                .parse::<u32>()
238                .unwrap_or(1);
239            let threads = String::from_utf8_lossy(&threads_output.stdout)
240                .trim()
241                .parse::<u32>()
242                .unwrap_or(1);
243
244            Ok((model, cores, threads))
245        }
246
247        #[cfg(target_os = "linux")]
248        {
249            let mut model = "Unknown CPU".to_string();
250            let mut cores = 1u32;
251            let mut threads = 1u32;
252
253            if let Ok(content) = fs::read_to_string("/proc/cpuinfo") {
254                let mut cpu_count = 0;
255                let mut core_ids = std::collections::HashSet::new();
256
257                for line in content.lines() {
258                    if line.starts_with("model name") {
259                        if let Some(name) = line.split(':').nth(1) {
260                            model = name.trim().to_string();
261                        }
262                    } else if line.starts_with("processor") {
263                        cpu_count += 1;
264                    } else if line.starts_with("core id") {
265                        if let Some(id) = line.split(':').nth(1) {
266                            if let Ok(core_id) = id.trim().parse::<u32>() {
267                                core_ids.insert(core_id);
268                            }
269                        }
270                    }
271                }
272
273                threads = cpu_count;
274                cores = core_ids.len() as u32;
275                if cores == 0 {
276                    cores = threads;
277                } // Fallback
278            }
279
280            Ok((model, cores, threads))
281        }
282
283        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
284        {
285            Ok(("Unknown CPU".to_string(), 1, 1))
286        }
287    }
288
289    fn get_total_memory() -> Result<u64> {
290        #[cfg(target_os = "macos")]
291        {
292            let output = Command::new("sysctl")
293                .arg("-n")
294                .arg("hw.memsize")
295                .output()?;
296            let mem_string = String::from_utf8_lossy(&output.stdout);
297            let mem_str = mem_string.trim();
298            Ok(mem_str.parse::<u64>().unwrap_or(0))
299        }
300
301        #[cfg(target_os = "linux")]
302        {
303            if let Ok(content) = fs::read_to_string("/proc/meminfo") {
304                for line in content.lines() {
305                    if line.starts_with("MemTotal:") {
306                        let parts: Vec<&str> = line.split_whitespace().collect();
307                        if parts.len() >= 2 {
308                            if let Ok(kb) = parts[1].parse::<u64>() {
309                                return Ok(kb * 1024); // Convert KB to bytes
310                            }
311                        }
312                    }
313                }
314            }
315            Ok(0)
316        }
317
318        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
319        {
320            Ok(0)
321        }
322    }
323
324    fn get_boot_time() -> Result<SystemTime> {
325        #[cfg(target_os = "macos")]
326        {
327            let output = Command::new("sysctl")
328                .arg("-n")
329                .arg("kern.boottime")
330                .output()?;
331            let boot_str = String::from_utf8_lossy(&output.stdout);
332
333            // Parse format like "{ sec = 1234567890, usec = 123456 }" - safely
334            if let Some(sec_start) = boot_str.find("sec = ") {
335                if sec_start + 6 < boot_str.len() {
336                    let sec_str = &boot_str[sec_start + 6..];
337                    if let Some(sec_end) = sec_str.find(',') {
338                        if sec_end <= sec_str.len() {
339                            if let Ok(secs) = sec_str[..sec_end].parse::<u64>() {
340                                return Ok(UNIX_EPOCH + Duration::from_secs(secs));
341                            }
342                        }
343                    }
344                }
345            }
346
347            // Fallback
348            Ok(SystemTime::now() - Duration::from_secs(3600))
349        }
350
351        #[cfg(target_os = "linux")]
352        {
353            if let Ok(content) = fs::read_to_string("/proc/stat") {
354                for line in content.lines() {
355                    if line.starts_with("btime ") {
356                        let parts: Vec<&str> = line.split_whitespace().collect();
357                        if parts.len() >= 2 {
358                            if let Ok(secs) = parts[1].parse::<u64>() {
359                                return Ok(UNIX_EPOCH + Duration::from_secs(secs));
360                            }
361                        }
362                    }
363                }
364            }
365
366            // Fallback
367            Ok(SystemTime::now() - Duration::from_secs(3600))
368        }
369
370        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
371        {
372            Ok(SystemTime::now() - Duration::from_secs(3600))
373        }
374    }
375
376    fn calculate_cpu_usage(&mut self) -> Result<f64> {
377        let current_stats = self.read_cpu_stats()?;
378
379        if let Some(ref last_stats) = self.last_cpu_stats {
380            let total_last = last_stats.user
381                + last_stats.nice
382                + last_stats.system
383                + last_stats.idle
384                + last_stats.iowait
385                + last_stats.irq
386                + last_stats.softirq
387                + last_stats.steal;
388
389            let total_current = current_stats.user
390                + current_stats.nice
391                + current_stats.system
392                + current_stats.idle
393                + current_stats.iowait
394                + current_stats.irq
395                + current_stats.softirq
396                + current_stats.steal;
397
398            let total_diff = total_current - total_last;
399            let idle_diff = current_stats.idle - last_stats.idle;
400
401            if total_diff > 0 {
402                let usage = ((total_diff - idle_diff) as f64 / total_diff as f64) * 100.0;
403                self.last_cpu_stats = Some(current_stats);
404                return Ok(usage.clamp(0.0, 100.0));
405            }
406        }
407
408        self.last_cpu_stats = Some(current_stats);
409        Ok(0.0)
410    }
411
412    fn read_cpu_stats(&self) -> Result<CpuStats> {
413        #[cfg(target_os = "linux")]
414        {
415            let content = fs::read_to_string("/proc/stat")?;
416            if let Some(line) = content.lines().next() {
417                if line.starts_with("cpu ") {
418                    let parts: Vec<&str> = line.split_whitespace().collect();
419                    if parts.len() >= 8 {
420                        return Ok(CpuStats {
421                            user: parts[1].parse().unwrap_or(0),
422                            nice: parts[2].parse().unwrap_or(0),
423                            system: parts[3].parse().unwrap_or(0),
424                            idle: parts[4].parse().unwrap_or(0),
425                            iowait: parts[5].parse().unwrap_or(0),
426                            irq: parts[6].parse().unwrap_or(0),
427                            softirq: parts[7].parse().unwrap_or(0),
428                            steal: parts.get(8).unwrap_or(&"0").parse().unwrap_or(0),
429                        });
430                    }
431                }
432            }
433        }
434
435        #[cfg(target_os = "macos")]
436        {
437            // macOS CPU usage via vm_stat and iostat
438            let output = Command::new("iostat").arg("-c").arg("1").arg("1").output();
439
440            if let Ok(output) = output {
441                let _content = String::from_utf8_lossy(&output.stdout);
442                // Parse iostat output for CPU percentages
443                // This is a simplified version
444                return Ok(CpuStats {
445                    user: 0,
446                    nice: 0,
447                    system: 0,
448                    idle: 1000, // Default to mostly idle
449                    iowait: 0,
450                    irq: 0,
451                    softirq: 0,
452                    steal: 0,
453                });
454            }
455        }
456
457        // Fallback
458        Ok(CpuStats {
459            user: 0,
460            nice: 0,
461            system: 0,
462            idle: 1000,
463            iowait: 0,
464            irq: 0,
465            softirq: 0,
466            steal: 0,
467        })
468    }
469
470    fn get_memory_stats(&self) -> Result<(f64, u64, u64)> {
471        #[cfg(target_os = "linux")]
472        {
473            let content = fs::read_to_string("/proc/meminfo")?;
474            let mut total = 0u64;
475            let mut available = 0u64;
476
477            for line in content.lines() {
478                if line.starts_with("MemTotal:") {
479                    let parts: Vec<&str> = line.split_whitespace().collect();
480                    if parts.len() >= 2 {
481                        total = parts[1].parse::<u64>().unwrap_or(0) * 1024; // KB to bytes
482                    }
483                } else if line.starts_with("MemAvailable:") {
484                    let parts: Vec<&str> = line.split_whitespace().collect();
485                    if parts.len() >= 2 {
486                        available = parts[1].parse::<u64>().unwrap_or(0) * 1024;
487                        // KB to bytes
488                    }
489                }
490            }
491
492            let used = total - available;
493            let usage_percent = if total > 0 {
494                (used as f64 / total as f64) * 100.0
495            } else {
496                0.0
497            };
498
499            Ok((usage_percent, used, available))
500        }
501
502        #[cfg(target_os = "macos")]
503        {
504            let output = Command::new("vm_stat").output()?;
505            let content = String::from_utf8_lossy(&output.stdout);
506
507            let mut pages_free = 0u64;
508            let mut pages_active = 0u64;
509            let mut pages_inactive = 0u64;
510            let mut pages_wired = 0u64;
511            let mut pages_compressed = 0u64;
512
513            for line in content.lines() {
514                if line.contains("Pages free:") {
515                    pages_free = Self::extract_pages(line);
516                } else if line.contains("Pages active:") {
517                    pages_active = Self::extract_pages(line);
518                } else if line.contains("Pages inactive:") {
519                    pages_inactive = Self::extract_pages(line);
520                } else if line.contains("Pages wired down:") {
521                    pages_wired = Self::extract_pages(line);
522                } else if line.contains("Pages stored in compressor:") {
523                    pages_compressed = Self::extract_pages(line);
524                }
525            }
526
527            let page_size = 4096u64; // 4KB pages on macOS
528            let total = self.system_info.total_memory;
529            let used = (pages_active + pages_inactive + pages_wired + pages_compressed) * page_size;
530            let available = pages_free * page_size;
531            let usage_percent = if total > 0 {
532                (used as f64 / total as f64) * 100.0
533            } else {
534                0.0
535            };
536
537            Ok((usage_percent, used, available))
538        }
539
540        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
541        {
542            Ok((0.0, 0, 0))
543        }
544    }
545
546    #[cfg(target_os = "macos")]
547    fn extract_pages(line: &str) -> u64 {
548        line.split_whitespace()
549            .find(|s| s.chars().all(|c| c.is_ascii_digit()))
550            .and_then(|s| s.parse().ok())
551            .unwrap_or(0)
552    }
553
554    fn get_load_average(&self) -> Result<(f64, f64, f64)> {
555        #[cfg(any(target_os = "linux", target_os = "macos"))]
556        {
557            let content = fs::read_to_string("/proc/loadavg").or_else(|_| -> Result<String> {
558                // macOS fallback using uptime command
559                let output = Command::new("uptime").output()?;
560                Ok(String::from_utf8_lossy(&output.stdout).to_string())
561            })?;
562
563            let parts: Vec<&str> = content.split_whitespace().collect();
564            if parts.len() >= 3 {
565                let load_1min = parts[0].parse::<f64>().unwrap_or(0.0);
566                let load_5min = parts[1].parse::<f64>().unwrap_or(0.0);
567                let load_quarter_hour = parts[2].parse::<f64>().unwrap_or(0.0);
568                return Ok((load_1min, load_5min, load_quarter_hour));
569            }
570
571            // Alternative parsing for uptime output - safely
572            if let Some(load_start) = content.find("load average") {
573                if load_start < content.len() {
574                    let load_str = &content[load_start..];
575                    if let Some(colon_pos) = load_str.find(':') {
576                        if colon_pos + 1 < load_str.len() {
577                            let numbers = &load_str[colon_pos + 1..];
578                            let nums: Vec<&str> = numbers.split(',').collect();
579                            if nums.len() >= 3 {
580                                let load_1min = nums[0].trim().parse::<f64>().unwrap_or(0.0);
581                                let load_5min = nums[1].trim().parse::<f64>().unwrap_or(0.0);
582                                let load_quarter_hour =
583                                    nums[2].trim().parse::<f64>().unwrap_or(0.0);
584                                return Ok((load_1min, load_5min, load_quarter_hour));
585                            }
586                        }
587                    }
588                }
589            }
590        }
591
592        Ok((0.0, 0.0, 0.0))
593    }
594
595    fn get_disk_usage(&self) -> Result<HashMap<String, DiskUsage>> {
596        let mut disk_usage = HashMap::new();
597
598        let output = Command::new("df").arg("-h").output()?;
599
600        let content = String::from_utf8_lossy(&output.stdout);
601        for line in content.lines().skip(1) {
602            // Skip header
603            let parts: Vec<&str> = line.split_whitespace().collect();
604            if parts.len() >= 6 {
605                let filesystem = parts[0].to_string();
606                let size_str = parts[1];
607                let used_str = parts[2];
608                let avail_str = parts[3];
609                let use_percent_str = parts[4];
610                let mount_point = parts[5].to_string();
611
612                // Parse sizes (handle K, M, G, T suffixes)
613                let total = Self::parse_size(size_str);
614                let used = Self::parse_size(used_str);
615                let available = Self::parse_size(avail_str);
616                let usage_percent = use_percent_str
617                    .trim_end_matches('%')
618                    .parse::<f64>()
619                    .unwrap_or(0.0);
620
621                disk_usage.insert(
622                    mount_point,
623                    DiskUsage {
624                        total,
625                        used,
626                        available,
627                        usage_percent,
628                        filesystem,
629                    },
630                );
631            }
632        }
633
634        Ok(disk_usage)
635    }
636
637    fn parse_size(size_str: &str) -> u64 {
638        let size_str = size_str.trim();
639        if size_str.is_empty() || size_str == "-" {
640            return 0;
641        }
642
643        let (number_part, suffix) = if size_str.ends_with(char::is_alphabetic) && size_str.len() > 1
644        {
645            let len = size_str.len();
646            (&size_str[..len - 1], &size_str[len - 1..])
647        } else {
648            (size_str, "")
649        };
650
651        let number: f64 = number_part.parse().unwrap_or(0.0);
652
653        match suffix.to_uppercase().as_str() {
654            "K" => (number * 1024.0) as u64,
655            "M" => (number * 1024.0 * 1024.0) as u64,
656            "G" => (number * 1024.0 * 1024.0 * 1024.0) as u64,
657            "T" => (number * 1024.0 * 1024.0 * 1024.0 * 1024.0) as u64,
658            _ => number as u64,
659        }
660    }
661
662    fn get_top_processes(&self) -> Result<Vec<ProcessInfo>> {
663        // Try Linux-style sorting first, fallback to basic ps on macOS
664        let output = Command::new("ps")
665            .args(["aux", "--sort=-pcpu"])
666            .output()
667            .or_else(|_| {
668                // Fallback for macOS - use basic ps and sort in code
669                Command::new("ps").args(["aux"]).output()
670            })?;
671
672        let content = String::from_utf8_lossy(&output.stdout);
673        let mut processes = Vec::new();
674
675        for line in content.lines().skip(1) {
676            // Skip header
677            let parts: Vec<&str> = line.split_whitespace().collect();
678            if parts.len() >= 11 {
679                let user = parts[0].to_string();
680                let pid = parts[1].parse::<u32>().unwrap_or(0);
681                let cpu_percent = parts[2].parse::<f64>().unwrap_or(0.0);
682                let memory_percent = parts[3].parse::<f64>().unwrap_or(0.0);
683                let memory_vms = parts[4].parse::<u64>().unwrap_or(0) * 1024; // KB to bytes
684                let memory_rss = parts[5].parse::<u64>().unwrap_or(0) * 1024; // KB to bytes
685                let state = parts.get(7).unwrap_or(&"?").to_string();
686
687                // Command is everything from column 10 onwards (safer indexing)
688                let command = if parts.len() > 10 {
689                    parts[10..].join(" ")
690                } else {
691                    "unknown".to_string()
692                };
693                let name = if parts.len() > 10 {
694                    parts[10]
695                        .split('/')
696                        .next_back()
697                        .unwrap_or("unknown")
698                        .to_string()
699                } else {
700                    "unknown".to_string()
701                };
702
703                processes.push(ProcessInfo {
704                    pid,
705                    name,
706                    cpu_percent,
707                    memory_percent,
708                    memory_rss,
709                    memory_vms,
710                    command,
711                    user,
712                    state,
713                });
714            }
715        }
716
717        // Sort by CPU percentage (descending) and take top 5
718        processes.sort_by(|a, b| {
719            b.cpu_percent
720                .partial_cmp(&a.cpu_percent)
721                .unwrap_or(std::cmp::Ordering::Equal)
722        });
723        processes.truncate(5);
724
725        Ok(processes)
726    }
727
728    pub fn format_bytes(bytes: u64) -> String {
729        const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
730        let mut size = bytes as f64;
731        let mut unit_index = 0;
732
733        while size >= 1024.0 && unit_index < UNITS.len() - 1 {
734            size /= 1024.0;
735            unit_index += 1;
736        }
737
738        // Safe array access with bounds checking
739        let unit = UNITS.get(unit_index).unwrap_or(&"B");
740
741        if size >= 100.0 {
742            format!("{size:.0} {unit}")
743        } else if size >= 10.0 {
744            format!("{size:.1} {unit}")
745        } else {
746            format!("{size:.2} {unit}")
747        }
748    }
749
750    pub fn format_uptime(duration: Duration) -> String {
751        let total_secs = duration.as_secs();
752        let days = total_secs / 86400;
753        let hours = (total_secs % 86400) / 3600;
754        let minutes = (total_secs % 3600) / 60;
755
756        if days > 0 {
757            format!("{days}d {hours}h {minutes}m")
758        } else if hours > 0 {
759            format!("{hours}h {minutes}m")
760        } else {
761            format!("{minutes}m")
762        }
763    }
764}