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