liblitho 0.2.0

cli tool to flash/clone the images to storage devices
Documentation
//! Device DTOs, errors, portable I/O policy, and a thin façade over [`crate::platform::Active`].

use crate::platform::traits::{
    DeviceInventory, DevicePathOps, DeviceSafety, Platform, VolumeOps,
};
use crate::platform::Active;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt;
use thiserror::Error;

/// Typed errors for device path validation, safety checks, and mount operations.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum DeviceError {
    #[error("{0}")]
    InvalidPath(String),
    #[error("{0}")]
    SystemDisk(String),
    #[error("{0}")]
    Busy(String),
    #[error("{0}")]
    UnmountFailed(String),
    #[error("{0}")]
    NotListed(String),
    #[error("{0}")]
    Unsupported(String),
    #[error("{0}")]
    QueryFailed(String),
}

impl DeviceError {
    pub fn invalid_path(msg: impl Into<String>) -> Self {
        Self::InvalidPath(msg.into())
    }
    pub fn system_disk(msg: impl Into<String>) -> Self {
        Self::SystemDisk(msg.into())
    }
    pub fn busy(msg: impl Into<String>) -> Self {
        Self::Busy(msg.into())
    }
    pub fn unmount(msg: impl Into<String>) -> Self {
        Self::UnmountFailed(msg.into())
    }
    pub fn not_listed(msg: impl Into<String>) -> Self {
        Self::NotListed(msg.into())
    }
    pub fn unsupported(msg: impl Into<String>) -> Self {
        Self::Unsupported(msg.into())
    }
    pub fn query(msg: impl Into<String>) -> Self {
        Self::QueryFailed(msg.into())
    }
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DeviceInfo {
    pub device_name: String,
    pub vendor_name: String,
    pub model_name: String,
    pub removable: u8,
    pub size: u64,
}

/// Active mount on a whole-disk target (partition source + mount point or drive letter).
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct DeviceMount {
    pub source: String,
    pub mount_point: String,
}

impl fmt::Display for DeviceInfo {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", json!(self))
    }
}

// --- Platform façade (stable public API) ---

/// Require a whole-disk device path for the current platform.
pub fn validate_block_device_path(path: &str) -> Result<(), DeviceError> {
    Active::validate_whole_disk_path(path)
}

/// Require that `path` is a valid block device and appears in `known` (picker flows).
pub fn validate_listed_block_device(
    path: &str,
    known: &[impl AsRef<str>],
) -> Result<(), DeviceError> {
    validate_device_for_io(path)?;
    let listed = known
        .iter()
        .any(|entry| Active::paths_equivalent(entry.as_ref(), path));
    if !listed {
        return Err(DeviceError::not_listed(format!(
            "Device {path} is not in the current device list. Refresh devices and select again."
        )));
    }
    Ok(())
}

/// Validate path format and refuse the system disk (mount state checked separately).
pub fn validate_device_for_io(path: &str) -> Result<(), DeviceError> {
    Active::validate_for_io(path)
}

/// Validate path format, refuse the system disk, and require no mounted volumes.
pub fn validate_device_safe_for_io(path: &str) -> Result<(), DeviceError> {
    Active::validate_safe_for_io(path)
}

/// Drive letters / volume labels on a physical drive (Windows). Empty on other platforms.
pub fn list_mounted_drive_letters(path: &str) -> Result<Vec<String>, DeviceError> {
    Ok(Active::list_mounts(path)?
        .into_iter()
        .map(|m| m.source)
        .collect())
}

/// Refuse the whole block device that hosts the system/root disk.
pub fn validate_device_not_system_disk(path: &str) -> Result<(), DeviceError> {
    Active::refuse_system_disk(path)
}

/// List partitions or volumes on `device_path` that are currently mounted.
pub fn list_device_mounts(device_path: &str) -> Result<Vec<DeviceMount>, DeviceError> {
    Active::list_mounts(device_path)
}

