elio 1.5.1

Snappy, batteries-included terminal file manager with rich previews, inline images, bulk actions, and trash support.
Documentation
use super::*;
use crate::preview::appearance as theme;
use ratatui::text::Line;
use std::{fs::File, io::Read, path::Path, process::Command};

pub(in crate::preview) const ISO_METADATA_SCAN_BYTES: u64 = 128 * 1024;
pub(in crate::preview) const ISO_DESCRIPTOR_START_SECTOR: usize = 16;
pub(in crate::preview) const ISO_SECTOR_SIZE: usize = 2048;
pub(in crate::preview) const ISO_BOOT_SYSTEM_ID: &str = "EL TORITO SPECIFICATION";

#[derive(Default)]
pub(in crate::preview) struct IsoMetadata {
    pub(in crate::preview) system_id: Option<String>,
    pub(in crate::preview) volume_id: Option<String>,
    pub(in crate::preview) publisher_id: Option<String>,
    pub(in crate::preview) preparer_id: Option<String>,
    pub(in crate::preview) application_id: Option<String>,
    pub(in crate::preview) created_at: Option<String>,
    pub(in crate::preview) modified_at: Option<String>,
    pub(in crate::preview) effective_at: Option<String>,
    pub(in crate::preview) total_size: Option<u64>,
    pub(in crate::preview) bootable: bool,
}

pub(in crate::preview) fn build_iso_preview(path: &Path) -> Option<PreviewContent> {
    let metadata = read_iso_metadata(path);
    let entries = collect_iso_entries(path);
    if metadata.is_none() && entries.is_none() {
        return None;
    }

    Some(render_iso_preview(
        metadata.unwrap_or_default(),
        entries.unwrap_or_default(),
    ))
}

fn read_iso_metadata(path: &Path) -> Option<IsoMetadata> {
    let mut bytes = Vec::with_capacity(ISO_METADATA_SCAN_BYTES as usize);
    File::open(path)
        .ok()?
        .take(ISO_METADATA_SCAN_BYTES)
        .read_to_end(&mut bytes)
        .ok()?;
    parse_iso_metadata(&bytes)
}

pub(in crate::preview) fn parse_iso_metadata(bytes: &[u8]) -> Option<IsoMetadata> {
    let mut metadata = IsoMetadata::default();
    let mut found_descriptor = false;
    let start = ISO_DESCRIPTOR_START_SECTOR * ISO_SECTOR_SIZE;
    if bytes.len() < start + ISO_SECTOR_SIZE {
        return None;
    }

    for descriptor in bytes[start..].chunks_exact(ISO_SECTOR_SIZE) {
        if descriptor.get(1..6) != Some(b"CD001".as_slice()) {
            continue;
        }

        found_descriptor = true;
        match descriptor[0] {
            0 => {
                let boot_id = parse_iso_text_field(descriptor, 7, 39);
                if boot_id
                    .as_deref()
                    .is_some_and(|value| value.eq_ignore_ascii_case(ISO_BOOT_SYSTEM_ID))
                {
                    metadata.bootable = true;
                }
            }
            1 => {
                metadata.system_id = parse_iso_text_field(descriptor, 8, 40);
                metadata.volume_id = parse_iso_text_field(descriptor, 40, 72);
                metadata.publisher_id = parse_iso_text_field(descriptor, 318, 446);
                metadata.preparer_id = parse_iso_text_field(descriptor, 446, 574);
                metadata.application_id = parse_iso_text_field(descriptor, 574, 702);
                metadata.created_at = parse_iso_datetime_field(descriptor, 813, 830);
                metadata.modified_at = parse_iso_datetime_field(descriptor, 830, 847);
                metadata.effective_at = parse_iso_datetime_field(descriptor, 864, 881);

                let volume_blocks = parse_iso_u32_le(descriptor, 80);
                let block_size = parse_iso_u16_le(descriptor, 128);
                metadata.total_size = volume_blocks
                    .zip(block_size)
                    .map(|(blocks, block_size)| u64::from(blocks) * u64::from(block_size));
            }
            255 => break,
            _ => {}
        }
    }

    found_descriptor.then_some(metadata)
}

fn collect_iso_entries(path: &Path) -> Option<Vec<ArchiveEntry>> {
    collect_iso_entries_with_bsdtar(path).or_else(|| collect_iso_entries_with_isoinfo(path))
}

fn collect_iso_entries_with_bsdtar(path: &Path) -> Option<Vec<ArchiveEntry>> {
    let output = Command::new("bsdtar").arg("-tf").arg(path).output().ok()?;
    if !output.status.success() {
        return None;
    }
    Some(normalize_archive_entries(
        String::from_utf8_lossy(&output.stdout).lines(),
        true,
    ))
}

