kittynode_core/application/
get_system_info.rs

1use crate::domain::system_info::{DiskInfo, MemoryInfo, ProcessorInfo, StorageInfo, SystemInfo};
2use eyre::Result;
3use std::collections::HashSet;
4use sysinfo::{Disks, System};
5
6// Formats bytes using decimal multiples (B, KB, MB, GB, TB)
7fn format_bytes_decimal(bytes: u64) -> String {
8    let units = ["B", "KB", "MB", "GB", "TB"];
9    let mut value = bytes as f64;
10    let mut unit_index = 0;
11
12    while value >= 1000.0 && unit_index < units.len() - 1 {
13        value /= 1000.0;
14        unit_index += 1;
15    }
16
17    format!("{:.2} {}", value, units[unit_index])
18}
19
20// Formats memory preferring whole-number GB. We snap to common marketed
21// capacities (8, 16, 32, 64, ... GB) when the reported bytes are closely below
22// those tiers to avoid showing "31 GB" on a 32 GB machine. When memory is below
23// 1 GiB we fall back to MB granularity for accuracy on smaller systems.
24fn format_memory_gb(bytes: u64) -> String {
25    const MIB: u64 = 1024 * 1024;
26    const GIB: u64 = 1024 * 1024 * 1024;
27    const MARKETING_LEVELS: &[u64] = &[
28        4, 6, 8, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048,
29    ];
30    const MARKETING_TOLERANCE: f64 = 0.07; // 7% below a tier still rounds up.
31
32    if bytes >= GIB {
33        let actual_gb = bytes as f64 / GIB as f64;
34        let fallback_gb = actual_gb.round().max(1.0) as u64;
35
36        let snapped_gb = MARKETING_LEVELS
37            .iter()
38            .copied()
39            .find(|&tier| {
40                let tier_f = tier as f64;
41                tier_f >= actual_gb && (tier_f - actual_gb) / tier_f <= MARKETING_TOLERANCE
42            })
43            .unwrap_or(fallback_gb);
44
45        format!("{} GB", snapped_gb)
46    } else if bytes >= MIB {
47        let mb = bytes.div_ceil(MIB);
48        format!("{} MB", mb)
49    } else {
50        format!("{} B", bytes)
51    }
52}
53
54pub fn get_system_info() -> Result<SystemInfo> {
55    let mut system = System::new_all();
56    system.refresh_all();
57
58    let processor = get_processor_info(&system)?;
59    let memory = get_memory_info(&system);
60    let storage = get_storage_info()?;
61
62    Ok(SystemInfo {
63        processor,
64        memory,
65        storage,
66    })
67}
68
69fn get_processor_info(system: &System) -> Result<ProcessorInfo> {
70    let cpu = system
71        .cpus()
72        .first()
73        .ok_or_else(|| eyre::eyre!("No CPU found"))?;
74
75    Ok(ProcessorInfo {
76        name: if cpu.brand().is_empty() {
77            "Unknown CPU".to_string()
78        } else {
79            cpu.brand().to_string()
80        },
81        cores: sysinfo::System::physical_core_count().unwrap_or(1) as u32,
82        frequency_ghz: cpu.frequency() as f64 / 1000.0,
83        architecture: std::env::consts::ARCH.to_string(),
84    })
85}
86
87fn get_memory_info(system: &System) -> MemoryInfo {
88    let total = system.total_memory();
89    MemoryInfo {
90        total_bytes: total,
91        // Show whole-number GB for user-friendly RAM display
92        total_display: format_memory_gb(total),
93    }
94}
95
96fn get_storage_info() -> Result<StorageInfo> {
97    let disks = Disks::new_with_refreshed_list();
98
99    let snapshots: Vec<DiskSnapshot> = disks
100        .list()
101        .iter()
102        .filter_map(|disk| {
103            // On macOS, sysinfo exposes multiple APFS system volumes (e.g.,
104            // "/" and "/System/Volumes/Data") that represent the same
105            // physical drive. These should not be shown separately in the UI.
106            // Filter out internal system mount points to avoid duplicates like
107            // "Macintosh HD" appearing twice.
108            #[cfg(target_os = "macos")]
109            {
110                let mp = match disk.mount_point().to_str() {
111                    Some(s) => s,
112                    None => return None,
113                };
114                if mp.starts_with("/System/Volumes") || mp == "/private/var/vm" {
115                    return None;
116                }
117            }
118
119            let mount_point = disk.mount_point().to_str()?.to_string();
120            let name = disk.name().to_str()?.to_string();
121            let file_system = disk.file_system().to_str()?.to_string();
122
123            Some(DiskSnapshot {
124                name,
125                mount_point,
126                total_bytes: disk.total_space(),
127                available_bytes: disk.available_space(),
128                file_system,
129            })
130        })
131        .collect();
132
133    build_storage_info(snapshots)
134}
135
136#[derive(Debug, Clone)]
137struct DiskSnapshot {
138    name: String,
139    mount_point: String,
140    total_bytes: u64,
141    available_bytes: u64,
142    file_system: String,
143}
144
145fn build_storage_info(disks: Vec<DiskSnapshot>) -> Result<StorageInfo> {
146    const MIN_DISK_SIZE: u64 = 10 * 1024 * 1024 * 1024; // 10 GiB
147
148    let mut seen_signatures = HashSet::new();
149    let disk_infos: Vec<DiskInfo> = disks
150        .into_iter()
151        .filter_map(|disk| {
152            if disk.total_bytes < MIN_DISK_SIZE || disk.total_bytes == 0 {
153                return None;
154            }
155
156            let storage_signature = (disk.total_bytes, disk.available_bytes);
157            if !seen_signatures.insert(storage_signature) {
158                return None;
159            }
160
161            Some(DiskInfo {
162                name: disk.name,
163                mount_point: disk.mount_point,
164                total_bytes: disk.total_bytes,
165                available_bytes: disk.available_bytes,
166                // For disks, users expect decimal-based sizes like Finder/Windows
167                total_display: format_bytes_decimal(disk.total_bytes),
168                used_display: format_bytes_decimal(disk.total_bytes - disk.available_bytes),
169                available_display: format_bytes_decimal(disk.available_bytes),
170                disk_type: disk.file_system,
171            })
172        })
173        .collect();
174
175    if disk_infos.is_empty() {
176        return Err(eyre::eyre!("No valid disks found"));
177    }
178
179    Ok(StorageInfo { disks: disk_infos })
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn memory_binary_formats_expected() {
188        // 32 GiB in bytes
189        let bytes_32_gib = 32u64 * 1024 * 1024 * 1024;
190        let s = format_memory_gb(bytes_32_gib);
191        assert_eq!(s, "32 GB", "got {s}");
192    }
193
194    #[test]
195    fn memory_ceil_prevents_under_reporting() {
196        const GIB: u64 = 1024 * 1024 * 1024;
197        const MIB: u64 = 1024 * 1024;
198
199        // 32 GiB minus 512 MiB should still display as 32 GB for user expectations
200        let bytes = (32 * GIB) - (512 * MIB);
201        let s = format_memory_gb(bytes);
202        assert_eq!(s, "32 GB", "got {s}");
203    }
204
205    #[test]
206    fn memory_snaps_to_marketing_tier_when_exactly_under() {
207        const GIB: u64 = 1024 * 1024 * 1024;
208        // Exactly 31 GiB should report 32 GB marketed size
209        let bytes = 31 * GIB;
210        let s = format_memory_gb(bytes);
211        assert_eq!(s, "32 GB", "got {s}");
212    }
213
214    #[test]
215    fn memory_uses_mb_for_sub_gib_values() {
216        const MIB: u64 = 1024 * 1024;
217        let bytes = 512 * MIB;
218        let s = format_memory_gb(bytes);
219        assert_eq!(s, "512 MB", "got {s}");
220    }
221
222    #[test]
223    fn memory_falls_back_when_far_from_marketing_tier() {
224        const GIB: u64 = 1024 * 1024 * 1024;
225        let bytes = 20 * GIB;
226        let s = format_memory_gb(bytes);
227        assert_eq!(s, "20 GB", "got {s}");
228    }
229
230    #[test]
231    fn memory_rounds_nearest_when_not_marketing_tier() {
232        const GIB: u64 = 1024 * 1024 * 1024;
233        let bytes = (20 * GIB) + (200 * 1024 * 1024); // ~20.2 GiB
234        let s = format_memory_gb(bytes);
235        assert_eq!(s, "20 GB", "got {s}");
236    }
237
238    #[test]
239    fn decimal_formats_expected() {
240        // 32 GiB in bytes should be ~34.36 GB in decimal
241        let bytes_32_gib = 32u64 * 1024 * 1024 * 1024;
242        let s = format_bytes_decimal(bytes_32_gib);
243        assert!(s.starts_with("34.36 GB"), "got {s}");
244    }
245
246    #[test]
247    fn build_storage_info_filters_small_and_duplicate_disks() {
248        let disks = vec![
249            DiskSnapshot {
250                name: "primary".into(),
251                mount_point: "/".into(),
252                total_bytes: 512u64 * 1024 * 1024 * 1024, // 512 GiB
253                available_bytes: 200u64 * 1024 * 1024 * 1024,
254                file_system: "apfs".into(),
255            },
256            DiskSnapshot {
257                // Duplicate signature should be ignored
258                name: "duplicate".into(),
259                mount_point: "/System/Volumes/Data".into(),
260                total_bytes: 512u64 * 1024 * 1024 * 1024,
261                available_bytes: 200u64 * 1024 * 1024 * 1024,
262                file_system: "apfs".into(),
263            },
264            DiskSnapshot {
265                // Too small, should be ignored
266                name: "tiny".into(),
267                mount_point: "/tiny".into(),
268                total_bytes: 2u64 * 1024 * 1024 * 1024,
269                available_bytes: 1024u64 * 1024 * 1024,
270                file_system: "ext4".into(),
271            },
272        ];
273
274        let storage = build_storage_info(disks).expect("expected valid storage info");
275        assert_eq!(storage.disks.len(), 1);
276        assert_eq!(storage.disks[0].name, "primary");
277        assert_eq!(
278            storage.disks[0].available_bytes,
279            200u64 * 1024 * 1024 * 1024
280        );
281    }
282
283    #[test]
284    fn build_storage_info_errors_when_no_valid_disks() {
285        let disks = vec![DiskSnapshot {
286            name: "tiny".into(),
287            mount_point: "/tiny".into(),
288            total_bytes: 0,
289            available_bytes: 0,
290            file_system: "ext4".into(),
291        }];
292
293        let err = build_storage_info(disks).expect_err("expected validation failure");
294        assert!(err.to_string().contains("No valid disks"));
295    }
296
297    #[test]
298    fn build_storage_info_formats_display_strings() {
299        let total = 512u64 * 1024 * 1024 * 1024; // 512 GiB
300        let available = 200u64 * 1024 * 1024 * 1024; // 200 GiB
301
302        let storage = build_storage_info(vec![DiskSnapshot {
303            name: "primary".into(),
304            mount_point: "/".into(),
305            total_bytes: total,
306            available_bytes: available,
307            file_system: "apfs".into(),
308        }])
309        .expect("expected valid storage info");
310
311        let disk = &storage.disks[0];
312        assert_eq!(disk.total_display, format_bytes_decimal(total));
313        assert_eq!(disk.available_display, format_bytes_decimal(available));
314        assert_eq!(disk.used_display, format_bytes_decimal(total - available));
315    }
316}