/// User-facing explanation when a target disk still has mounted volumes.
pub fn format_device_busy_error(device_path: &str, mounts: &[DeviceMount]) -> String {
    if mounts.is_empty() {
        return format!("{device_path} is not available for raw disk I/O.");
    }

    let details: Vec<String> = mounts
        .iter()
        .map(|m| format!("{} on {}", m.source, m.mount_point))
        .collect();

    let hint = Active::busy_hint();

    format!(
        "Cannot use {device_path}: {}. {}",
        if details.len() == 1 {
            format!("{} is still mounted", details[0])
        } else {
            format!("these volumes are still mounted: {}", details.join("; "))
        },
        hint
    )
}

/// Unmount every partition/volume on `device_path`.
pub fn unmount_device_volumes(device_path: &str) -> Result<Vec<String>, DeviceError> {
    Active::unmount_volumes(device_path)
}

/// When `auto_unmount` is true, unmount mounted volumes then verify the disk is idle.
///
/// Does not hold a long-lived exclusive session (Windows locks are taken when the
/// writer opens via [`VolumeOps::prepare_for_io`]).
pub fn ensure_device_ready_for_io(device_path: &str, auto_unmount: bool) -> Result<(), DeviceError> {
    Active::preflight_for_io(device_path, auto_unmount)
}

/// Refuse devices with partitions or the whole disk currently mounted.
pub fn validate_device_not_busy(path: &str) -> Result<(), DeviceError> {
    Active::refuse_if_busy(path)
}

/// Map a block device name or path to its parent whole-disk path (Linux).
///
/// On non-Linux platforms returns the input path unchanged when valid, or an error.
pub fn whole_disk_path(path_or_name: &str) -> Result<String, DeviceError> {
    #[cfg(target_os = "linux")]
    {
        crate::platform::linux::whole_disk_path(path_or_name)
    }
    #[cfg(not(target_os = "linux"))]
    {
        Active::validate_whole_disk_path(path_or_name)?;
        Ok(path_or_name.trim().to_string())
    }
}

/// Whether two device paths refer to the same device on this platform.
pub fn device_paths_equivalent(listed: &str, selected: &str) -> bool {
    Active::paths_equivalent(listed, selected)
}

/// Optional OS-specific write-error hint.
pub fn hint_for_io_error(err: &std::io::Error) -> Option<String> {
    Active::hint_for_io_error(err)
}

// --- Portable I/O buffer policy (not OS-specific) ---

const IO_BLOCK_SIZES: [usize; 14] = [
    4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576, 2097152, 4194304, 8388608,
    16777216, 33554432,
];

const DEFAULT_IO_BLOCK_SIZE: usize = IO_BLOCK_SIZES[0];

/// Max I/O buffer for removable USB / SD media.
pub const REMOVABLE_IO_BLOCK_CAP: usize = 4_194_304;

/// Pick I/O buffer size from device capacity (`DeviceInfo.size` — 512-byte sectors).
///
/// **Legacy quirk (Lithographer compatibility):** table entries are byte sizes, but the
/// comparison uses raw sector counts (not bytes).
pub fn optimal_io_block_size_from_sectors(size_sectors: u64) -> usize {
    if size_sectors > IO_BLOCK_SIZES[13] as u64 {
        return IO_BLOCK_SIZES[13];
    }
    if size_sectors < IO_BLOCK_SIZES[0] as u64 {
        return IO_BLOCK_SIZES[0];
    }

    IO_BLOCK_SIZES
        .iter()
        .copied()
        .find(|&block| size_sectors <= block as u64)
        .unwrap_or(IO_BLOCK_SIZES[13])
}

/// Apply removable-media cap to a table-derived block size.
pub fn optimal_io_block_size_for_media(size_sectors: u64, removable: bool) -> usize {
    let selected = optimal_io_block_size_from_sectors(size_sectors);
    if removable {
        selected.min(REMOVABLE_IO_BLOCK_CAP)
    } else {
        selected
    }
}

