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 /// Create a `DriveInfo` with the four essential fields.
76 ///
77 /// `is_system`, `is_read_only`, `disabled`, and `usb_info` all default
78 /// to `false` / `None`.
79 pub fn new(name: String, mount_point: String, size_gb: f64, device_path: String) -> Self {
80 Self {
81 name,
82 mount_point,
83 size_gb,
84 device_path,
85 is_system: false,
86 is_read_only: false,
87 disabled: false,
88 usb_info: None,
89 }
90 }
91
92 /// Create a `DriveInfo` with constraint flags set explicitly.
93 ///
94 /// `disabled` defaults to `false`; `usb_info` defaults to `None`.
95 pub fn with_constraints(
96 name: String,
97 mount_point: String,
98 size_gb: f64,
99 device_path: String,
100 is_system: bool,
101 is_read_only: bool,
102 ) -> Self {
103 Self {
104 name,
105 mount_point,
106 size_gb,
107 device_path,
108 is_system,
109 is_read_only,
110 disabled: false,
111 usb_info: None,
112 }
113 }
114
115 /// Attach USB descriptor metadata to this drive and return `self`.
116 ///
117 /// Intended for use in a builder chain:
118 /// ```rust
119 /// # use flashkraft_core::domain::drive_info::{DriveInfo, UsbInfo};
120 /// let drive = DriveInfo::new(
121 /// "SanDisk Ultra".into(), "/dev/sdb".into(), 32.0, "/dev/sdb".into(),
122 /// )
123 /// .with_usb_info(UsbInfo {
124 /// vendor_id: 0x0781,
125 /// product_id: 0x5581,
126 /// manufacturer: Some("SanDisk".into()),
127 /// product: Some("Ultra".into()),
128 /// serial: Some("AA01234567890".into()),
129 /// speed: Some("SuperSpeed (5 Gbps)".into()),
130 /// });
131 /// assert!(drive.usb_info.is_some());
132 /// ```
133 pub fn with_usb_info(mut self, info: UsbInfo) -> Self {
134 self.usb_info = Some(info);
135 self
136 }
137
138 /// Return `true` if this is a USB-attached drive (has USB descriptor info).
139 pub fn is_usb(&self) -> bool {
140 self.usb_info.is_some()
141 }
142}
143
144impl PartialEq for DriveInfo {
145 /// Two drives are equal when they refer to the same kernel block device.
146 fn eq(&self, other: &Self) -> bool {
147 self.device_path == other.device_path
148 }
149}
150
151// ---------------------------------------------------------------------------
152// Tests
153// ---------------------------------------------------------------------------
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 fn usb_info_fixture() -> UsbInfo {
160 UsbInfo {
161 vendor_id: 0x0781,
162 product_id: 0x5581,
163 manufacturer: Some("SanDisk".into()),
164 product: Some("Ultra".into()),
165 serial: Some("SN123456".into()),
166 speed: Some("SuperSpeed (5 Gbps)".into()),
167 }
168 }
169
170 #[test]
171 fn test_new_defaults() {
172 let d = DriveInfo::new(
173 "USB Drive".into(),
174 "/media/usb".into(),
175 32.0,
176 "/dev/sdb".into(),
177 );
178 assert!(!d.is_system);
179 assert!(!d.is_read_only);
180 assert!(!d.disabled);
181 assert!(d.usb_info.is_none());
182 assert!(!d.is_usb());
183 }
184
185 #[test]
186 fn test_with_constraints_defaults_usb_info_none() {
187 let d = DriveInfo::with_constraints(
188 "USB".into(),
189 "/media/usb".into(),
190 32.0,
191 "/dev/sdb".into(),
192 true,
193 false,
194 );
195 assert!(d.is_system);
196 assert!(!d.is_read_only);
197 assert!(d.usb_info.is_none());
198 }
199
200 #[test]
201 fn test_with_usb_info_builder() {
202 let d = DriveInfo::new(
203 "SanDisk Ultra".into(),
204 "/media/usb".into(),
205 32.0,
206 "/dev/sdb".into(),
207 )
208 .with_usb_info(usb_info_fixture());
209
210 assert!(d.is_usb());
211 let info = d.usb_info.as_ref().unwrap();
212 assert_eq!(info.vendor_id, 0x0781);
213 assert_eq!(info.product_id, 0x5581);
214 assert_eq!(info.serial.as_deref(), Some("SN123456"));
215 assert_eq!(info.speed.as_deref(), Some("SuperSpeed (5 Gbps)"));
216 }
217
218 #[test]
219 fn test_usb_info_display_label_both() {
220 let info = usb_info_fixture();
221 assert_eq!(info.display_label(), "SanDisk Ultra");
222 }
223
224 #[test]
225 fn test_usb_info_display_label_product_only() {
226 let info = UsbInfo {
227 vendor_id: 0x1234,
228 product_id: 0x5678,
229 manufacturer: None,
230 product: Some("MyDrive".into()),
231 serial: None,
232 speed: None,
233 };
234 assert_eq!(info.display_label(), "MyDrive");
235 }
236
237 #[test]
238 fn test_usb_info_display_label_fallback_to_ids() {
239 let info = UsbInfo {
240 vendor_id: 0x1234,
241 product_id: 0xabcd,
242 manufacturer: None,
243 product: None,
244 serial: None,
245 speed: None,
246 };
247 assert_eq!(info.display_label(), "1234:abcd");
248 }
249
250 #[test]
251 fn test_drive_equality_by_device_path() {
252 let d1 = DriveInfo::new("A".into(), "/mnt/a".into(), 32.0, "/dev/sdb".into());
253 let d2 = DriveInfo::new("B".into(), "/mnt/b".into(), 64.0, "/dev/sdb".into());
254 let d3 = DriveInfo::new("C".into(), "/mnt/c".into(), 32.0, "/dev/sdc".into());
255 assert_eq!(d1, d2, "same device_path → equal");
256 assert_ne!(d1, d3, "different device_path → not equal");
257 }
258
259 #[test]
260 fn test_drive_equality_ignores_usb_info() {
261 let d1 = DriveInfo::new("A".into(), "/mnt/a".into(), 32.0, "/dev/sdb".into())
262 .with_usb_info(usb_info_fixture());
263 let d2 = DriveInfo::new("A".into(), "/mnt/a".into(), 32.0, "/dev/sdb".into());
264 assert_eq!(d1, d2, "usb_info must not affect equality");
265 }
266}