Skip to main content

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}