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    /// 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}