/// Resolve block size for a device path using the Lithographer I/O buffer table.
pub fn optimal_io_block_size(device_path: &str) -> usize {
    let removable = is_removable_device(device_path).unwrap_or(true);
    device_size_sectors(device_path)
        .map(|sectors| optimal_io_block_size_for_media(sectors, removable))
        .unwrap_or(DEFAULT_IO_BLOCK_SIZE)
}

/// Returns device size in 512-byte sectors.
pub fn device_size_sectors(device_path: &str) -> Option<u64> {
    Active::device_size_sectors(device_path)
}

/// Returns device size in bytes.
pub fn device_size_bytes(device_path: &str) -> Option<u64> {
    Active::device_size_bytes(device_path)
}

/// Whether the block device is removable media.
pub fn is_removable_device(device_path: &str) -> Result<bool> {
    Active::is_removable(device_path)
}

pub fn get_storage_devices() -> Result<Vec<DeviceInfo>> {
    Active::list_storage_devices()
}

#[cfg(test)]
mod validation_tests {
    use super::*;

    #[test]
    fn validate_requires_dev_prefix() {
        // Platform-specific: Linux rejects bare names; Windows rejects non-PhysicalDrive.
        assert!(validate_block_device_path("sdb").is_err());
    }

    #[cfg(target_os = "windows")]
    #[test]
    fn validate_rejects_non_physical_drive_paths() {
        assert!(validate_block_device_path(r"\\.\C:").is_err());
        assert!(validate_block_device_path("sdb").is_err());
    }

    #[test]
    fn optimal_io_block_size_from_sectors_matches_lithographer_table() {
        assert_eq!(optimal_io_block_size_from_sectors(2_048), 4_096);
        assert_eq!(optimal_io_block_size_from_sectors(4_096), 4_096);
        assert_eq!(optimal_io_block_size_from_sectors(5_000), 8_192);
        assert_eq!(optimal_io_block_size_from_sectors(100_000), 131_072);
        assert_eq!(optimal_io_block_size_from_sectors(2_097_152), 2_097_152);
    }

    #[test]
    fn optimal_io_block_size_from_sectors_clamps_large_disks() {
        assert_eq!(optimal_io_block_size_from_sectors(100_000_000), 33_554_432);
        assert_eq!(optimal_io_block_size_from_sectors(500), 4_096);
    }

    #[test]
    fn optimal_io_block_size_for_media_caps_removable() {
        assert_eq!(
            optimal_io_block_size_for_media(100_000_000, true),
            REMOVABLE_IO_BLOCK_CAP
        );
        assert_eq!(
            optimal_io_block_size_for_media(2_097_152, true),
            2_097_152
        );
        assert_eq!(
            optimal_io_block_size_for_media(100_000_000, false),
            33_554_432
        );
    }

    #[test]
    fn format_device_busy_error_is_actionable() {
        let mounts = vec![DeviceMount {
            source: "/dev/sda1".to_string(),
            mount_point: "/boot".to_string(),
        }];
        let msg = format_device_busy_error("/dev/sda", &mounts);
        assert!(msg.contains("/dev/sda"));
        assert!(msg.contains("/dev/sda1"));
        assert!(msg.contains("/boot"));
        assert!(msg.contains("unmount") || msg.contains("Confirm") || msg.contains("Unmount"));
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn whole_disk_path_strips_partitions() {
        assert_eq!(whole_disk_path("/dev/sdb1").unwrap(), "/dev/sdb");
        assert_eq!(whole_disk_path("/dev/nvme0n1p2").unwrap(), "/dev/nvme0n1");
        assert_eq!(whole_disk_path("/dev/mmcblk0p1").unwrap(), "/dev/mmcblk0");
        assert_eq!(whole_disk_path("/dev/sdb").unwrap(), "/dev/sdb");
    }
}