1pub fn detect_logical_disks() -> Vec<(String, u64, u64, String)> {
12 #[cfg(target_os = "linux")]
13 return detect_logical_linux();
14
15 #[cfg(not(target_os = "linux"))]
16 return detect_logical_sysinfo();
17}
18
19#[cfg(target_os = "linux")]
21fn is_skip_fs(fs_type: &str) -> bool {
22 const SKIP: &[&str] = &[
23 "sysfs",
24 "proc",
25 "devtmpfs",
26 "tmpfs",
27 "devpts",
28 "cgroup",
29 "cgroup2",
30 "pstore",
31 "bpf",
32 "tracefs",
33 "debugfs",
34 "securityfs",
35 "hugetlbfs",
36 "mqueue",
37 "fusectl",
38 "rpc_pipefs",
39 "configfs",
40 "autofs",
41 "efivarfs",
42 "binfmt_misc",
43 "squashfs",
44 "overlay",
45 "ramfs",
46 "rootfs",
47 "nsfs",
48 "pipefs",
49 "sockfs",
50 "anon_inodefs",
51 "cpuset",
52 ];
53 SKIP.contains(&fs_type) || fs_type.starts_with("fuse.")
55}
56
57#[cfg(target_os = "linux")]
58fn detect_logical_linux() -> Vec<(String, u64, u64, String)> {
59 use std::collections::HashSet;
60 use std::ffi::CString;
61
62 let mounts = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
63 let mut results = Vec::new();
64 let mut seen_devs: HashSet<String> = HashSet::new();
65
66 for line in mounts.lines() {
67 let parts: Vec<&str> = line.splitn(4, ' ').collect();
68 if parts.len() < 3 {
69 continue;
70 }
71 let device = parts[0];
72 let mount_point = parts[1];
73 let fs_type = parts[2];
74
75 if is_skip_fs(fs_type) {
76 continue;
77 }
78
79 if device.starts_with('/') && !seen_devs.insert(device.to_string()) {
81 continue;
82 }
83
84 let Ok(mp_c) = CString::new(mount_point) else {
85 continue;
86 };
87
88 let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
89 if unsafe { libc::statvfs(mp_c.as_ptr(), &mut stat) } != 0 {
90 continue;
91 }
92
93 let total = (stat.f_blocks as u64).saturating_mul(stat.f_frsize as u64);
94 let avail = (stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64);
95
96 if total == 0 {
97 continue;
98 }
99
100 results.push((mount_point.to_string(), total, avail, fs_type.to_string()));
101 }
102
103 results
104}
105
106#[cfg(not(target_os = "linux"))]
107fn detect_logical_sysinfo() -> Vec<(String, u64, u64, String)> {
108 use sysinfo::Disks;
109 Disks::new_with_refreshed_list()
110 .iter()
111 .filter(|d| d.total_space() > 0)
112 .map(|d| {
113 (
114 d.mount_point().to_string_lossy().to_string(),
115 d.total_space(),
116 d.available_space(),
117 d.file_system().to_string_lossy().to_string(),
118 )
119 })
120 .collect()
121}
122
123pub fn detect_physical_disks() -> Vec<String> {
124 #[cfg(target_os = "linux")]
125 return detect_linux();
126
127 #[cfg(target_os = "macos")]
128 return detect_macos();
129
130 #[cfg(target_os = "windows")]
131 return detect_windows();
132
133 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
134 return Vec::new();
135}
136
137#[cfg(target_os = "linux")]
138fn detect_linux() -> Vec<String> {
139 use std::fs;
140
141 let Ok(entries) = fs::read_dir("/sys/class/block") else {
142 return Vec::new();
143 };
144
145 let mut disks = Vec::new();
146
147 for entry in entries.flatten() {
148 let name = entry.file_name();
149 let name = name.to_string_lossy();
150
151 if name.starts_with("loop")
153 || name.starts_with("ram")
154 || name.starts_with("zram")
155 || name.starts_with("dm-")
156 || name.starts_with("md")
157 {
158 continue;
159 }
160
161 let dev_path = entry.path();
162
163 if dev_path.join("partition").exists() {
165 continue;
166 }
167
168 if !dev_path.join("queue").exists() {
170 continue;
171 }
172
173 let model = fs::read_to_string(dev_path.join("device/model"))
174 .map(|s| strip_embedded_size(s.trim()).to_string())
175 .unwrap_or_default();
176
177 let size_bytes = fs::read_to_string(dev_path.join("size"))
179 .ok()
180 .and_then(|s| s.trim().parse::<u64>().ok())
181 .map(|sectors| sectors * 512);
182
183 let rotational = fs::read_to_string(dev_path.join("queue/rotational"))
184 .map(|s| s.trim() == "1")
185 .unwrap_or(false);
186
187 let is_nvme = name.starts_with("nvme");
188
189 let kind = if is_nvme {
190 "NVMe SSD"
191 } else if rotational {
192 "HDD"
193 } else {
194 "SSD"
195 };
196
197 let size_str = size_bytes.map(format_size).unwrap_or_default();
198
199 let label = if model.is_empty() {
200 format!("{} [{}]", size_str, kind)
201 } else {
202 format!("{} {} [{}]", model.trim(), size_str, kind)
203 };
204
205 let label = label.trim().to_string();
206 if !label.is_empty() {
207 disks.push(label);
208 }
209 }
210
211 disks.sort();
212 disks
213}
214
215#[cfg(target_os = "macos")]
216fn detect_macos() -> Vec<String> {
217 let output = std::process::Command::new("diskutil")
220 .args(["list", "-plist"])
221 .output();
222
223 let Ok(out) = output else {
224 return Vec::new();
225 };
226 if !out.status.success() {
227 return Vec::new();
228 }
229
230 let text = String::from_utf8_lossy(&out.stdout);
232
233 let mut disk_ids: Vec<String> = Vec::new();
236 let mut in_whole_disks = false;
237 for line in text.lines() {
238 let trimmed = line.trim();
239 if trimmed == "<key>WholeDisks</key>" {
240 in_whole_disks = true;
241 continue;
242 }
243 if in_whole_disks {
244 if trimmed == "</array>" {
245 break;
246 }
247 if let Some(inner) = trimmed
248 .strip_prefix("<string>")
249 .and_then(|s| s.strip_suffix("</string>"))
250 {
251 disk_ids.push(inner.to_string());
252 }
253 }
254 }
255
256 let mut disks = Vec::new();
257 for id in disk_ids {
258 if let Some(entry) = diskutil_info(&id) {
259 disks.push(entry);
260 }
261 }
262 disks
263}
264
265#[cfg(target_os = "macos")]
266fn diskutil_info(disk_id: &str) -> Option<String> {
267 let output = std::process::Command::new("diskutil")
268 .args(["info", "-plist", disk_id])
269 .output()
270 .ok()?;
271
272 if !output.status.success() {
273 return None;
274 }
275
276 let text = String::from_utf8_lossy(&output.stdout);
277 parse_diskutil_info_plist(&text)
278}
279
280#[cfg(target_os = "macos")]
283pub fn parse_diskutil_info_plist(text: &str) -> Option<String> {
284 let mut model = String::new();
285 let mut size_bytes: Option<u64> = None;
286 let mut is_ssd = false;
287 let mut protocol = String::new();
288 let mut virtual_or_physical = String::new();
289
290 let mut last_key = String::new();
291 for line in text.lines() {
292 let trimmed = line.trim();
293 if let Some(key) = trimmed
294 .strip_prefix("<key>")
295 .and_then(|s| s.strip_suffix("</key>"))
296 {
297 last_key = key.to_string();
298 continue;
299 }
300 if let Some(val) = trimmed
301 .strip_prefix("<string>")
302 .and_then(|s| s.strip_suffix("</string>"))
303 {
304 match last_key.as_str() {
305 "MediaName" => {
308 if !val.is_empty() {
309 model = val.to_string();
310 }
311 }
312 "IORegistryEntryName" => {
313 if model.is_empty() && !val.is_empty() {
314 model = val.to_string();
315 }
316 }
317 "BusProtocol" => protocol = val.to_string(),
318 "VirtualOrPhysical" => virtual_or_physical = val.to_string(),
319 _ => {}
320 }
321 }
322 if let Some(val) = trimmed
323 .strip_prefix("<integer>")
324 .and_then(|s| s.strip_suffix("</integer>"))
325 {
326 if last_key == "TotalSize" {
327 size_bytes = val.parse().ok();
328 }
329 }
330 if trimmed == "<true/>" && last_key == "SolidState" {
331 is_ssd = true;
332 }
333 }
334
335 if virtual_or_physical == "Virtual" {
337 return None;
338 }
339
340 let kind =
341 if protocol.to_lowercase().contains("pcie") || protocol.to_lowercase().contains("nvme") {
342 "NVMe SSD"
343 } else if is_ssd {
344 "SSD"
345 } else {
346 "HDD"
347 };
348
349 let size_str = size_bytes.map(format_size).unwrap_or_default();
350
351 let label = if model.is_empty() {
352 format!("{} [{}]", size_str, kind)
353 } else {
354 format!("{} {} [{}]", model.trim(), size_str, kind)
355 };
356
357 Some(label.trim().to_string())
358}
359
360#[cfg(target_os = "linux")]
363fn strip_embedded_size(model: &str) -> &str {
364 let bytes = model.as_bytes();
365 let mut i = bytes.len();
367 while i > 0 && bytes[i - 1] == b' ' {
369 i -= 1;
370 }
371 if i >= 2 {
373 let suffix = &bytes[i - 2..i];
374 if matches!(suffix, b"GB" | b"TB" | b"MB") {
375 i -= 2;
376 let digits_end = i;
378 while i > 0 && bytes[i - 1].is_ascii_digit() {
379 i -= 1;
380 }
381 if i < digits_end {
382 if i > 0 && bytes[i - 1] == b' ' {
384 i -= 1;
385 }
386 return model[..i].trim_end();
387 }
388 }
389 }
390 model
391}
392
393#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
394fn format_size(bytes: u64) -> String {
395 const TB: u64 = 1_000_000_000_000;
396 const GB: u64 = 1_000_000_000;
397 if bytes >= TB {
398 format!("{:.1} TB", bytes as f64 / TB as f64)
399 } else {
400 format!("{:.0} GB", bytes as f64 / GB as f64)
401 }
402}
403
404#[cfg(target_os = "windows")]
405fn detect_windows() -> Vec<String> {
406 let cmd = "Get-PhysicalDisk | \
407 Select-Object FriendlyName,Size,MediaType,BusType | \
408 ConvertTo-Csv -NoTypeInformation";
409 let Ok(output) = std::process::Command::new("powershell")
410 .args(["-NoProfile", "-NonInteractive", "-Command", cmd])
411 .output()
412 else {
413 return Vec::new();
414 };
415 if !output.status.success() {
416 return Vec::new();
417 }
418 let text = String::from_utf8_lossy(&output.stdout);
419 parse_get_physical_disk(&text)
420}
421
422#[cfg(target_os = "windows")]
426pub fn parse_get_physical_disk(csv: &str) -> Vec<String> {
427 let mut lines = csv.lines();
428 lines.next(); let mut disks = Vec::new();
430 for line in lines {
431 let fields = unquoted_csv_fields(line);
432 if fields.len() < 4 {
433 continue;
434 }
435 let name = fields[0].trim();
436 let size_bytes: Option<u64> = fields[1].trim().parse().ok();
437 let media_type = fields[2].trim();
438 let bus_type = fields[3].trim();
439
440 let kind = if bus_type.eq_ignore_ascii_case("NVMe") {
441 "NVMe SSD"
442 } else if media_type.eq_ignore_ascii_case("SSD") {
443 "SSD"
444 } else if media_type.eq_ignore_ascii_case("HDD") {
445 "HDD"
446 } else {
447 "SSD"
449 };
450
451 let size_str = size_bytes.map(format_size).unwrap_or_default();
452 let label = if name.is_empty() {
453 format!("{} [{}]", size_str, kind)
454 } else {
455 format!("{} {} [{}]", name, size_str, kind)
456 };
457 disks.push(label.trim().to_string());
458 }
459 disks
460}
461
462#[cfg(target_os = "windows")]
466fn unquoted_csv_fields(line: &str) -> Vec<String> {
467 let line = line.trim().trim_matches('"');
468 line.split("\",\"").map(|s| s.to_string()).collect()
469}
470
471#[cfg(test)]
472mod tests {
473 #[cfg(any(target_os = "linux", target_os = "macos"))]
474 use super::format_size;
475 #[cfg(target_os = "linux")]
476 use super::strip_embedded_size;
477
478 #[cfg(target_os = "linux")]
479 #[test]
480 fn test_strip_embedded_size() {
481 assert_eq!(
482 strip_embedded_size("BC901 NVMe SK hynix 1024GB"),
483 "BC901 NVMe SK hynix"
484 );
485 assert_eq!(
486 strip_embedded_size("Samsung SSD 970 EVO 500GB"),
487 "Samsung SSD 970 EVO"
488 );
489 assert_eq!(strip_embedded_size("WD Blue 2TB"), "WD Blue");
490 assert_eq!(strip_embedded_size("CT500MX500SSD1"), "CT500MX500SSD1"); assert_eq!(
492 strip_embedded_size("SAMSUNG MZQL23T8HCLS"),
493 "SAMSUNG MZQL23T8HCLS"
494 ); assert_eq!(strip_embedded_size("Some Drive 256GB"), "Some Drive");
496 }
497
498 #[cfg(any(target_os = "linux", target_os = "macos"))]
499 #[test]
500 fn test_format_size_gb() {
501 assert_eq!(format_size(512_110_190_592), "512 GB");
502 }
503
504 #[cfg(any(target_os = "linux", target_os = "macos"))]
505 #[test]
506 fn test_format_size_tb() {
507 assert_eq!(format_size(1_000_204_886_016), "1.0 TB");
508 }
509
510 #[cfg(any(target_os = "linux", target_os = "macos"))]
511 #[test]
512 fn test_format_size_2tb() {
513 assert_eq!(format_size(2_000_398_934_016), "2.0 TB");
514 }
515
516 #[cfg(target_os = "macos")]
517 #[test]
518 fn test_parse_diskutil_info_plist_apple_silicon() {
519 let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
522<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
523<plist version="1.0">
524<dict>
525 <key>BusProtocol</key>
526 <string>Apple Fabric</string>
527 <key>IORegistryEntryName</key>
528 <string>APPLE SSD AP1024Z Media</string>
529 <key>MediaName</key>
530 <string>APPLE SSD AP1024Z</string>
531 <key>SolidState</key>
532 <true/>
533 <key>TotalSize</key>
534 <integer>1000555581440</integer>
535 <key>VirtualOrPhysical</key>
536 <string>Unknown</string>
537</dict>
538</plist>"#;
539 let result = super::parse_diskutil_info_plist(plist);
540 assert_eq!(result, Some("APPLE SSD AP1024Z 1.0 TB [SSD]".to_string()));
541 }
542
543 #[cfg(target_os = "macos")]
544 #[test]
545 fn test_parse_diskutil_info_plist_nvme() {
546 let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
547<plist version="1.0">
548<dict>
549 <key>BusProtocol</key>
550 <string>PCIe</string>
551 <key>MediaName</key>
552 <string>Samsung SSD 990 Pro</string>
553 <key>SolidState</key>
554 <true/>
555 <key>TotalSize</key>
556 <integer>2000398934016</integer>
557 <key>VirtualOrPhysical</key>
558 <string>Physical</string>
559</dict>
560</plist>"#;
561 let result = super::parse_diskutil_info_plist(plist);
562 assert_eq!(
563 result,
564 Some("Samsung SSD 990 Pro 2.0 TB [NVMe SSD]".to_string())
565 );
566 }
567
568 #[cfg(target_os = "macos")]
569 #[test]
570 fn test_parse_diskutil_info_plist_virtual_skipped() {
571 let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
572<plist version="1.0">
573<dict>
574 <key>MediaName</key>
575 <string>APFS Container Disk</string>
576 <key>TotalSize</key>
577 <integer>500000000000</integer>
578 <key>VirtualOrPhysical</key>
579 <string>Virtual</string>
580</dict>
581</plist>"#;
582 let result = super::parse_diskutil_info_plist(plist);
583 assert_eq!(result, None);
584 }
585
586 #[cfg(target_os = "windows")]
587 #[test]
588 fn test_parse_get_physical_disk_nvme() {
589 let csv = r#""FriendlyName","Size","MediaType","BusType"
590"Samsung SSD 980 Pro 1TB","1000204886016","SSD","NVMe"
591"#;
592 let disks = super::parse_get_physical_disk(csv);
593 assert_eq!(disks, vec!["Samsung SSD 980 Pro 1TB 1.0 TB [NVMe SSD]"]);
594 }
595
596 #[cfg(target_os = "windows")]
597 #[test]
598 fn test_parse_get_physical_disk_hdd() {
599 let csv = r#""FriendlyName","Size","MediaType","BusType"
600"WD Blue 2TB","2000398934016","HDD","SATA"
601"#;
602 let disks = super::parse_get_physical_disk(csv);
603 assert_eq!(disks, vec!["WD Blue 2TB 2.0 TB [HDD]"]);
604 }
605
606 #[cfg(target_os = "windows")]
607 #[test]
608 fn test_parse_get_physical_disk_unspecified_sata_ssd() {
609 let csv = r#""FriendlyName","Size","MediaType","BusType"
611"Crucial CT500MX500SSD1","500107862016","Unspecified","SATA"
612"#;
613 let disks = super::parse_get_physical_disk(csv);
614 assert_eq!(disks, vec!["Crucial CT500MX500SSD1 500 GB [SSD]"]);
615 }
616
617 #[cfg(target_os = "windows")]
618 #[test]
619 fn test_parse_get_physical_disk_multi() {
620 let csv = r#""FriendlyName","Size","MediaType","BusType"
621"Samsung SSD 980 Pro","1000204886016","SSD","NVMe"
622"WD Blue","2000398934016","HDD","SATA"
623"#;
624 let disks = super::parse_get_physical_disk(csv);
625 assert_eq!(disks.len(), 2);
626 assert_eq!(disks[0], "Samsung SSD 980 Pro 1.0 TB [NVMe SSD]");
627 assert_eq!(disks[1], "WD Blue 2.0 TB [HDD]");
628 }
629}