1pub fn detect_logical_disks(include_fuse: bool) -> Vec<(String, u64, u64, String)> {
15 #[cfg(target_os = "linux")]
16 {
17 detect_logical_linux(include_fuse)
18 }
19
20 #[cfg(not(target_os = "linux"))]
21 {
22 let _ = include_fuse;
23 detect_logical_sysinfo()
24 }
25}
26
27#[cfg(target_os = "linux")]
29fn is_skip_fs(fs_type: &str, include_fuse: bool) -> bool {
30 const SKIP: &[&str] = &[
31 "sysfs",
32 "proc",
33 "devtmpfs",
34 "tmpfs",
35 "devpts",
36 "cgroup",
37 "cgroup2",
38 "pstore",
39 "bpf",
40 "tracefs",
41 "debugfs",
42 "securityfs",
43 "hugetlbfs",
44 "mqueue",
45 "fusectl",
46 "rpc_pipefs",
47 "configfs",
48 "autofs",
49 "efivarfs",
50 "binfmt_misc",
51 "squashfs",
52 "overlay",
53 "ramfs",
54 "rootfs",
55 "nsfs",
56 "pipefs",
57 "sockfs",
58 "anon_inodefs",
59 "cpuset",
60 ];
61 SKIP.contains(&fs_type) || (fs_type.starts_with("fuse.") && !include_fuse)
63}
64
65#[cfg(target_os = "linux")]
66fn detect_logical_linux(include_fuse: bool) -> Vec<(String, u64, u64, String)> {
67 use std::collections::HashSet;
68 use std::ffi::CString;
69
70 let mounts = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
71 let mut results = Vec::new();
72 let mut seen_devs: HashSet<String> = HashSet::new();
73
74 for line in mounts.lines() {
75 let parts: Vec<&str> = line.splitn(4, ' ').collect();
76 if parts.len() < 3 {
77 continue;
78 }
79 let device = parts[0];
80 let mount_point = parts[1];
81 let fs_type = parts[2];
82
83 if is_skip_fs(fs_type, include_fuse) {
84 continue;
85 }
86
87 if device.starts_with('/') && !seen_devs.insert(device.to_string()) {
89 continue;
90 }
91
92 let Ok(mp_c) = CString::new(mount_point) else {
93 continue;
94 };
95
96 let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
97 if unsafe { libc::statvfs(mp_c.as_ptr(), &mut stat) } != 0 {
98 continue;
99 }
100
101 let total = (stat.f_blocks as u64).saturating_mul(stat.f_frsize as u64);
102 let avail = (stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64);
103
104 if total == 0 {
105 continue;
106 }
107
108 results.push((mount_point.to_string(), total, avail, fs_type.to_string()));
109 }
110
111 results
112}
113
114#[cfg(not(target_os = "linux"))]
115fn detect_logical_sysinfo() -> Vec<(String, u64, u64, String)> {
116 use sysinfo::Disks;
117 Disks::new_with_refreshed_list()
118 .iter()
119 .filter(|d| d.total_space() > 0)
120 .map(|d| {
121 (
122 d.mount_point().to_string_lossy().to_string(),
123 d.total_space(),
124 d.available_space(),
125 d.file_system().to_string_lossy().to_string(),
126 )
127 })
128 .collect()
129}
130
131pub fn detect_physical_disks() -> Vec<String> {
132 #[cfg(target_os = "linux")]
133 return detect_linux();
134
135 #[cfg(target_os = "macos")]
136 return detect_macos();
137
138 #[cfg(target_os = "windows")]
139 return detect_windows();
140
141 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
142 return Vec::new();
143}
144
145#[cfg(target_os = "linux")]
146fn detect_linux() -> Vec<String> {
147 use std::fs;
148
149 let Ok(entries) = fs::read_dir("/sys/class/block") else {
150 return Vec::new();
151 };
152
153 let mut disks = Vec::new();
154
155 for entry in entries.flatten() {
156 let name = entry.file_name();
157 let name = name.to_string_lossy();
158
159 if name.starts_with("loop")
161 || name.starts_with("ram")
162 || name.starts_with("zram")
163 || name.starts_with("dm-")
164 || name.starts_with("md")
165 {
166 continue;
167 }
168
169 let dev_path = entry.path();
170
171 if dev_path.join("partition").exists() {
173 continue;
174 }
175
176 if !dev_path.join("queue").exists() {
178 continue;
179 }
180
181 let model = fs::read_to_string(dev_path.join("device/model"))
182 .map(|s| strip_embedded_size(s.trim()).to_string())
183 .unwrap_or_default();
184
185 let size_bytes = fs::read_to_string(dev_path.join("size"))
187 .ok()
188 .and_then(|s| s.trim().parse::<u64>().ok())
189 .map(|sectors| sectors * 512);
190
191 let rotational = fs::read_to_string(dev_path.join("queue/rotational"))
192 .map(|s| s.trim() == "1")
193 .unwrap_or(false);
194
195 let is_nvme = name.starts_with("nvme");
196
197 let kind = if is_nvme {
198 "NVMe SSD"
199 } else if rotational {
200 "HDD"
201 } else {
202 "SSD"
203 };
204
205 let size_str = size_bytes.map(format_size).unwrap_or_default();
206
207 let label = if model.is_empty() {
208 format!("{} [{}]", size_str, kind)
209 } else {
210 format!("{} {} [{}]", model.trim(), size_str, kind)
211 };
212
213 let label = label.trim().to_string();
214 if !label.is_empty() {
215 disks.push(label);
216 }
217 }
218
219 disks.sort();
220 disks
221}
222
223#[cfg(target_os = "macos")]
224fn detect_macos() -> Vec<String> {
225 let output = std::process::Command::new("diskutil")
228 .args(["list", "-plist"])
229 .output();
230
231 let Ok(out) = output else {
232 return Vec::new();
233 };
234 if !out.status.success() {
235 return Vec::new();
236 }
237
238 let text = String::from_utf8_lossy(&out.stdout);
240
241 let mut disk_ids: Vec<String> = Vec::new();
244 let mut in_whole_disks = false;
245 for line in text.lines() {
246 let trimmed = line.trim();
247 if trimmed == "<key>WholeDisks</key>" {
248 in_whole_disks = true;
249 continue;
250 }
251 if in_whole_disks {
252 if trimmed == "</array>" {
253 break;
254 }
255 if let Some(inner) = trimmed
256 .strip_prefix("<string>")
257 .and_then(|s| s.strip_suffix("</string>"))
258 {
259 disk_ids.push(inner.to_string());
260 }
261 }
262 }
263
264 let mut disks = Vec::new();
265 for id in disk_ids {
266 if let Some(entry) = diskutil_info(&id) {
267 disks.push(entry);
268 }
269 }
270 disks
271}
272
273#[cfg(target_os = "macos")]
274fn diskutil_info(disk_id: &str) -> Option<String> {
275 let output = std::process::Command::new("diskutil")
276 .args(["info", "-plist", disk_id])
277 .output()
278 .ok()?;
279
280 if !output.status.success() {
281 return None;
282 }
283
284 let text = String::from_utf8_lossy(&output.stdout);
285 parse_diskutil_info_plist(&text)
286}
287
288#[cfg(target_os = "macos")]
291pub fn parse_diskutil_info_plist(text: &str) -> Option<String> {
292 let mut model = String::new();
293 let mut size_bytes: Option<u64> = None;
294 let mut is_ssd = false;
295 let mut protocol = String::new();
296 let mut virtual_or_physical = String::new();
297
298 let mut last_key = String::new();
299 for line in text.lines() {
300 let trimmed = line.trim();
301 if let Some(key) = trimmed
302 .strip_prefix("<key>")
303 .and_then(|s| s.strip_suffix("</key>"))
304 {
305 last_key = key.to_string();
306 continue;
307 }
308 if let Some(val) = trimmed
309 .strip_prefix("<string>")
310 .and_then(|s| s.strip_suffix("</string>"))
311 {
312 match last_key.as_str() {
313 "MediaName" => {
316 if !val.is_empty() {
317 model = val.to_string();
318 }
319 }
320 "IORegistryEntryName" => {
321 if model.is_empty() && !val.is_empty() {
322 model = val.to_string();
323 }
324 }
325 "BusProtocol" => protocol = val.to_string(),
326 "VirtualOrPhysical" => virtual_or_physical = val.to_string(),
327 _ => {}
328 }
329 }
330 if let Some(val) = trimmed
331 .strip_prefix("<integer>")
332 .and_then(|s| s.strip_suffix("</integer>"))
333 {
334 if last_key == "TotalSize" {
335 size_bytes = val.parse().ok();
336 }
337 }
338 if trimmed == "<true/>" && last_key == "SolidState" {
339 is_ssd = true;
340 }
341 }
342
343 if virtual_or_physical == "Virtual" {
345 return None;
346 }
347
348 let kind =
349 if protocol.to_lowercase().contains("pcie") || protocol.to_lowercase().contains("nvme") {
350 "NVMe SSD"
351 } else if is_ssd {
352 "SSD"
353 } else {
354 "HDD"
355 };
356
357 let size_str = size_bytes.map(format_size).unwrap_or_default();
358
359 let label = if model.is_empty() {
360 format!("{} [{}]", size_str, kind)
361 } else {
362 format!("{} {} [{}]", model.trim(), size_str, kind)
363 };
364
365 Some(label.trim().to_string())
366}
367
368#[cfg(target_os = "linux")]
371fn strip_embedded_size(model: &str) -> &str {
372 let bytes = model.as_bytes();
373 let mut i = bytes.len();
375 while i > 0 && bytes[i - 1] == b' ' {
377 i -= 1;
378 }
379 if i >= 2 {
381 let suffix = &bytes[i - 2..i];
382 if matches!(suffix, b"GB" | b"TB" | b"MB") {
383 i -= 2;
384 let digits_end = i;
386 while i > 0 && bytes[i - 1].is_ascii_digit() {
387 i -= 1;
388 }
389 if i < digits_end {
390 if i > 0 && bytes[i - 1] == b' ' {
392 i -= 1;
393 }
394 return model[..i].trim_end();
395 }
396 }
397 }
398 model
399}
400
401#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
402fn format_size(bytes: u64) -> String {
403 const TB: u64 = 1_000_000_000_000;
404 const GB: u64 = 1_000_000_000;
405 if bytes >= TB {
406 format!("{:.1} TB", bytes as f64 / TB as f64)
407 } else {
408 format!("{:.0} GB", bytes as f64 / GB as f64)
409 }
410}
411
412#[cfg(target_os = "windows")]
419fn detect_windows() -> Vec<String> {
420 const MAX_DRIVES: u32 = 32;
425 (0..MAX_DRIVES)
426 .filter_map(win_ffi::query_physical_drive)
427 .collect()
428}
429
430#[cfg(target_os = "windows")]
437fn format_disk_label(
438 model: &str,
439 size_bytes: Option<u64>,
440 bus_type: u32,
441 incurs_seek_penalty: Option<bool>,
442) -> String {
443 let kind = if bus_type == win_ffi::BUS_TYPE_NVME {
444 "NVMe SSD"
445 } else {
446 match incurs_seek_penalty {
447 Some(true) => "HDD",
448 Some(false) | None => "SSD",
451 }
452 };
453
454 let name = model.trim();
455 let size_str = size_bytes.map(format_size).unwrap_or_default();
456 let label = if name.is_empty() {
457 format!("{} [{}]", size_str, kind)
458 } else {
459 format!("{} {} [{}]", name, size_str, kind)
460 };
461 label.trim().to_string()
462}
463
464#[cfg(target_os = "windows")]
472fn combine_model(vendor: &str, product: &str) -> String {
473 let v = vendor.trim();
474 let p = product.trim();
475 if p.is_empty() {
476 return v.to_string();
477 }
478 if v.is_empty()
479 || v.eq_ignore_ascii_case("ATA")
480 || p.to_ascii_lowercase().contains(&v.to_ascii_lowercase())
481 {
482 p.to_string()
483 } else {
484 format!("{} {}", v, p)
485 }
486}
487
488#[cfg(target_os = "windows")]
493mod win_ffi {
494 use super::{combine_model, format_disk_label};
495 use std::ffi::{c_void, OsStr};
496 use std::mem::size_of;
497 use std::os::windows::ffi::OsStrExt;
498 use std::ptr;
499
500 #[allow(clippy::upper_case_acronyms)]
501 type HANDLE = *mut c_void;
502 const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE;
503 const FILE_SHARE_READ: u32 = 0x0000_0001;
504 const FILE_SHARE_WRITE: u32 = 0x0000_0002;
505 const OPEN_EXISTING: u32 = 3;
506
507 const IOCTL_STORAGE_QUERY_PROPERTY: u32 = 0x002D_1400;
510 const IOCTL_DISK_GET_DRIVE_GEOMETRY_EX: u32 = 0x0007_00A0;
511
512 const STORAGE_DEVICE_PROPERTY: u32 = 0;
514 const STORAGE_DEVICE_SEEK_PENALTY_PROPERTY: u32 = 7;
515 const PROPERTY_STANDARD_QUERY: u32 = 0;
517
518 pub const BUS_TYPE_NVME: u32 = 17;
520
521 #[repr(C)]
522 struct StoragePropertyQuery {
523 property_id: u32,
524 query_type: u32,
525 additional_parameters: [u8; 1],
526 }
527
528 #[repr(C)]
529 struct StorageDeviceDescriptor {
530 version: u32,
531 size: u32,
532 device_type: u8,
533 device_type_modifier: u8,
534 removable_media: u8,
535 command_queueing: u8,
536 vendor_id_offset: u32,
537 product_id_offset: u32,
538 product_revision_offset: u32,
539 serial_number_offset: u32,
540 bus_type: u32,
541 raw_properties_length: u32,
542 raw_device_properties: [u8; 1],
543 }
544
545 #[repr(C)]
546 struct DeviceSeekPenaltyDescriptor {
547 version: u32,
548 size: u32,
549 incurs_seek_penalty: u8,
550 }
551
552 #[repr(C)]
553 struct DiskGeometry {
554 cylinders: i64,
555 media_type: u32,
556 tracks_per_cylinder: u32,
557 sectors_per_track: u32,
558 bytes_per_sector: u32,
559 }
560
561 #[repr(C)]
562 struct DiskGeometryEx {
563 geometry: DiskGeometry,
564 disk_size: i64,
565 data: [u8; 1],
566 }
567
568 extern "system" {
569 fn CreateFileW(
570 lp_file_name: *const u16,
571 dw_desired_access: u32,
572 dw_share_mode: u32,
573 lp_security_attributes: *mut c_void,
574 dw_creation_disposition: u32,
575 dw_flags_and_attributes: u32,
576 h_template_file: HANDLE,
577 ) -> HANDLE;
578
579 fn DeviceIoControl(
580 h_device: HANDLE,
581 dw_io_control_code: u32,
582 lp_in_buffer: *const c_void,
583 n_in_buffer_size: u32,
584 lp_out_buffer: *mut c_void,
585 n_out_buffer_size: u32,
586 lp_bytes_returned: *mut u32,
587 lp_overlapped: *mut c_void,
588 ) -> i32;
589
590 fn CloseHandle(h_object: HANDLE) -> i32;
591 }
592
593 pub fn query_physical_drive(index: u32) -> Option<String> {
596 let path = format!(r"\\.\PhysicalDrive{index}");
597 let path_w: Vec<u16> = OsStr::new(&path).encode_wide().chain(Some(0)).collect();
598
599 let handle = unsafe {
602 CreateFileW(
603 path_w.as_ptr(),
604 0,
605 FILE_SHARE_READ | FILE_SHARE_WRITE,
606 ptr::null_mut(),
607 OPEN_EXISTING,
608 0,
609 ptr::null_mut(),
610 )
611 };
612 if handle == INVALID_HANDLE_VALUE || handle.is_null() {
613 return None;
614 }
615
616 let descriptor = query_device_descriptor(handle);
617 let result = descriptor.map(|(bus_type, model)| {
618 let size = query_disk_size(handle);
619 let seek = query_seek_penalty(handle);
620 format_disk_label(&model, size, bus_type, seek)
621 });
622
623 unsafe {
625 CloseHandle(handle);
626 }
627 result
628 }
629
630 fn read_ansi_at(buf: &[u8], offset: usize) -> String {
633 if offset == 0 || offset >= buf.len() {
634 return String::new();
635 }
636 let bytes = &buf[offset..];
637 let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
638 String::from_utf8_lossy(&bytes[..end]).trim().to_string()
639 }
640
641 fn query_device_descriptor(handle: HANDLE) -> Option<(u32, String)> {
644 let query = StoragePropertyQuery {
645 property_id: STORAGE_DEVICE_PROPERTY,
646 query_type: PROPERTY_STANDARD_QUERY,
647 additional_parameters: [0; 1],
648 };
649 let mut buf = [0u8; 1024];
652 let mut returned: u32 = 0;
653 let ok = unsafe {
656 DeviceIoControl(
657 handle,
658 IOCTL_STORAGE_QUERY_PROPERTY,
659 &query as *const _ as *const c_void,
660 size_of::<StoragePropertyQuery>() as u32,
661 buf.as_mut_ptr() as *mut c_void,
662 buf.len() as u32,
663 &mut returned,
664 ptr::null_mut(),
665 )
666 };
667 if ok == 0 || (returned as usize) < size_of::<StorageDeviceDescriptor>() {
668 return None;
669 }
670 let desc = unsafe { &*(buf.as_ptr() as *const StorageDeviceDescriptor) };
674 let bus_type = desc.bus_type;
675 let vendor = read_ansi_at(&buf, desc.vendor_id_offset as usize);
676 let product = read_ansi_at(&buf, desc.product_id_offset as usize);
677 Some((bus_type, combine_model(&vendor, &product)))
678 }
679
680 fn query_disk_size(handle: HANDLE) -> Option<u64> {
682 let mut geo = DiskGeometryEx {
683 geometry: DiskGeometry {
684 cylinders: 0,
685 media_type: 0,
686 tracks_per_cylinder: 0,
687 sectors_per_track: 0,
688 bytes_per_sector: 0,
689 },
690 disk_size: 0,
691 data: [0; 1],
692 };
693 let mut returned: u32 = 0;
694 let ok = unsafe {
696 DeviceIoControl(
697 handle,
698 IOCTL_DISK_GET_DRIVE_GEOMETRY_EX,
699 ptr::null(),
700 0,
701 &mut geo as *mut _ as *mut c_void,
702 size_of::<DiskGeometryEx>() as u32,
703 &mut returned,
704 ptr::null_mut(),
705 )
706 };
707 if ok == 0 || geo.disk_size <= 0 {
708 None
709 } else {
710 Some(geo.disk_size as u64)
711 }
712 }
713
714 fn query_seek_penalty(handle: HANDLE) -> Option<bool> {
718 let query = StoragePropertyQuery {
719 property_id: STORAGE_DEVICE_SEEK_PENALTY_PROPERTY,
720 query_type: PROPERTY_STANDARD_QUERY,
721 additional_parameters: [0; 1],
722 };
723 let mut desc = DeviceSeekPenaltyDescriptor {
724 version: 0,
725 size: 0,
726 incurs_seek_penalty: 0,
727 };
728 let mut returned: u32 = 0;
729 let ok = unsafe {
731 DeviceIoControl(
732 handle,
733 IOCTL_STORAGE_QUERY_PROPERTY,
734 &query as *const _ as *const c_void,
735 size_of::<StoragePropertyQuery>() as u32,
736 &mut desc as *mut _ as *mut c_void,
737 size_of::<DeviceSeekPenaltyDescriptor>() as u32,
738 &mut returned,
739 ptr::null_mut(),
740 )
741 };
742 if ok == 0 || (returned as usize) < size_of::<DeviceSeekPenaltyDescriptor>() {
743 None
744 } else {
745 Some(desc.incurs_seek_penalty != 0)
746 }
747 }
748
749 #[cfg(test)]
750 mod layout {
751 use std::mem::{offset_of, size_of};
752
753 #[test]
756 fn ffi_struct_layout() {
757 assert_eq!(size_of::<super::StoragePropertyQuery>(), 12);
758 assert_eq!(size_of::<super::StorageDeviceDescriptor>(), 40);
759 assert_eq!(
760 offset_of!(super::StorageDeviceDescriptor, vendor_id_offset),
761 12
762 );
763 assert_eq!(
764 offset_of!(super::StorageDeviceDescriptor, product_id_offset),
765 16
766 );
767 assert_eq!(offset_of!(super::StorageDeviceDescriptor, bus_type), 28);
768 assert_eq!(size_of::<super::DeviceSeekPenaltyDescriptor>(), 12);
769 assert_eq!(size_of::<super::DiskGeometryEx>(), 40);
770 assert_eq!(offset_of!(super::DiskGeometryEx, disk_size), 24);
771 }
772 }
773}
774
775#[cfg(test)]
776mod tests {
777 #[cfg(any(target_os = "linux", target_os = "macos"))]
778 use super::format_size;
779 #[cfg(target_os = "linux")]
780 use super::{is_skip_fs, strip_embedded_size};
781
782 #[cfg(target_os = "linux")]
783 #[test]
784 fn test_is_skip_fs_pseudo() {
785 assert!(is_skip_fs("sysfs", false));
786 assert!(is_skip_fs("proc", false));
787 assert!(is_skip_fs("tmpfs", false));
788 assert!(is_skip_fs("fusectl", false)); assert!(is_skip_fs("fusectl", true)); }
791
792 #[cfg(target_os = "linux")]
793 #[test]
794 fn test_is_skip_fs_fuse_excluded_by_default() {
795 assert!(is_skip_fs("fuse.gvfsd-fuse", false));
796 assert!(is_skip_fs("fuse.sshfs", false));
797 assert!(is_skip_fs("fuse.cryfs", false));
798 }
799
800 #[cfg(target_os = "linux")]
801 #[test]
802 fn test_is_skip_fs_fuse_included_in_full() {
803 assert!(!is_skip_fs("fuse.gvfsd-fuse", true));
804 assert!(!is_skip_fs("fuse.sshfs", true));
805 assert!(!is_skip_fs("fuse.cryfs", true));
806 }
807
808 #[cfg(target_os = "linux")]
809 #[test]
810 fn test_is_skip_fs_real_fs() {
811 assert!(!is_skip_fs("ext4", false));
812 assert!(!is_skip_fs("btrfs", false));
813 assert!(!is_skip_fs("vfat", false));
814 }
815
816 #[cfg(target_os = "linux")]
817 #[test]
818 fn test_strip_embedded_size() {
819 assert_eq!(
820 strip_embedded_size("BC901 NVMe SK hynix 1024GB"),
821 "BC901 NVMe SK hynix"
822 );
823 assert_eq!(
824 strip_embedded_size("Samsung SSD 970 EVO 500GB"),
825 "Samsung SSD 970 EVO"
826 );
827 assert_eq!(strip_embedded_size("WD Blue 2TB"), "WD Blue");
828 assert_eq!(strip_embedded_size("CT500MX500SSD1"), "CT500MX500SSD1"); assert_eq!(
830 strip_embedded_size("SAMSUNG MZQL23T8HCLS"),
831 "SAMSUNG MZQL23T8HCLS"
832 ); assert_eq!(strip_embedded_size("Some Drive 256GB"), "Some Drive");
834 }
835
836 #[cfg(any(target_os = "linux", target_os = "macos"))]
837 #[test]
838 fn test_format_size_gb() {
839 assert_eq!(format_size(512_110_190_592), "512 GB");
840 }
841
842 #[cfg(any(target_os = "linux", target_os = "macos"))]
843 #[test]
844 fn test_format_size_tb() {
845 assert_eq!(format_size(1_000_204_886_016), "1.0 TB");
846 }
847
848 #[cfg(any(target_os = "linux", target_os = "macos"))]
849 #[test]
850 fn test_format_size_2tb() {
851 assert_eq!(format_size(2_000_398_934_016), "2.0 TB");
852 }
853
854 #[cfg(target_os = "macos")]
855 #[test]
856 fn test_parse_diskutil_info_plist_apple_silicon() {
857 let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
860<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
861<plist version="1.0">
862<dict>
863 <key>BusProtocol</key>
864 <string>Apple Fabric</string>
865 <key>IORegistryEntryName</key>
866 <string>APPLE SSD AP1024Z Media</string>
867 <key>MediaName</key>
868 <string>APPLE SSD AP1024Z</string>
869 <key>SolidState</key>
870 <true/>
871 <key>TotalSize</key>
872 <integer>1000555581440</integer>
873 <key>VirtualOrPhysical</key>
874 <string>Unknown</string>
875</dict>
876</plist>"#;
877 let result = super::parse_diskutil_info_plist(plist);
878 assert_eq!(result, Some("APPLE SSD AP1024Z 1.0 TB [SSD]".to_string()));
879 }
880
881 #[cfg(target_os = "macos")]
882 #[test]
883 fn test_parse_diskutil_info_plist_nvme() {
884 let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
885<plist version="1.0">
886<dict>
887 <key>BusProtocol</key>
888 <string>PCIe</string>
889 <key>MediaName</key>
890 <string>Samsung SSD 990 Pro</string>
891 <key>SolidState</key>
892 <true/>
893 <key>TotalSize</key>
894 <integer>2000398934016</integer>
895 <key>VirtualOrPhysical</key>
896 <string>Physical</string>
897</dict>
898</plist>"#;
899 let result = super::parse_diskutil_info_plist(plist);
900 assert_eq!(
901 result,
902 Some("Samsung SSD 990 Pro 2.0 TB [NVMe SSD]".to_string())
903 );
904 }
905
906 #[cfg(target_os = "macos")]
907 #[test]
908 fn test_parse_diskutil_info_plist_virtual_skipped() {
909 let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
910<plist version="1.0">
911<dict>
912 <key>MediaName</key>
913 <string>APFS Container Disk</string>
914 <key>TotalSize</key>
915 <integer>500000000000</integer>
916 <key>VirtualOrPhysical</key>
917 <string>Virtual</string>
918</dict>
919</plist>"#;
920 let result = super::parse_diskutil_info_plist(plist);
921 assert_eq!(result, None);
922 }
923
924 #[cfg(target_os = "windows")]
925 use super::win_ffi::BUS_TYPE_NVME;
926 #[cfg(target_os = "windows")]
927 use super::{combine_model, format_disk_label};
928
929 #[cfg(target_os = "windows")]
930 #[test]
931 fn test_format_disk_label_nvme() {
932 let label = format_disk_label(
934 "Samsung SSD 980 Pro",
935 Some(1_000_204_886_016),
936 BUS_TYPE_NVME,
937 Some(false),
938 );
939 assert_eq!(label, "Samsung SSD 980 Pro 1.0 TB [NVMe SSD]");
940 }
941
942 #[cfg(target_os = "windows")]
943 #[test]
944 fn test_format_disk_label_hdd() {
945 let label = format_disk_label("WD Blue", Some(2_000_398_934_016), 11, Some(true));
947 assert_eq!(label, "WD Blue 2.0 TB [HDD]");
948 }
949
950 #[cfg(target_os = "windows")]
951 #[test]
952 fn test_format_disk_label_sata_ssd() {
953 let label = format_disk_label(
955 "Crucial CT500MX500SSD1",
956 Some(500_107_862_016),
957 11,
958 Some(false),
959 );
960 assert_eq!(label, "Crucial CT500MX500SSD1 500 GB [SSD]");
961 }
962
963 #[cfg(target_os = "windows")]
964 #[test]
965 fn test_format_disk_label_unknown_seek_penalty_defaults_to_ssd() {
966 let label = format_disk_label("Some eMMC", Some(64_000_000_000), 13, None);
968 assert_eq!(label, "Some eMMC 64 GB [SSD]");
969 }
970
971 #[cfg(target_os = "windows")]
972 #[test]
973 fn test_format_disk_label_empty_model() {
974 let label = format_disk_label("", Some(500_107_862_016), 11, Some(false));
975 assert_eq!(label, "500 GB [SSD]");
976 }
977
978 #[cfg(target_os = "windows")]
979 #[test]
980 fn test_combine_model_generic_ata_vendor_suppressed() {
981 assert_eq!(
983 combine_model("ATA", "Samsung SSD 860 EVO"),
984 "Samsung SSD 860 EVO"
985 );
986 }
987
988 #[cfg(target_os = "windows")]
989 #[test]
990 fn test_combine_model_empty_vendor() {
991 assert_eq!(
992 combine_model("", "Samsung SSD 980 Pro"),
993 "Samsung SSD 980 Pro"
994 );
995 }
996
997 #[cfg(target_os = "windows")]
998 #[test]
999 fn test_combine_model_vendor_already_in_product() {
1000 assert_eq!(
1002 combine_model("Samsung", "Samsung SSD 980 Pro"),
1003 "Samsung SSD 980 Pro"
1004 );
1005 }
1006
1007 #[cfg(target_os = "windows")]
1008 #[test]
1009 fn test_combine_model_distinct_vendor_prepended() {
1010 assert_eq!(combine_model("Kingston", "A400 SSD"), "Kingston A400 SSD");
1011 }
1012
1013 #[cfg(target_os = "windows")]
1014 #[test]
1015 fn test_combine_model_empty_product_falls_back_to_vendor() {
1016 assert_eq!(combine_model("SomeVendor", ""), "SomeVendor");
1017 }
1018}