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