blockdev/
lib.rs

1use serde::de::Error as DeError;
2use serde::{Deserialize, Deserializer, Serialize};
3use serde_json::Value;
4use std::process::Command;
5use std::slice::Iter;
6use std::string::FromUtf8Error;
7use std::vec::IntoIter;
8use thiserror::Error;
9
10/// Represents the major and minor device numbers.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct MajMin {
13    /// The major device number.
14    pub major: u32,
15    /// The minor device number.
16    pub minor: u32,
17}
18
19impl Serialize for MajMin {
20    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
21    where
22        S: serde::Serializer,
23    {
24        serializer.serialize_str(&format!("{}:{}", self.major, self.minor))
25    }
26}
27
28impl<'de> Deserialize<'de> for MajMin {
29    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
30    where
31        D: Deserializer<'de>,
32    {
33        let s = String::deserialize(deserializer)?;
34        let parts: Vec<&str> = s.split(':').collect();
35        if parts.len() != 2 {
36            return Err(DeError::custom(format!(
37                "invalid maj:min format: expected 'major:minor', got '{s}'"
38            )));
39        }
40        let major = parts[0]
41            .parse()
42            .map_err(|_| DeError::custom(format!("invalid major number: {}", parts[0])))?;
43        let minor = parts[1]
44            .parse()
45            .map_err(|_| DeError::custom(format!("invalid minor number: {}", parts[1])))?;
46        Ok(MajMin { major, minor })
47    }
48}
49
50impl std::fmt::Display for MajMin {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "{}:{}", self.major, self.minor)
53    }
54}
55
56/// Represents the type of a block device.
57#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
58#[serde(rename_all = "lowercase")]
59pub enum DeviceType {
60    /// A physical disk device.
61    Disk,
62    /// A partition on a disk.
63    Part,
64    /// A loop device.
65    Loop,
66    /// A RAID1 (mirroring) device.
67    Raid1,
68    /// A RAID5 device.
69    Raid5,
70    /// A RAID6 device.
71    Raid6,
72    /// A RAID0 (striping) device.
73    Raid0,
74    /// A RAID10 device.
75    Raid10,
76    /// An LVM logical volume.
77    Lvm,
78    /// A device mapper crypt device.
79    Crypt,
80    /// A ROM device (e.g., CD/DVD drive).
81    Rom,
82    /// An unknown or unsupported device type.
83    #[serde(other)]
84    Other,
85}
86
87/// Error type for blockdev operations.
88#[derive(Debug, Error)]
89pub enum BlockDevError {
90    /// The lsblk command failed to execute.
91    #[error("failed to execute lsblk: {0}")]
92    CommandFailed(#[from] std::io::Error),
93
94    /// The lsblk command returned a non-zero exit status.
95    #[error("lsblk returned error: {0}")]
96    LsblkError(String),
97
98    /// The output from lsblk was not valid UTF-8.
99    #[error("invalid UTF-8 in lsblk output: {0}")]
100    InvalidUtf8(#[from] FromUtf8Error),
101
102    /// Failed to parse the JSON output from lsblk.
103    #[error("failed to parse lsblk JSON: {0}")]
104    JsonParse(#[from] serde_json::Error),
105}
106
107/// Represents the entire JSON output produced by `lsblk --json`.
108#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
109pub struct BlockDevices {
110    /// A vector of block devices.
111    pub blockdevices: Vec<BlockDevice>,
112}
113
114/// Parses a human-readable size string (e.g., "500G", "3.5T") into bytes.
115fn parse_size_string(s: &str) -> Option<u64> {
116    let s = s.trim();
117    if s.is_empty() {
118        return None;
119    }
120
121    // Find where the numeric part ends and the suffix begins
122    let (num_part, suffix) = {
123        let idx = s
124            .find(|c: char| !c.is_ascii_digit() && c != '.')
125            .unwrap_or(s.len());
126        (&s[..idx], s[idx..].trim())
127    };
128
129    let num: f64 = num_part.parse().ok()?;
130    let multiplier: u64 = match suffix.to_uppercase().as_str() {
131        "" | "B" => 1,
132        "K" | "KB" | "KIB" => 1024,
133        "M" | "MB" | "MIB" => 1024 * 1024,
134        "G" | "GB" | "GIB" => 1024 * 1024 * 1024,
135        "T" | "TB" | "TIB" => 1024 * 1024 * 1024 * 1024,
136        "P" | "PB" | "PIB" => 1024 * 1024 * 1024 * 1024 * 1024,
137        _ => return None,
138    };
139
140    Some((num * multiplier as f64) as u64)
141}
142
143/// Custom deserializer that handles both numeric byte values and human-readable size strings.
144fn deserialize_size<'de, D>(deserializer: D) -> Result<u64, D::Error>
145where
146    D: Deserializer<'de>,
147{
148    let value = Value::deserialize(deserializer)?;
149    match &value {
150        Value::Number(n) => n
151            .as_u64()
152            .or_else(|| n.as_f64().map(|f| f as u64))
153            .ok_or_else(|| DeError::custom("invalid numeric size")),
154        Value::String(s) => {
155            parse_size_string(s).ok_or_else(|| DeError::custom(format!("invalid size string: {s}")))
156        }
157        _ => Err(DeError::custom("size must be a number or string")),
158    }
159}
160
161/// Custom deserializer that supports both a single mountpoint (which may be null)
162/// and an array of mountpoints.
163///
164/// # Arguments
165///
166/// * `deserializer` - The deserializer instance.
167///
168/// # Returns
169///
170/// A vector of optional strings representing mountpoints.
171///
172/// # Errors
173///
174/// Returns an error if the value cannot be deserialized either as a single value or as an array.
175///
176/// This function is used internally by Serde when deserializing block devices.
177/// For example, if the JSON value is `null`, it will be converted to `vec![None]`.
178fn deserialize_mountpoints<'de, D>(deserializer: D) -> Result<Vec<Option<String>>, D::Error>
179where
180    D: Deserializer<'de>,
181{
182    let value = Value::deserialize(deserializer)?;
183    if value.is_array() {
184        // Deserialize as an array of optional strings.
185        serde_json::from_value(value).map_err(DeError::custom)
186    } else {
187        // Otherwise, deserialize as a single Option<String> and wrap it in a vector.
188        let single: Option<String> = serde_json::from_value(value).map_err(DeError::custom)?;
189        Ok(vec![single])
190    }
191}
192
193/// Represents a block device as output by `lsblk`.
194///
195/// Note that the `children` field is optional, as some devices might not have any nested children.
196///
197/// # Field Details
198///
199/// - `name`: The device name.
200/// - `maj_min`: The device's major and minor numbers. (Renamed from the JSON field "maj:min")
201/// - `rm`: Whether the device is removable.
202/// - `size`: The device size.
203/// - `ro`: Whether the device is read-only.
204/// - `device_type`: The device type (renamed from the reserved keyword "type").
205/// - `mountpoints`: A vector of mountpoints for the device. Uses a custom deserializer to support both single and multiple mountpoints.
206/// - `children`: Optional nested block devices.
207#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
208pub struct BlockDevice {
209    /// The name of the block device.
210    pub name: String,
211    /// The major and minor numbers of the block device.
212    ///
213    /// This field corresponds to the JSON field `"maj:min"`.
214    #[serde(rename = "maj:min")]
215    pub maj_min: MajMin,
216    /// Indicates if the device is removable.
217    pub rm: bool,
218    /// The size of the block device in bytes.
219    #[serde(deserialize_with = "deserialize_size")]
220    pub size: u64,
221    /// Indicates if the device is read-only.
222    pub ro: bool,
223    /// The type of the block device.
224    ///
225    /// The JSON field is `"type"`, which is a reserved keyword in Rust. It is renamed to `device_type`.
226    #[serde(rename = "type")]
227    pub device_type: DeviceType,
228    /// The mountpoints of the device.
229    ///
230    /// Uses a custom deserializer to handle both a single mountpoint (possibly null) and an array of mountpoints.
231    #[serde(
232        default,
233        alias = "mountpoint",
234        deserialize_with = "deserialize_mountpoints"
235    )]
236    pub mountpoints: Vec<Option<String>>,
237    /// Optional nested children block devices.
238    #[serde(default)]
239    pub children: Option<Vec<BlockDevice>>,
240}
241
242impl BlockDevice {
243    /// Returns `true` if this device has any children.
244    #[must_use]
245    pub fn has_children(&self) -> bool {
246        self.children.as_ref().is_some_and(|c| !c.is_empty())
247    }
248
249    /// Returns an iterator over the children of this device.
250    ///
251    /// Returns an empty iterator if the device has no children.
252    pub fn children_iter(&self) -> impl Iterator<Item = &BlockDevice> {
253        self.children.iter().flat_map(|c| c.iter())
254    }
255
256    /// Finds a direct child device by name.
257    ///
258    /// Returns `None` if no child with the given name exists.
259    #[must_use]
260    pub fn find_child(&self, name: &str) -> Option<&BlockDevice> {
261        self.children.as_ref()?.iter().find(|c| c.name == name)
262    }
263
264    /// Returns all non-null mountpoints for this device.
265    #[must_use]
266    pub fn active_mountpoints(&self) -> Vec<&str> {
267        self.mountpoints
268            .iter()
269            .filter_map(|m| m.as_deref())
270            .collect()
271    }
272
273    /// Returns `true` if this device has at least one mountpoint.
274    #[must_use]
275    pub fn is_mounted(&self) -> bool {
276        self.mountpoints.iter().any(|m| m.is_some())
277    }
278
279    /// Determines if this block device or any of its recursive children has a mountpoint of `/`,
280    /// indicating a system mount.
281    #[must_use]
282    pub fn is_system(&self) -> bool {
283        if self.mountpoints.iter().any(|m| m.as_deref() == Some("/")) {
284            return true;
285        }
286        if let Some(children) = &self.children {
287            for child in children {
288                if child.is_system() {
289                    return true;
290                }
291            }
292        }
293        false
294    }
295
296    /// Returns `true` if this device is a disk.
297    #[must_use]
298    pub fn is_disk(&self) -> bool {
299        self.device_type == DeviceType::Disk
300    }
301
302    /// Returns `true` if this device is a partition.
303    #[must_use]
304    pub fn is_partition(&self) -> bool {
305        self.device_type == DeviceType::Part
306    }
307}
308
309impl BlockDevices {
310    /// Returns the number of top-level block devices.
311    #[must_use]
312    pub fn len(&self) -> usize {
313        self.blockdevices.len()
314    }
315
316    /// Returns `true` if there are no block devices.
317    #[must_use]
318    pub fn is_empty(&self) -> bool {
319        self.blockdevices.is_empty()
320    }
321
322    /// Returns an iterator over references to the block devices.
323    pub fn iter(&self) -> Iter<'_, BlockDevice> {
324        self.blockdevices.iter()
325    }
326
327    /// Returns a vector of references to `BlockDevice` entries that have a mountpoint
328    /// of `/` on them or on any of their recursive children.
329    #[must_use]
330    pub fn system(&self) -> Vec<&BlockDevice> {
331        self.blockdevices
332            .iter()
333            .filter(|device| device.is_system())
334            .collect()
335    }
336
337    /// Returns a vector of references to `BlockDevice` entries that do not have a mountpoint
338    /// of `/` on them or on any of their recursive children.
339    #[must_use]
340    pub fn non_system(&self) -> Vec<&BlockDevice> {
341        self.blockdevices
342            .iter()
343            .filter(|device| !device.is_system())
344            .collect()
345    }
346
347    /// Finds a top-level block device by name.
348    ///
349    /// Returns `None` if no device with the given name exists.
350    #[must_use]
351    pub fn find_by_name(&self, name: &str) -> Option<&BlockDevice> {
352        self.blockdevices.iter().find(|d| d.name == name)
353    }
354}
355
356impl IntoIterator for BlockDevices {
357    type Item = BlockDevice;
358    type IntoIter = IntoIter<BlockDevice>;
359
360    fn into_iter(self) -> Self::IntoIter {
361        self.blockdevices.into_iter()
362    }
363}
364
365impl<'a> IntoIterator for &'a BlockDevices {
366    type Item = &'a BlockDevice;
367    type IntoIter = Iter<'a, BlockDevice>;
368
369    fn into_iter(self) -> Self::IntoIter {
370        self.blockdevices.iter()
371    }
372}
373
374/// Parses a JSON string (produced by `lsblk --json`)
375/// into a `BlockDevices` struct.
376///
377/// This function is useful when you already have JSON data from `lsblk`
378/// and want to parse it without running the command again.
379///
380/// # Arguments
381///
382/// * `json_data` - A string slice containing the JSON data.
383///
384/// # Errors
385///
386/// Returns a `serde_json::Error` if the JSON cannot be parsed.
387///
388/// # Examples
389///
390/// ```
391/// use blockdev::parse_lsblk;
392///
393/// let json = r#"{"blockdevices": [{"name": "sda", "maj:min": "8:0", "rm": false, "size": "500G", "ro": false, "type": "disk", "mountpoints": [null]}]}"#;
394/// let devices = parse_lsblk(json).expect("Failed to parse JSON");
395/// assert_eq!(devices.len(), 1);
396/// ```
397pub fn parse_lsblk(json_data: &str) -> Result<BlockDevices, serde_json::Error> {
398    serde_json::from_str(json_data)
399}
400
401/// Runs the `lsblk --json` command, captures its output, and parses it
402/// into a `BlockDevices` struct. If the command fails or the output cannot be parsed,
403/// an error is returned.
404///
405/// # Errors
406///
407/// Returns an error if the `lsblk` command fails or if the output cannot be parsed as valid JSON.
408///
409/// # Examples
410///
411/// ```no_run
412/// # use blockdev::get_devices;
413/// let devices = get_devices().expect("Failed to get block devices");
414/// ```
415pub fn get_devices() -> Result<BlockDevices, BlockDevError> {
416    let output = Command::new("lsblk")
417        .arg("--json")
418        .arg("--bytes")
419        .output()?;
420
421    if !output.status.success() {
422        return Err(BlockDevError::LsblkError(
423            String::from_utf8_lossy(&output.stderr).into_owned(),
424        ));
425    }
426
427    let json_output = String::from_utf8(output.stdout)?;
428    let lsblk = parse_lsblk(&json_output)?;
429    Ok(lsblk)
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    const SAMPLE_JSON: &str = r#"
437    {
438        "blockdevices": [
439            {"name":"nvme1n1", "maj:min":"259:0", "rm":false, "size":"3.5T", "ro":false, "type":"disk", "mountpoint":null,
440                "children": [
441                    {"name":"nvme1n1p1", "maj:min":"259:1", "rm":false, "size":"3.5T", "ro":false, "type":"part", "mountpoint":null},
442                    {"name":"nvme1n1p9", "maj:min":"259:2", "rm":false, "size":"8M", "ro":false, "type":"part", "mountpoint":null}
443                ]
444            },
445            {"name":"nvme7n1", "maj:min":"259:3", "rm":false, "size":"3.5T", "ro":false, "type":"disk", "mountpoint":null,
446                "children": [
447                    {"name":"nvme7n1p1", "maj:min":"259:7", "rm":false, "size":"3.5T", "ro":false, "type":"part", "mountpoint":null},
448                    {"name":"nvme7n1p9", "maj:min":"259:8", "rm":false, "size":"8M", "ro":false, "type":"part", "mountpoint":null}
449                ]
450            },
451            {"name":"nvme5n1", "maj:min":"259:4", "rm":false, "size":"3.5T", "ro":false, "type":"disk", "mountpoint":null,
452                "children": [
453                    {"name":"nvme5n1p1", "maj:min":"259:5", "rm":false, "size":"3.5T", "ro":false, "type":"part", "mountpoint":null},
454                    {"name":"nvme5n1p9", "maj:min":"259:6", "rm":false, "size":"8M", "ro":false, "type":"part", "mountpoint":null}
455                ]
456            },
457            {"name":"nvme9n1", "maj:min":"259:9", "rm":false, "size":"3.5T", "ro":false, "type":"disk", "mountpoint":null,
458                "children": [
459                    {"name":"nvme9n1p1", "maj:min":"259:13", "rm":false, "size":"3.5T", "ro":false, "type":"part", "mountpoint":null},
460                    {"name":"nvme9n1p9", "maj:min":"259:14", "rm":false, "size":"8M", "ro":false, "type":"part", "mountpoint":null}
461                ]
462            },
463            {"name":"nvme4n1", "maj:min":"259:10", "rm":false, "size":"3.5T", "ro":false, "type":"disk", "mountpoint":null,
464                "children": [
465                    {"name":"nvme4n1p1", "maj:min":"259:11", "rm":false, "size":"3.5T", "ro":false, "type":"part", "mountpoint":null},
466                    {"name":"nvme4n1p9", "maj:min":"259:12", "rm":false, "size":"8M", "ro":false, "type":"part", "mountpoint":null}
467                ]
468            },
469            {"name":"nvme8n1", "maj:min":"259:15", "rm":false, "size":"3.5T", "ro":false, "type":"disk", "mountpoint":null,
470                "children": [
471                    {"name":"nvme8n1p1", "maj:min":"259:20", "rm":false, "size":"3.5T", "ro":false, "type":"part", "mountpoint":null},
472                    {"name":"nvme8n1p9", "maj:min":"259:21", "rm":false, "size":"8M", "ro":false, "type":"part", "mountpoint":null}
473                ]
474            },
475            {"name":"nvme6n1", "maj:min":"259:16", "rm":false, "size":"3.5T", "ro":false, "type":"disk", "mountpoint":null,
476                "children": [
477                    {"name":"nvme6n1p1", "maj:min":"259:17", "rm":false, "size":"3.5T", "ro":false, "type":"part", "mountpoint":null},
478                    {"name":"nvme6n1p9", "maj:min":"259:18", "rm":false, "size":"8M", "ro":false, "type":"part", "mountpoint":null}
479                ]
480            },
481            {"name":"nvme3n1", "maj:min":"259:19", "rm":false, "size":"894.3G", "ro":false, "type":"disk", "mountpoint":null,
482                "children": [
483                    {"name":"nvme3n1p1", "maj:min":"259:23", "rm":false, "size":"1M", "ro":false, "type":"part", "mountpoint":null},
484                    {"name":"nvme3n1p2", "maj:min":"259:24", "rm":false, "size":"244M", "ro":false, "type":"part", "mountpoint":"/boot/efi"},
485                    {"name":"nvme3n1p3", "maj:min":"259:25", "rm":false, "size":"488M", "ro":false, "type":"part", "mountpoint":null,
486                    "children": [
487                        {"name":"md0", "maj:min":"9:0", "rm":false, "size":"487M", "ro":false, "type":"raid1", "mountpoint":"/boot"}
488                    ]
489                    },
490                    {"name":"nvme3n1p4", "maj:min":"259:26", "rm":false, "size":"7.6G", "ro":false, "type":"part", "mountpoint":null,
491                    "children": [
492                        {"name":"md1", "maj:min":"9:1", "rm":false, "size":"7.6G", "ro":false, "type":"raid1", "mountpoint":"[SWAP]"}
493                    ]
494                    },
495                    {"name":"nvme3n1p5", "maj:min":"259:27", "rm":false, "size":"19.1G", "ro":false, "type":"part", "mountpoint":null,
496                    "children": [
497                        {"name":"md2", "maj:min":"9:2", "rm":false, "size":"19.1G", "ro":false, "type":"raid1", "mountpoint":"/"}
498                    ]
499                    },
500                    {"name":"nvme3n1p6", "maj:min":"259:28", "rm":false, "size":"866.8G", "ro":false, "type":"part", "mountpoint":null}
501                ]
502            },
503            {"name":"nvme0n1", "maj:min":"259:22", "rm":false, "size":"3.5T", "ro":false, "type":"disk", "mountpoint":null,
504                "children": [
505                    {"name":"nvme0n1p1", "maj:min":"259:29", "rm":false, "size":"3.5T", "ro":false, "type":"part", "mountpoint":null},
506                    {"name":"nvme0n1p9", "maj:min":"259:30", "rm":false, "size":"8M", "ro":false, "type":"part", "mountpoint":null}
507                ]
508            },
509            {"name":"nvme2n1", "maj:min":"259:31", "rm":false, "size":"894.3G", "ro":false, "type":"disk", "mountpoint":null,
510                "children": [
511                    {"name":"nvme2n1p1", "maj:min":"259:32", "rm":false, "size":"1M", "ro":false, "type":"part", "mountpoint":null},
512                    {"name":"nvme2n1p2", "maj:min":"259:33", "rm":false, "size":"244M", "ro":false, "type":"part", "mountpoint":null},
513                    {"name":"nvme2n1p3", "maj:min":"259:34", "rm":false, "size":"488M", "ro":false, "type":"part", "mountpoint":null,
514                    "children": [
515                        {"name":"md0", "maj:min":"9:0", "rm":false, "size":"487M", "ro":false, "type":"raid1", "mountpoint":"/boot"}
516                    ]
517                    },
518                    {"name":"nvme2n1p4", "maj:min":"259:35", "rm":false, "size":"7.6G", "ro":false, "type":"part", "mountpoint":null,
519                    "children": [
520                        {"name":"md1", "maj:min":"9:1", "rm":false, "size":"7.6G", "ro":false, "type":"raid1", "mountpoint":"[SWAP]"}
521                    ]
522                    },
523                    {"name":"nvme2n1p5", "maj:min":"259:36", "rm":false, "size":"19.1G", "ro":false, "type":"part", "mountpoint":null,
524                    "children": [
525                        {"name":"md2", "maj:min":"9:2", "rm":false, "size":"19.1G", "ro":false, "type":"raid1", "mountpoint":"/"}
526                    ]
527                    },
528                    {"name":"nvme2n1p6", "maj:min":"259:37", "rm":false, "size":"866.8G", "ro":false, "type":"part", "mountpoint":null}
529                ]
530            }
531        ]
532    }
533    "#;
534
535    #[test]
536    fn test_parse_lsblk() {
537        let lsblk = parse_lsblk(SAMPLE_JSON).expect("Failed to parse JSON");
538
539        // Assert the expected number of top-level block devices.
540        assert_eq!(
541            lsblk.blockdevices.len(),
542            10,
543            "Expected 10 top-level block devices"
544        );
545
546        // Verify that required fields are non-empty.
547        for device in &lsblk.blockdevices {
548            assert!(!device.name.is_empty(), "Device name should not be empty");
549        }
550
551        // Pick a device with nested children and validate details.
552        let nvme3n1 = lsblk
553            .blockdevices
554            .iter()
555            .find(|d| d.name == "nvme3n1")
556            .expect("Expected to find device nvme3n1");
557
558        // Its first mountpoint should be None.
559        assert!(
560            nvme3n1
561                .mountpoints
562                .first()
563                .and_then(|opt| opt.as_deref())
564                .is_none(),
565            "nvme3n1 effective mountpoint should be None"
566        );
567
568        // Verify that nvme3n1 has exactly 6 children.
569        let children = nvme3n1
570            .children
571            .as_ref()
572            .expect("nvme3n1 should have children");
573        assert_eq!(children.len(), 6, "nvme3n1 should have 6 children");
574
575        // Validate that child nvme3n1p2 has first mountpoint of "/boot/efi".
576        let nvme3n1p2 = children
577            .iter()
578            .find(|c| c.name == "nvme3n1p2")
579            .expect("Expected to find nvme3n1p2");
580        assert_eq!(
581            nvme3n1p2.mountpoints.first().and_then(|opt| opt.as_deref()),
582            Some("/boot/efi"),
583            "nvme3n1p2 first mountpoint should be '/boot/efi'"
584        );
585
586        // In nvme3n1p3, verify that its nested child md0 has an effective mountpoint of "/boot".
587        let nvme3n1p3 = children
588            .iter()
589            .find(|c| c.name == "nvme3n1p3")
590            .expect("Expected to find nvme3n1p3");
591        let nested_children = nvme3n1p3
592            .children
593            .as_ref()
594            .expect("nvme3n1p3 should have children");
595        let md0 = nested_children
596            .iter()
597            .find(|d| d.name == "md0")
598            .expect("Expected to find md0 under nvme3n1p3");
599        assert_eq!(
600            md0.mountpoints.first().and_then(|opt| opt.as_deref()),
601            Some("/boot"),
602            "md0 effective mountpoint should be '/boot'"
603        );
604
605        // Test the non_system method.
606        // Since nvme3n1 has a descendant (md2) with effective mountpoint "/" it should be excluded.
607        let non_system = lsblk.non_system();
608        assert_eq!(
609            non_system.len(),
610            8,
611            "Expected 8 non-system top-level devices, since nvme3n1/nvme2n1 is system"
612        );
613        assert!(
614            !non_system.iter().any(|d| d.name == "nvme3n1"),
615            "nvme3n1 should be excluded from non-system devices"
616        );
617    }
618
619    #[test]
620    fn test_non_system() {
621        // Create a JSON where one device is system (has "/" mountpoint in a child)
622        // and one is non-system.
623        let test_json = r#"
624        {
625            "blockdevices": [
626                {
627                    "name": "sda",
628                    "maj:min": "8:0",
629                    "rm": false,
630                    "size": "447.1G",
631                    "ro": false,
632                    "type": "disk",
633                    "mountpoints": [
634                        null
635                    ],
636                    "children": [
637                        {
638                        "name": "sda1",
639                        "maj:min": "8:1",
640                        "rm": false,
641                        "size": "512M",
642                        "ro": false,
643                        "type": "part",
644                        "mountpoints": [
645                            null
646                        ]
647                        },{
648                        "name": "sda2",
649                        "maj:min": "8:2",
650                        "rm": false,
651                        "size": "446.6G",
652                        "ro": false,
653                        "type": "part",
654                        "mountpoints": [
655                            null
656                        ],
657                        "children": [
658                            {
659                                "name": "md0",
660                                "maj:min": "9:0",
661                                "rm": false,
662                                "size": "446.6G",
663                                "ro": false,
664                                "type": "raid1",
665                                "mountpoints": [
666                                    "/"
667                                ]
668                            }
669                        ]
670                        }
671                    ]
672                },{
673                    "name": "sdb",
674                    "maj:min": "8:16",
675                    "rm": false,
676                    "size": "447.1G",
677                    "ro": false,
678                    "type": "disk",
679                    "mountpoints": [
680                        null
681                    ],
682                    "children": [
683                        {
684                        "name": "sdb1",
685                        "maj:min": "8:17",
686                        "rm": false,
687                        "size": "512M",
688                        "ro": false,
689                        "type": "part",
690                        "mountpoints": [
691                            "/boot/efi"
692                        ]
693                        },{
694                        "name": "sdb2",
695                        "maj:min": "8:18",
696                        "rm": false,
697                        "size": "446.6G",
698                        "ro": false,
699                        "type": "part",
700                        "mountpoints": [
701                            null
702                        ],
703                        "children": [
704                            {
705                                "name": "md0",
706                                "maj:min": "9:0",
707                                "rm": false,
708                                "size": "446.6G",
709                                "ro": false,
710                                "type": "raid1",
711                                "mountpoints": [
712                                    "/"
713                                ]
714                            }
715                        ]
716                        }
717                    ]
718                },{
719                    "name": "nvme0n1",
720                    "maj:min": "259:2",
721                    "rm": false,
722                    "size": "1.7T",
723                    "ro": false,
724                    "type": "disk",
725                    "mountpoints": [
726                        null
727                    ]
728                },{
729                    "name": "nvme1n1",
730                    "maj:min": "259:3",
731                    "rm": false,
732                    "size": "1.7T",
733                    "ro": false,
734                    "type": "disk",
735                    "mountpoints": [
736                        null
737                    ]
738                }
739            ]
740        }
741        "#;
742        let disks = parse_lsblk(test_json).unwrap();
743        let non_system = disks.non_system();
744        assert_eq!(non_system.len(), 2);
745        let names: Vec<&str> = non_system.iter().map(|d| d.name.as_str()).collect();
746        assert_eq!(names, vec!["nvme0n1", "nvme1n1"]);
747    }
748
749    /// Warning: This test will attempt to run the `lsblk` command on your system.
750    /// It may fail if `lsblk` is not available or if the test environment does not permit running commands.
751    #[test]
752    #[ignore = "requires lsblk command to be available on the system"]
753    fn test_get_devices() {
754        let dev = get_devices().expect("Failed to get block devices");
755        // This assertion is simplistic; adjust according to your environment's expected output.
756        assert!(!dev.blockdevices.is_empty());
757    }
758    #[test]
759    fn test_into_iterator() {
760        // Create dummy BlockDevice instances.
761        let device1 = BlockDevice {
762            name: "sda".to_string(),
763            maj_min: MajMin { major: 8, minor: 0 },
764            rm: false,
765            size: 536_870_912_000, // 500G in bytes
766            ro: false,
767            device_type: DeviceType::Disk,
768            mountpoints: vec![None],
769            children: None,
770        };
771
772        let device2 = BlockDevice {
773            name: "sdb".to_string(),
774            maj_min: MajMin { major: 8, minor: 16 },
775            rm: false,
776            size: 536_870_912_000, // 500G in bytes
777            ro: false,
778            device_type: DeviceType::Disk,
779            mountpoints: vec![None],
780            children: None,
781        };
782
783        // Create a BlockDevices instance containing the two devices.
784        let devices = BlockDevices {
785            blockdevices: vec![device1, device2],
786        };
787
788        // Use the IntoIterator implementation to iterate over the devices.
789        let names: Vec<String> = devices.into_iter().map(|dev| dev.name).collect();
790        assert_eq!(names, vec!["sda".to_string(), "sdb".to_string()]);
791    }
792
793    #[test]
794    fn test_empty_blockdevices() {
795        let json = r#"{"blockdevices": []}"#;
796        let devices = parse_lsblk(json).expect("Failed to parse empty JSON");
797        assert!(devices.is_empty());
798        assert_eq!(devices.len(), 0);
799        assert!(devices.non_system().is_empty());
800        assert!(devices.system().is_empty());
801        assert!(devices.find_by_name("sda").is_none());
802    }
803
804    #[test]
805    fn test_default_trait() {
806        let devices = BlockDevices::default();
807        assert!(devices.is_empty());
808        assert_eq!(devices.len(), 0);
809    }
810
811    #[test]
812    fn test_clone_trait() {
813        let json = r#"{"blockdevices": [{"name": "sda", "maj:min": "8:0", "rm": false, "size": "500G", "ro": false, "type": "disk", "mountpoints": [null]}]}"#;
814        let devices = parse_lsblk(json).expect("Failed to parse JSON");
815        let cloned = devices.clone();
816        assert_eq!(devices, cloned);
817        assert_eq!(cloned.len(), 1);
818    }
819
820    #[test]
821    fn test_serialization_roundtrip() {
822        let json = r#"{"blockdevices":[{"name":"sda","maj:min":"8:0","rm":false,"size":"500G","ro":false,"type":"disk","mountpoints":[null],"children":null}]}"#;
823        let devices = parse_lsblk(json).expect("Failed to parse JSON");
824        let serialized = serde_json::to_string(&devices).expect("Failed to serialize");
825        let deserialized: BlockDevices =
826            serde_json::from_str(&serialized).expect("Failed to deserialize");
827        assert_eq!(devices, deserialized);
828    }
829
830    #[test]
831    fn test_device_with_direct_root_mount() {
832        let json = r#"{
833            "blockdevices": [{
834                "name": "sda",
835                "maj:min": "8:0",
836                "rm": false,
837                "size": "500G",
838                "ro": false,
839                "type": "disk",
840                "mountpoints": ["/"]
841            }]
842        }"#;
843        let devices = parse_lsblk(json).expect("Failed to parse JSON");
844        let device = devices.find_by_name("sda").unwrap();
845        assert!(device.is_system());
846        assert!(device.is_mounted());
847        assert_eq!(device.active_mountpoints(), vec!["/"]);
848        assert_eq!(devices.system().len(), 1);
849        assert!(devices.non_system().is_empty());
850    }
851
852    #[test]
853    fn test_block_device_methods() {
854        let device = BlockDevice {
855            name: "sda".to_string(),
856            maj_min: MajMin { major: 8, minor: 0 },
857            rm: false,
858            size: 536_870_912_000, // 500G in bytes
859            ro: false,
860            device_type: DeviceType::Disk,
861            mountpoints: vec![Some("/mnt/data".to_string()), None],
862            children: Some(vec![BlockDevice {
863                name: "sda1".to_string(),
864                maj_min: MajMin { major: 8, minor: 1 },
865                rm: false,
866                size: 268_435_456_000, // 250G in bytes
867                ro: false,
868                device_type: DeviceType::Part,
869                mountpoints: vec![Some("/home".to_string())],
870                children: None,
871            }]),
872        };
873
874        assert!(device.is_disk());
875        assert!(!device.is_partition());
876        assert!(device.has_children());
877        assert!(device.is_mounted());
878        assert_eq!(device.active_mountpoints(), vec!["/mnt/data"]);
879
880        let child = device.find_child("sda1").unwrap();
881        assert!(!child.is_disk());
882        assert!(child.is_partition());
883        assert!(!child.has_children());
884
885        assert!(device.find_child("nonexistent").is_none());
886    }
887
888    #[test]
889    fn test_children_iter() {
890        let device = BlockDevice {
891            name: "sda".to_string(),
892            maj_min: MajMin { major: 8, minor: 0 },
893            rm: false,
894            size: 536_870_912_000, // 500G in bytes
895            ro: false,
896            device_type: DeviceType::Disk,
897            mountpoints: vec![None],
898            children: Some(vec![
899                BlockDevice {
900                    name: "sda1".to_string(),
901                    maj_min: MajMin { major: 8, minor: 1 },
902                    rm: false,
903                    size: 268_435_456_000, // 250G in bytes
904                    ro: false,
905                    device_type: DeviceType::Part,
906                    mountpoints: vec![None],
907                    children: None,
908                },
909                BlockDevice {
910                    name: "sda2".to_string(),
911                    maj_min: MajMin { major: 8, minor: 2 },
912                    rm: false,
913                    size: 268_435_456_000, // 250G in bytes
914                    ro: false,
915                    device_type: DeviceType::Part,
916                    mountpoints: vec![None],
917                    children: None,
918                },
919            ]),
920        };
921
922        let names: Vec<&str> = device.children_iter().map(|c| c.name.as_str()).collect();
923        assert_eq!(names, vec!["sda1", "sda2"]);
924
925        // Test empty children iterator
926        let device_no_children = BlockDevice {
927            name: "sdb".to_string(),
928            maj_min: MajMin { major: 8, minor: 16 },
929            rm: false,
930            size: 536_870_912_000, // 500G in bytes
931            ro: false,
932            device_type: DeviceType::Disk,
933            mountpoints: vec![None],
934            children: None,
935        };
936        assert_eq!(device_no_children.children_iter().count(), 0);
937    }
938
939    #[test]
940    fn test_borrowing_iterator() {
941        let devices = BlockDevices {
942            blockdevices: vec![
943                BlockDevice {
944                    name: "sda".to_string(),
945                    maj_min: MajMin { major: 8, minor: 0 },
946                    rm: false,
947                    size: 536_870_912_000, // 500G in bytes
948                    ro: false,
949                    device_type: DeviceType::Disk,
950                    mountpoints: vec![None],
951                    children: None,
952                },
953                BlockDevice {
954                    name: "sdb".to_string(),
955                    maj_min: MajMin { major: 8, minor: 16 },
956                    rm: false,
957                    size: 536_870_912_000, // 500G in bytes
958                    ro: false,
959                    device_type: DeviceType::Disk,
960                    mountpoints: vec![None],
961                    children: None,
962                },
963            ],
964        };
965
966        // Test borrowing iterator (doesn't consume)
967        let names: Vec<&str> = (&devices).into_iter().map(|d| d.name.as_str()).collect();
968        assert_eq!(names, vec!["sda", "sdb"]);
969
970        // devices is still available
971        assert_eq!(devices.len(), 2);
972
973        // Test iter() method
974        let names2: Vec<&str> = devices.iter().map(|d| d.name.as_str()).collect();
975        assert_eq!(names2, vec!["sda", "sdb"]);
976    }
977
978    #[test]
979    fn test_find_by_name() {
980        let devices = BlockDevices {
981            blockdevices: vec![
982                BlockDevice {
983                    name: "sda".to_string(),
984                    maj_min: MajMin { major: 8, minor: 0 },
985                    rm: false,
986                    size: 536_870_912_000, // 500G in bytes
987                    ro: false,
988                    device_type: DeviceType::Disk,
989                    mountpoints: vec![None],
990                    children: None,
991                },
992                BlockDevice {
993                    name: "nvme0n1".to_string(),
994                    maj_min: MajMin { major: 259, minor: 0 },
995                    rm: false,
996                    size: 1_099_511_627_776, // 1T in bytes
997                    ro: false,
998                    device_type: DeviceType::Disk,
999                    mountpoints: vec![None],
1000                    children: None,
1001                },
1002            ],
1003        };
1004
1005        assert!(devices.find_by_name("sda").is_some());
1006        assert_eq!(devices.find_by_name("sda").unwrap().size, 536_870_912_000);
1007        assert!(devices.find_by_name("nvme0n1").is_some());
1008        assert!(devices.find_by_name("nonexistent").is_none());
1009    }
1010
1011    #[test]
1012    fn test_system_method() {
1013        let json = r#"{
1014            "blockdevices": [
1015                {"name": "sda", "maj:min": "8:0", "rm": false, "size": "500G", "ro": false, "type": "disk", "mountpoints": ["/"]},
1016                {"name": "sdb", "maj:min": "8:16", "rm": false, "size": "500G", "ro": false, "type": "disk", "mountpoints": [null]},
1017                {"name": "sdc", "maj:min": "8:32", "rm": false, "size": "500G", "ro": false, "type": "disk", "mountpoints": ["/home"]}
1018            ]
1019        }"#;
1020        let devices = parse_lsblk(json).expect("Failed to parse JSON");
1021        let system = devices.system();
1022        assert_eq!(system.len(), 1);
1023        assert_eq!(system[0].name, "sda");
1024    }
1025
1026    #[test]
1027    fn test_multiple_mountpoints() {
1028        let json = r#"{
1029            "blockdevices": [{
1030                "name": "sda",
1031                "maj:min": "8:0",
1032                "rm": false,
1033                "size": "500G",
1034                "ro": false,
1035                "type": "disk",
1036                "mountpoints": ["/mnt/data", "/mnt/backup", null]
1037            }]
1038        }"#;
1039        let devices = parse_lsblk(json).expect("Failed to parse JSON");
1040        let device = devices.find_by_name("sda").unwrap();
1041        assert!(device.is_mounted());
1042        assert_eq!(
1043            device.active_mountpoints(),
1044            vec!["/mnt/data", "/mnt/backup"]
1045        );
1046    }
1047
1048    #[test]
1049    fn test_removable_and_readonly() {
1050        let json = r#"{
1051            "blockdevices": [{
1052                "name": "sr0",
1053                "maj:min": "11:0",
1054                "rm": true,
1055                "size": "4.7G",
1056                "ro": true,
1057                "type": "rom",
1058                "mountpoints": [null]
1059            }]
1060        }"#;
1061        let devices = parse_lsblk(json).expect("Failed to parse JSON");
1062        let device = devices.find_by_name("sr0").unwrap();
1063        assert!(device.rm);
1064        assert!(device.ro);
1065        assert_eq!(device.device_type, DeviceType::Rom);
1066    }
1067}