fn collect_iso_entries_with_isoinfo(path: &Path) -> Option<Vec<ArchiveEntry>> {
    let output = Command::new("isoinfo")
        .arg("-i")
        .arg(path)
        .arg("-f")
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    Some(normalize_archive_entries(
        String::from_utf8_lossy(&output.stdout).lines(),
        true,
    ))
}

pub(in crate::preview) fn render_iso_preview(
    metadata: IsoMetadata,
    entries: Vec<ArchiveEntry>,
) -> PreviewContent {
    let palette = theme::palette();
    let mut lines = Vec::new();
    let total_items = entries.len();
    let folder_count = entries.iter().filter(|entry| entry.is_dir).count();
    let file_count = total_items.saturating_sub(folder_count);

    let summary = vec![
        ("Volume", metadata.volume_id.clone()),
        ("System", metadata.system_id.clone()),
        (
            "Image Size",
            metadata.total_size.map(crate::fs::format_size),
        ),
        (
            "Bootable",
            metadata
                .bootable
                .then(|| "El Torito".to_string())
                .or_else(|| (total_items > 0).then(|| "No".to_string())),
        ),
        (
            "Entries",
            (total_items > 0).then(|| format!("{total_items} total")),
        ),
        (
            "Folders",
            (folder_count > 0).then(|| format!("{folder_count}")),
        ),
        ("Files", (file_count > 0).then(|| format!("{file_count}"))),
        ("Publisher", metadata.publisher_id.clone()),
        ("Prepared By", metadata.preparer_id.clone()),
        ("Application", metadata.application_id.clone()),
        ("Created", metadata.created_at.clone()),
        ("Modified", metadata.modified_at.clone()),
        ("Effective", metadata.effective_at.clone()),
    ];

    push_preview_section(&mut lines, "Details", &summary, palette);

    let mut rendered_items = 0usize;
    let mut tree_truncated = false;
    if !lines.is_empty() {
        lines.push(Line::from(""));
    }
    lines.push(section_line("Contents", palette));

    if entries.is_empty() {
        lines.push(Line::from("Unable to read ISO contents"));
    } else {
        let mut root = ArchiveTreeNode::default();
        for entry in &entries {
            insert_archive_tree_entry(&mut root, entry);
        }
        let available_lines = PREVIEW_RENDER_LINE_LIMIT.saturating_sub(lines.len());
        let mut remaining = available_lines;
        if remaining == 0 {
            tree_truncated = true;
        } else {
            let children = ordered_archive_children(&root.children);
            render_archive_tree(
                &children,
                "",
                &mut remaining,
                &mut rendered_items,
                &mut lines,
                palette,
            );
            tree_truncated = rendered_items < total_items;
        }
    }

    let mut preview =
        PreviewContent::new(PreviewKind::Archive, lines).with_detail("ISO disk image");
    let truncation_note = match (tree_truncated, total_items, rendered_items) {
        (true, total, rendered) if total > 0 && rendered > 0 => {
            Some(format!("showing first {rendered} of {total} entries"))
        }
        (true, total, _) if total > 0 => {
            Some(format!("showing first {PREVIEW_RENDER_LINE_LIMIT} lines"))
        }
        _ => None,
    };
    if let Some(note) = truncation_note {
        preview = preview.with_truncation(note);
    }
    preview
}

fn parse_iso_text_field(bytes: &[u8], start: usize, end: usize) -> Option<String> {
    let field = bytes.get(start..end)?;
    let text = String::from_utf8_lossy(field);
    let trimmed = text.trim_matches(char::from(0)).trim();
    (!trimmed.is_empty()).then(|| trimmed.to_string())
}

fn parse_iso_datetime_field(bytes: &[u8], start: usize, end: usize) -> Option<String> {
    let raw = parse_iso_text_field(bytes, start, end)?;
    format_iso_datetime(&raw)
}

fn format_iso_datetime(value: &str) -> Option<String> {
    let digits = value.as_bytes();
    if digits.len() != 17 || digits[..16].iter().any(|byte| !byte.is_ascii_digit()) {
        return None;
    }
    if digits[..16].iter().all(|byte| *byte == b'0') {
        return None;
    }

    let year = &value[0..4];
    let month = &value[4..6];
    let day = &value[6..8];
    let hour = &value[8..10];
    let minute = &value[10..12];
    let second = &value[12..14];
    Some(format!("{year}-{month}-{day} {hour}:{minute}:{second}"))
}

fn parse_iso_u32_le(bytes: &[u8], offset: usize) -> Option<u32> {
    let raw = bytes.get(offset..offset + 4)?;
    Some(u32::from_le_bytes(raw.try_into().ok()?))
}

fn parse_iso_u16_le(bytes: &[u8], offset: usize) -> Option<u16> {
    let raw = bytes.get(offset..offset + 2)?;
    Some(u16::from_le_bytes(raw.try_into().ok()?))
}