kittynode_core/application/
get_system_info.rs1use crate::domain::system_info::{DiskInfo, MemoryInfo, ProcessorInfo, StorageInfo, SystemInfo};
2use eyre::Result;
3use std::collections::HashSet;
4use sysinfo::{Disks, System};
5
6fn 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
20fn 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; 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 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 #[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; 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 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 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 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 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); let s = format_memory_gb(bytes);
235 assert_eq!(s, "20 GB", "got {s}");
236 }
237
238 #[test]
239 fn decimal_formats_expected() {
240 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, available_bytes: 200u64 * 1024 * 1024 * 1024,
254 file_system: "apfs".into(),
255 },
256 DiskSnapshot {
257 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 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; let available = 200u64 * 1024 * 1024 * 1024; 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}