flashkraft_core/domain/drive_info.rs
1//! Drive Information Domain Model
2//!
3//! This module contains the DriveInfo struct which represents
4//! information about a storage drive in the system.
5
6// ---------------------------------------------------------------------------
7// USB metadata (populated by sysfs / diskutil / wmic enumeration)
8// ---------------------------------------------------------------------------
9
10/// Rich metadata sourced from USB descriptors via the native OS enumeration
11/// (sysfs on Linux, `system_profiler` on macOS, wmic PNPDeviceID on Windows).
12///
13/// Only present when the drive was detected as a USB Mass Storage device.
14/// Internal SATA / NVMe / eMMC drives will have `usb_info: None`.
15#[derive(Debug, Clone)]
16pub struct UsbInfo {
17 /// USB Vendor ID (idVendor), e.g. 0x0781 = SanDisk.
18 pub vendor_id: u16,
19 /// USB Product ID (idProduct), e.g. 0x5581 = Ultra.
20 pub product_id: u16,
21 /// Manufacturer string from USB descriptor, if available.
22 pub manufacturer: Option<String>,
23 /// Product string from USB descriptor, if available.
24 pub product: Option<String>,
25 /// Serial number string from USB descriptor, if available.
26 pub serial: Option<String>,
27 /// Human-readable connection speed, e.g. `"SuperSpeed (5 Gbps)"`.
28 pub speed: Option<String>,
29}
30
31impl UsbInfo {
32 /// Build a display label from the available USB descriptor strings.
33 ///
34 /// Priority: `"{manufacturer} {product}"` → `"{product}"` → `"{vendor_id}:{product_id}"`.
35 pub fn display_label(&self) -> String {
36 match (&self.manufacturer, &self.product) {
37 (Some(mfr), Some(prd)) => format!("{} {}", mfr.trim(), prd.trim()),
38 (None, Some(prd)) => prd.trim().to_string(),
39 (Some(mfr), None) => mfr.trim().to_string(),
40 (None, None) => format!("{:04x}:{:04x}", self.vendor_id, self.product_id),
41 }
42 }
43}
44
45// ---------------------------------------------------------------------------
46// DriveInfo
47// ---------------------------------------------------------------------------
48
49/// Information about a storage drive visible to the system.
50#[derive(Debug, Clone)]
51pub struct DriveInfo {
52 /// Human-readable display name (model, vendor, or device node).
53 pub name: String,
54 /// Mount point of the drive or one of its partitions.
55 /// Falls back to the device path when not mounted.
56 pub mount_point: String,
57 /// Drive capacity in gigabytes.
58 pub size_gb: f64,
59 /// Kernel block device path, e.g. `/dev/sdb`.
60 pub device_path: String,
61 /// `true` when the drive (or one of its partitions) is mounted at a
62 /// critical system location (`/`, `/boot`, `/usr`, …).
63 pub is_system: bool,
64 /// `true` when the kernel reports the device as read-only (`/sys/block/X/ro == 1`).
65 pub is_read_only: bool,
66 /// `true` when a constraint check determined this drive must not be
67 /// selected (too small, read-only, source drive, …).
68 pub disabled: bool,
69 /// USB descriptor metadata — `Some` for USB drives, `None` for internal
70 /// SATA / NVMe / eMMC devices.
71 pub usb_info: Option<UsbInfo>,
72}
73
74impl DriveInfo {
75 /// Return the drive capacity in bytes.
76 pub fn size_bytes(&self) -> u64 {
77 (self.size_gb * 1_073_741_824.0) as u64
78 }
79
80 /// Create a `DriveInfo` with the four essential fields.
81 ///
82 /// `is_system`, `is_read_only`, `disabled`, and `usb_info` all default
83 /// to `false` / `None`.
84 pub fn new(name: String, mount_point: String, size_gb: f64, device_path: String) -> Self {
85 Self {
86 name,
87 mount_point,
88 size_gb,
89 device_path,
90 is_system: false,
91 is_read_only: false,
92 disabled: false,
93 usb_info: None,
94 }
95 }
96
97 /// Create a `DriveInfo` with constraint flags set explicitly.
98 ///
99 /// `disabled` defaults to `false`; `usb_info` defaults to `None`.
100 pub fn with_constraints(
101 name: String,
102 mount_point: String,
103 size_gb: f64,
104 device_path: String,
105 is_system: bool,
106 is_read_only: bool,
107 ) -> Self {
108 Self {
109 name,
110 mount_point,
111 size_gb,
112 device_path,
113 is_system,
114 is_read_only,
115 disabled: false,
116 usb_info: None,
117 }
118 }
119
120 /// Attach USB descriptor metadata to this drive and return `self`.
121 ///
122 /// Intended for use in a builder chain:
123 /// ```rust
124 /// # use flashkraft_core::domain::drive_info::{DriveInfo, UsbInfo};
125 /// let drive = DriveInfo::new(
126 /// "SanDisk Ultra".into(), "/dev/sdb".into(), 32.0, "/dev/sdb".into(),
127 /// )
128 /// .with_usb_info(UsbInfo {
129 /// vendor_id: 0x0781,
130 /// product_id: 0x5581,
131 /// manufacturer: Some("SanDisk".into()),
132 /// product: Some("Ultra".into()),
133 /// serial: Some("AA01234567890".into()),
134 /// speed: Some("SuperSpeed (5 Gbps)".into()),
135 /// });
136 /// assert!(drive.usb_info.is_some());
137 /// ```
138 pub fn with_usb_info(mut self, info: UsbInfo) -> Self {
139 self.usb_info = Some(info);
140 self
141 }
142
143 /// Return `true` if this is a USB-attached drive (has USB descriptor info).
144 pub fn is_usb(&self) -> bool {
145 self.usb_info.is_some()
146 }
147}
148
149impl PartialEq for DriveInfo {
150 /// Two drives are equal when they refer to the same kernel block device.
151 fn eq(&self, other: &Self) -> bool {
152 self.device_path == other.device_path
153 }
154}
155
156// ---------------------------------------------------------------------------
157// Tests
158// ---------------------------------------------------------------------------
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 fn usb_info_fixture() -> UsbInfo {
165 UsbInfo {
166 vendor_id: 0x0781,
167 product_id: 0x5581,
168 manufacturer: Some("SanDisk".into()),
169 product: Some("Ultra".into()),
170 serial: Some("SN123456".into()),
171 speed: Some("SuperSpeed (5 Gbps)".into()),
172 }
173 }
174
175 #[test]
176 fn test_new_defaults() {
177 let d = DriveInfo::new(
178 "USB Drive".into(),
179 "/media/usb".into(),
180 32.0,
181 "/dev/sdb".into(),
182 );
183 assert!(!d.is_system);
184 assert!(!d.is_read_only);
185 assert!(!d.disabled);
186 assert!(d.usb_info.is_none());
187 assert!(!d.is_usb());
188 }
189
190 #[test]
191 fn test_with_constraints_defaults_usb_info_none() {
192 let d = DriveInfo::with_constraints(
193 "USB".into(),
194 "/media/usb".into(),
195 32.0,
196 "/dev/sdb".into(),
197 true,
198 false,
199 );
200 assert!(d.is_system);
201 assert!(!d.is_read_only);
202 assert!(d.usb_info.is_none());
203 }
204
205 #[test]
206 fn test_with_usb_info_builder() {
207 let d = DriveInfo::new(
208 "SanDisk Ultra".into(),
209 "/media/usb".into(),
210 32.0,
211 "/dev/sdb".into(),
212 )
213 .with_usb_info(usb_info_fixture());
214
215 assert!(d.is_usb());
216 let info = d.usb_info.as_ref().unwrap();
217 assert_eq!(info.vendor_id, 0x0781);
218 assert_eq!(info.product_id, 0x5581);
219 assert_eq!(info.serial.as_deref(), Some("SN123456"));
220 assert_eq!(info.speed.as_deref(), Some("SuperSpeed (5 Gbps)"));
221 }
222
223 #[test]
224 fn test_usb_info_display_label_both() {
225 let info = usb_info_fixture();
226 assert_eq!(info.display_label(), "SanDisk Ultra");
227 }
228
229 #[test]
230 fn test_usb_info_display_label_product_only() {
231 let info = UsbInfo {
232 vendor_id: 0x1234,
233 product_id: 0x5678,
234 manufacturer: None,
235 product: Some("MyDrive".into()),
236 serial: None,
237 speed: None,
238 };
239 assert_eq!(info.display_label(), "MyDrive");
240 }
241
242 #[test]
243 fn test_usb_info_display_label_fallback_to_ids() {
244 let info = UsbInfo {
245 vendor_id: 0x1234,
246 product_id: 0xabcd,
247 manufacturer: None,
248 product: None,
249 serial: None,
250 speed: None,
251 };
252 assert_eq!(info.display_label(), "1234:abcd");
253 }
254
255 #[test]
256 fn test_drive_equality_by_device_path() {
257 let d1 = DriveInfo::new("A".into(), "/mnt/a".into(), 32.0, "/dev/sdb".into());
258 let d2 = DriveInfo::new("B".into(), "/mnt/b".into(), 64.0, "/dev/sdb".into());
259 let d3 = DriveInfo::new("C".into(), "/mnt/c".into(), 32.0, "/dev/sdc".into());
260 assert_eq!(d1, d2, "same device_path → equal");
261 assert_ne!(d1, d3, "different device_path → not equal");
262 }
263
264 #[test]
265 fn test_drive_equality_ignores_usb_info() {
266 let d1 = DriveInfo::new("A".into(), "/mnt/a".into(), 32.0, "/dev/sdb".into())
267 .with_usb_info(usb_info_fixture());
268 let d2 = DriveInfo::new("A".into(), "/mnt/a".into(), 32.0, "/dev/sdb".into());
269 assert_eq!(d1, d2, "usb_info must not affect equality");
270 }
271}