yuru-tui 0.2.3

Terminal user interface for Yuru
Documentation
use std::path::Path;
use std::process::Command;
use std::{fs::File, io::Read};

use super::cache::PreviewPayload;
#[cfg(feature = "image")]
use super::image::preview_image_metadata_from_path;
use super::process::{run_bounded_process, MAX_PREVIEW_OUTPUT_BYTES};
use super::PreviewCancellation;

const ASCII_TEXT_SNIFF_BYTES: usize = 8192;

pub(super) fn run_builtin_preview(
    item: &str,
    text_extensions: &[String],
    cancellation: &PreviewCancellation,
) -> PreviewPayload {
    if cancellation.is_cancelled() {
        return PreviewPayload::Text("preview cancelled".to_string());
    }
    let path = Path::new(item);
    if item.trim().is_empty() {
        return PreviewPayload::Text("no selection".to_string());
    }
    if path.is_dir() {
        return PreviewPayload::Text(preview_directory(path));
    }
    if !path.exists() {
        return PreviewPayload::Text(format!("missing: {item}"));
    }
    if !path.is_file() {
        return PreviewPayload::Text(preview_path_metadata(path));
    }
    if path.metadata().is_ok_and(|metadata| metadata.len() == 0) {
        return PreviewPayload::Text(format!("empty file: {item}"));
    }
    if is_text_path(path, text_extensions) || is_ascii_text_file(path) {
        return PreviewPayload::Text(preview_text_file(path, cancellation));
    }
    #[cfg(feature = "image")]
    if let Some(metadata) = preview_image_metadata_from_path(path) {
        return PreviewPayload::Text(metadata);
    }
    PreviewPayload::Text(preview_path_metadata(path))
}

fn preview_directory(path: &Path) -> String {
    let mut entries = match std::fs::read_dir(path) {
        Ok(entries) => entries
            .filter_map(|entry| entry.ok())
            .take(121)
            .map(|entry| {
                let suffix = entry
                    .file_type()
                    .ok()
                    .filter(|file_type| file_type.is_dir())
                    .map(|_| "/")
                    .unwrap_or_default();
                format!("{}{}", entry.file_name().to_string_lossy(), suffix)
            })
            .collect::<Vec<_>>(),
        Err(error) => return format!("directory: {}\nerror: {error}", path.display()),
    };
    entries.sort();
    let mut output = format!("directory: {}\n\n", path.display());
    for entry in entries.into_iter().take(120) {
        output.push_str(&entry);
        output.push('\n');
    }
    output
}

fn preview_text_file(path: &Path, cancellation: &PreviewCancellation) -> String {
    if let Some(output) = preview_text_with_bat(path, cancellation) {
        return output;
    }
    if cancellation.is_cancelled() {
        return "preview cancelled".to_string();
    }
    match read_bounded_file(path) {
        Ok((bytes, truncated)) => format_bounded_text(&bytes, truncated),
        Err(error) => format!("file: {}\nerror: {error}", path.display()),
    }
}

fn preview_text_with_bat(path: &Path, cancellation: &PreviewCancellation) -> Option<String> {
    let mut process = Command::new("bat");
    process
        .args([
            "--style=numbers",
            "--color=never",
            "--paging=never",
            "--line-range",
            ":200",
            "--",
        ])
        .arg(path);
    let output = run_bounded_process(&mut process, cancellation).ok()?;
    if output.cancelled
        || output.timed_out
        || !output.status.is_some_and(|status| status.success())
        || output.stdout.is_empty()
    {
        return None;
    }
    Some(format_bounded_text(&output.stdout, output.truncated))
}

fn read_bounded_file(path: &Path) -> std::io::Result<(Vec<u8>, bool)> {
    let file = File::open(path)?;
    let mut bytes = Vec::with_capacity(16 * 1024);
    file.take((MAX_PREVIEW_OUTPUT_BYTES + 1) as u64)
        .read_to_end(&mut bytes)?;
    let truncated = bytes.len() > MAX_PREVIEW_OUTPUT_BYTES;
    bytes.truncate(MAX_PREVIEW_OUTPUT_BYTES);
    Ok((bytes, truncated))
}

fn format_bounded_text(bytes: &[u8], byte_truncated: bool) -> String {
    let text = String::from_utf8_lossy(bytes);
    let mut output = limit_preview_lines(&text, 200);
    if byte_truncated {
        output.push_str("... preview truncated at 1 MiB ...\n");
    }
    output
}

fn limit_preview_lines(text: &str, limit: usize) -> String {
    let mut output = String::new();
    for (index, line) in text.lines().enumerate() {
        if index >= limit {
            output.push_str("...\n");
            break;
        }
        output.push_str(line);
        output.push('\n');
    }
    output
}

fn preview_path_metadata(path: &Path) -> String {
    match path.metadata() {
        Ok(metadata) => format!(
            "file: {}\nsize: {} bytes\npreview: no text preview for this file type",
            path.display(),
            metadata.len()
        ),
        Err(error) => format!("file: {}\nerror: {error}", path.display()),
    }
}

fn is_text_path(path: &Path, text_extensions: &[String]) -> bool {
    let Some(extension) = path
        .extension()
        .and_then(|extension| extension.to_str())
        .map(str::to_ascii_lowercase)
    else {
        return false;
    };
    text_extensions.iter().any(|item| {
        item.trim_start_matches('.')
            .eq_ignore_ascii_case(&extension)
    })
}

fn is_ascii_text_file(path: &Path) -> bool {
    let mut file = match File::open(path) {
        Ok(file) => file,
        Err(_) => return false,
    };
    let mut sample = [0u8; ASCII_TEXT_SNIFF_BYTES];
    let len = match file.read(&mut sample) {
        Ok(len) => len,
        Err(_) => return false,
    };
    let sample = &sample[..len];
    !sample.is_empty() && sample.iter().all(|byte| is_ascii_text_byte(*byte))
}

fn is_ascii_text_byte(byte: u8) -> bool {
    matches!(byte, b'\t' | b'\n' | b'\r' | 0x0c | 0x20..=0x7e)
}