liblitho 0.2.0

cli tool to flash/clone the images to storage devices
Documentation
use crate::tui::app::Operation;
use crate::tui::theme::{ACCENT, AMBER, EMERALD, HINT, MUTED, TEXT};
use liblitho::devices::DeviceInfo;
use ratatui::{
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::ListItem,
};
use std::path::Path;

pub fn default_device_index(devices: &[DeviceInfo]) -> usize {
    devices.iter().position(|d| d.removable == 1).unwrap_or(0)
}

pub fn device_path(device: &DeviceInfo) -> String {
    device.device_name.clone()
}

pub fn device_display_name(device: &DeviceInfo) -> String {
    let vendor_model = format!("{} {}", device.vendor_name.trim(), device.model_name.trim())
        .trim()
        .to_string();
    if vendor_model.is_empty() {
        device.device_name.clone()
    } else {
        vendor_model
    }
}

pub fn device_label(device: &DeviceInfo) -> String {
    let removable = if device.removable == 1 {
        "(Removable)"
    } else {
        "(Fixed)"
    };
    format!(
        "{}{} {}",
        device_display_name(device),
        format_size(device.size),
        removable
    )
}

pub fn device_list_entry(device: &DeviceInfo, selected: bool) -> ListItem<'static> {
    let path = device_path(device);
    let removable = if device.removable == 1 {
        Span::styled(
            " Removable",
            Style::default().fg(EMERALD).add_modifier(Modifier::BOLD),
        )
    } else {
        Span::styled(
            " Fixed",
            Style::default().fg(AMBER).add_modifier(Modifier::BOLD),
        )
    };

    let marker = if selected { "" } else { "  " };
    let marker_style = if selected {
        Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(HINT)
    };
    let style = if selected {
        Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(TEXT)
    };

    ListItem::new(Line::from(vec![
        Span::styled(marker, marker_style),
        Span::styled(path, style),
        Span::styled(
            format!("  {}  ", device_display_name(device)),
            Style::default().fg(if selected { MUTED } else { HINT }),
        ),
        Span::styled(
            format_size(device.size),
            Style::default().fg(if selected { MUTED } else { HINT }),
        ),
        removable,
    ]))
}

pub fn file_basename(path: &str) -> String {
    Path::new(path)
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or(path)
        .to_string()
}

/// Truncate long strings for fixed-width TUI rows (prefix ellipsis).
pub fn truncate_end(s: &str, max_chars: usize) -> String {
    if max_chars == 0 {
        return String::new();
    }
    if s.chars().count() <= max_chars {
        return s.to_string();
    }
    if max_chars == 1 {
        return "".to_string();
    }
    let tail: String = s
        .chars()
        .rev()
        .take(max_chars - 1)
        .collect::<Vec<_>>()
        .into_iter()
        .rev()
        .collect();
    format!("{tail}")
}

pub fn file_path_hint(path: &str, operation: Operation, max_chars: usize) -> String {
    if path.is_empty() {
        return String::new();
    }
    let path = Path::new(path);
    match operation {
        Operation::Flash => truncate_end(&path.display().to_string(), max_chars),
        Operation::Clone => path
            .parent()
            .map(|parent| truncate_end(&parent.display().to_string(), max_chars))
            .unwrap_or_default(),
    }
}

pub fn file_section_label(operation: Operation) -> &'static str {
    match operation {
        Operation::Flash => "SOURCE FILE",
        Operation::Clone => "OUTPUT FILE",
    }
}

pub fn format_size(size: u64) -> String {
    const SECTOR_SIZE: u64 = 512;
    let bytes = size * SECTOR_SIZE;

    if bytes >= 1_000_000_000_000 {
        format!("{:.2} TB", bytes as f64 / 1_000_000_000_000.0)
    } else if bytes >= 1_000_000_000 {
        format!("{:.2} GB", bytes as f64 / 1_000_000_000.0)
    } else if bytes >= 1_000_000 {
        format!("{:.2} MB", bytes as f64 / 1_000_000.0)
    } else if bytes >= 1_000 {
        format!("{:.2} KB", bytes as f64 / 1_000.0)
    } else {
        format!("{} B", bytes)
    }
}

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

    #[test]
    fn truncate_end_short_string_unchanged() {
        assert_eq!(truncate_end("abc", 5), "abc");
    }

    #[test]
    fn truncate_end_long_string_uses_ellipsis() {
        assert_eq!(truncate_end("abcdefghij", 6), "…fghij");
    }

    #[test]
    fn file_path_hint_clone_shows_parent_only() {
        let hint = file_path_hint("/home/user/out/sdb-clone.img", Operation::Clone, 40);
        assert_eq!(hint, "/home/user/out");
    }

    #[test]
    fn file_path_hint_flash_shows_full_path() {
        let hint = file_path_hint("/home/user/image.img", Operation::Flash, 40);
        assert_eq!(hint, "/home/user/image.img");
    }
}