use core::fmt::Write as _;
use std::fs::File;
use std::io::{Seek, SeekFrom};
use std::path::Path;
mod bar;
pub use bar::{render_disk_bar, render_overview};
#[cfg(target_os = "linux")]
mod linux;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
mod sysfs;
#[cfg_attr(not(windows), allow(dead_code))]
mod drive_layout;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(windows)]
mod windows;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct PhysicalDisk {
pub device_path: String,
pub name: String,
pub size_bytes: u64,
pub logical_sector_size: u32,
pub physical_sector_size: u32,
pub model: Option<String>,
pub serial: Option<String>,
pub removable: bool,
pub read_only: bool,
pub synthesized: bool,
pub partitions: Vec<Partition>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Partition {
pub device_path: String,
pub name: String,
pub start_offset: u64,
pub size_bytes: u64,
pub partition_type: Option<String>,
pub mount_point: Option<String>,
pub filesystem: Option<String>,
pub label: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("live device enumeration is not supported on this platform")]
Unsupported,
#[error("I/O error enumerating devices: {0}")]
Io(#[from] std::io::Error),
#[error("device enumeration failed: {0}")]
Os(String),
}
pub fn enumerate() -> Result<Vec<PhysicalDisk>, Error> {
#[cfg(target_os = "linux")]
{
linux::enumerate()
}
#[cfg(target_os = "macos")]
{
macos::enumerate()
}
#[cfg(windows)]
{
windows::enumerate()
}
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
{
Err(Error::Unsupported)
}
}
pub fn open_device(path: &Path) -> std::io::Result<(File, u64)> {
let mut file = File::open(path)?;
let size = file.seek(SeekFrom::End(0))?;
file.seek(SeekFrom::Start(0))?;
Ok((file, size))
}
#[must_use]
pub fn human_size(bytes: u64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
if bytes < 1000 {
return format!("{bytes} B");
}
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1000.0 && unit < UNITS.len() - 1 {
value /= 1000.0;
unit += 1;
}
format!("{value:.1} {}", UNITS[unit])
}
#[must_use]
pub fn render_disks(disks: &[PhysicalDisk]) -> String {
let mut s = String::new();
if disks.is_empty() {
s.push_str("No disks found.\n");
return s;
}
let _ = writeln!(s, "{:<14} {:>10} {:<6} INFO", "NAME", "SIZE", "TYPE");
for d in disks {
let kind = if d.synthesized { "synth" } else { "disk" };
let mut info = d.model.clone().unwrap_or_default();
if d.removable {
info = if info.is_empty() {
"removable".to_string()
} else {
format!("{info} (removable)")
};
}
let _ = writeln!(
s,
"{:<14} {:>10} {:<6} {}",
d.name,
human_size(d.size_bytes),
kind,
info.trim()
);
for p in &d.partitions {
let indented = format!(" {}", p.name);
let _ = writeln!(
s,
"{:<14} {:>10} {:<6} {}",
indented,
human_size(p.size_bytes),
"part",
partition_info(p)
);
}
}
s
}
fn partition_info(p: &Partition) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(t) = &p.partition_type {
parts.push(t.clone());
}
if let Some(m) = &p.mount_point {
parts.push(m.clone());
}
if let Some(l) = &p.label {
parts.push(format!("[{l}]"));
}
parts.join(" ")
}
#[must_use]
pub fn render_listing(disks: &[PhysicalDisk], width: usize, color: bool) -> String {
if disks.is_empty() {
return "No disks found.\n".to_string();
}
let mut s = String::new();
let overview = render_overview(disks, width, color);
if !overview.is_empty() {
s.push_str(&overview);
s.push('\n');
}
for d in disks {
let kind = if d.synthesized { " (synthesized)" } else { "" };
let model = d
.model
.as_deref()
.map(|m| format!(" {m}"))
.unwrap_or_default();
let _ = writeln!(
s,
"{} {}{kind}{model}",
d.device_path,
human_size(d.size_bytes)
);
if d.partitions.is_empty() {
s.push_str(" (no partitions)\n");
} else if d.synthesized {
for p in &d.partitions {
let _ = writeln!(
s,
" {:<16} {:>10} {}",
p.name,
human_size(p.size_bytes),
partition_info(p)
);
}
s.push_str(" (volumes share container space)\n");
} else {
s.push_str(&render_disk_bar(d, width, color));
}
s.push('\n');
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_disk() -> PhysicalDisk {
PhysicalDisk {
device_path: "/dev/disk0".into(),
name: "disk0".into(),
size_bytes: 4_000_000_000_000,
logical_sector_size: 512,
physical_sector_size: 4096,
model: Some("APPLE SSD AP4096".into()),
serial: None,
removable: false,
read_only: false,
synthesized: false,
partitions: vec![Partition {
device_path: "/dev/disk0s1".into(),
name: "disk0s1".into(),
start_offset: 20480,
size_bytes: 524_300_000,
partition_type: Some("Apple_APFS_ISC".into()),
mount_point: None,
filesystem: None,
label: None,
}],
}
}
#[test]
fn human_size_matches_decimal_units() {
assert_eq!(human_size(512), "512 B");
assert_eq!(human_size(999), "999 B");
assert_eq!(human_size(1000), "1.0 KB");
assert_eq!(human_size(24_576), "24.6 KB");
assert_eq!(human_size(524_300_000), "524.3 MB");
assert_eq!(human_size(5_400_000_000), "5.4 GB");
assert_eq!(human_size(4_000_000_000_000), "4.0 TB");
}
#[test]
fn render_disks_shows_disk_then_indented_partitions() {
let out = render_disks(&[sample_disk()]);
assert!(out.contains("NAME"));
assert!(out.contains("disk0"));
assert!(out.contains("4.0 TB"));
assert!(out.contains("APPLE SSD AP4096"));
assert!(out.contains(" disk0s1"));
assert!(out.contains("Apple_APFS_ISC"));
let disk_line = out.lines().find(|l| l.contains("disk0 ")).unwrap();
assert!(disk_line.contains("disk"));
}
#[test]
fn render_disks_empty_is_explicit() {
assert_eq!(render_disks(&[]), "No disks found.\n");
}
#[test]
fn partition_info_joins_type_mount_label() {
let p = Partition {
device_path: "/dev/disk0s2".into(),
name: "disk0s2".into(),
start_offset: 0,
size_bytes: 1,
partition_type: Some("Apple_APFS".into()),
mount_point: Some("/Volumes/Data".into()),
label: Some("DATA".into()),
filesystem: None,
};
assert_eq!(partition_info(&p), "Apple_APFS /Volumes/Data [DATA]");
}
#[test]
fn removable_flag_annotates_info() {
let mut d = sample_disk();
d.model = None;
d.removable = true;
let out = render_disks(&[d]);
assert!(out.contains("removable"));
}
#[test]
fn render_listing_draws_bar_for_physical_disk() {
let out = render_listing(&[sample_disk()], 40, false);
assert!(out.contains("/dev/disk0"));
assert!(out.contains("4.0 TB"));
assert!(out.contains("APPLE SSD AP4096"));
assert!(out.contains('['), "physical disk gets a proportional bar");
}
#[test]
fn render_listing_lists_volumes_for_synthesized_disk() {
let mut d = sample_disk();
d.synthesized = true;
d.model = None;
let out = render_listing(&[d], 40, false);
assert!(out.contains("(synthesized)"));
assert!(out.contains("share container space"));
assert!(!out.contains('['));
}
#[test]
fn render_listing_empty_is_explicit() {
assert_eq!(render_listing(&[], 40, false), "No disks found.\n");
}
#[test]
fn enumerate_runs_on_host() {
let _ = enumerate();
}
#[cfg(unix)]
#[test]
fn open_device_sizes_dev_null_to_zero() {
let (_file, size) = open_device(Path::new("/dev/null")).unwrap();
assert_eq!(size, 0);
}
}