chezmoi-tui 0.2.0

A visual TUI wrapper around chezmoi
use crate::actions::send_task;
use crate::app::{App, BackendTask, DetailKind};
use crate::domain::ListView;
use anyhow::{Context, Result};
use std::fmt::Write as _;
use std::fs::{self, File};
use std::io::{ErrorKind, Read};
use std::path::Path;
use tokio::sync::mpsc::UnboundedSender;

const PREVIEW_MAX_BYTES: usize = 64 * 1024;
const PREVIEW_BINARY_SAMPLE_BYTES: usize = 4096;

pub(crate) fn load_file_preview(path: &Path) -> Result<String> {
    let metadata = fs::symlink_metadata(path)
        .with_context(|| format!("preview target metadata failed: {}", path.display()))?;
    let kind = metadata.file_type();

    if kind.is_dir() {
        return Ok("This is a directory. Expand it and select a file inside.".to_string());
    }
    if kind.is_symlink() {
        match fs::metadata(path) {
            Ok(target) if target.is_dir() => {
                return Ok(
                    "This is a directory symlink. Directory links are shown but not expanded by default."
                        .to_string(),
                );
            }
            Ok(_) => {}
            Err(err) if err.kind() == ErrorKind::NotFound => {
                return Ok("Cannot preview broken symlink.".to_string());
            }
            Err(err) => {
                return Err(err).with_context(|| {
                    format!("failed to inspect symlink target: {}", path.display())
                });
            }
        }
    }

    let file = File::open(path).with_context(|| format!("failed to read: {}", path.display()))?;
    let mut bytes = Vec::with_capacity(PREVIEW_MAX_BYTES + 1);
    file.take((PREVIEW_MAX_BYTES + 1) as u64)
        .read_to_end(&mut bytes)
        .with_context(|| format!("failed to read: {}", path.display()))?;

    let sample_len = bytes.len().min(PREVIEW_BINARY_SAMPLE_BYTES);
    if bytes[..sample_len].contains(&0) {
        return Ok("Cannot preview binary file.".to_string());
    }

    let is_truncated = bytes.len() > PREVIEW_MAX_BYTES;
    if is_truncated {
        bytes.truncate(PREVIEW_MAX_BYTES);
    }

    let mut text = String::from_utf8_lossy(&bytes).to_string();
    if is_truncated {
        let _ = write!(
            text,
            "\n\n--- preview truncated at {} bytes (file size: {} bytes) ---",
            PREVIEW_MAX_BYTES,
            metadata.len()
        );
    }
    Ok(text)
}

fn maybe_enqueue_unmanaged_preview(
    app: &mut App,
    task_tx: &UnboundedSender<BackendTask>,
) -> Result<()> {
    if app.view != ListView::Unmanaged {
        return Ok(());
    }
    if app.selected_is_directory() {
        app.clear_detail();
        return Ok(());
    }

    let (Some(target), Some(absolute)) = (app.selected_path(), app.selected_absolute_path()) else {
        return Ok(());
    };

    if app.detail_kind == DetailKind::Preview && app.detail_target.as_ref() == Some(&target) {
        return Ok(());
    }

    let request_id = app.begin_detail_request();
    send_task(
        app,
        task_tx,
        BackendTask::LoadPreview {
            request_id,
            target,
            absolute,
            origin: app.preview_origin_for_view(ListView::Unmanaged),
        },
    )
}

fn maybe_enqueue_managed_preview(
    app: &mut App,
    task_tx: &UnboundedSender<BackendTask>,
) -> Result<()> {
    if app.view != ListView::Managed {
        return Ok(());
    }
    if app.selected_is_directory() {
        app.clear_detail();
        return Ok(());
    }

    let (Some(target), Some(absolute)) = (app.selected_path(), app.selected_absolute_path()) else {
        return Ok(());
    };

    if app.detail_kind == DetailKind::Preview && app.detail_target.as_ref() == Some(&target) {
        return Ok(());
    }

    let request_id = app.begin_detail_request();
    send_task(
        app,
        task_tx,
        BackendTask::LoadPreview {
            request_id,
            target,
            absolute,
            origin: app.preview_origin_for_view(ListView::Managed),
        },
    )
}

fn maybe_enqueue_source_preview(
    app: &mut App,
    task_tx: &UnboundedSender<BackendTask>,
) -> Result<()> {
    if app.view != ListView::Source {
        return Ok(());
    }
    if app.selected_is_directory() {
        app.clear_detail();
        return Ok(());
    }

    let (Some(target), Some(absolute)) = (app.selected_path(), app.selected_absolute_path()) else {
        return Ok(());
    };

    if app.detail_kind == DetailKind::Preview && app.detail_target.as_ref() == Some(&target) {
        return Ok(());
    }

    let request_id = app.begin_detail_request();
    send_task(
        app,
        task_tx,
        BackendTask::LoadPreview {
            request_id,
            target,
            absolute,
            origin: app.preview_origin_for_view(ListView::Source),
        },
    )
}

fn maybe_enqueue_status_diff(app: &mut App, task_tx: &UnboundedSender<BackendTask>) -> Result<()> {
    if app.view != ListView::Status {
        return Ok(());
    }

    let Some(target) = app.selected_absolute_path() else {
        return Ok(());
    };
    if app.detail_kind == DetailKind::Diff && app.detail_target.as_ref() == Some(&target) {
        return Ok(());
    }

    let request_id = app.begin_detail_request();
    send_task(
        app,
        task_tx,
        BackendTask::LoadDiff {
            request_id,
            target: Some(target),
        },
    )
}

pub(crate) fn maybe_enqueue_auto_detail(
    app: &mut App,
    task_tx: &UnboundedSender<BackendTask>,
) -> Result<()> {
    if !app.config.auto_preview {
        return Ok(());
    }

    maybe_enqueue_status_diff(app, task_tx)?;
    maybe_enqueue_managed_preview(app, task_tx)?;
    maybe_enqueue_unmanaged_preview(app, task_tx)?;
    maybe_enqueue_source_preview(app, task_tx)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn preview_rejects_binary_files() {
        let file =
            std::env::temp_dir().join(format!("chezmoi_tui_preview_bin_{}", std::process::id()));
        std::fs::write(&file, [0, 159, 146, 150]).expect("write binary");
        let got = load_file_preview(&file).expect("preview");
        assert!(got.contains("binary file"));
        let _ = std::fs::remove_file(file);
    }

    #[test]
    fn preview_truncates_large_text() {
        let file =
            std::env::temp_dir().join(format!("chezmoi_tui_preview_txt_{}", std::process::id()));
        let payload = "a".repeat(PREVIEW_MAX_BYTES + 128);
        std::fs::write(&file, payload).expect("write text");
        let got = load_file_preview(&file).expect("preview");
        assert!(got.contains("preview truncated"));
        let _ = std::fs::remove_file(file);
    }

    #[cfg(unix)]
    #[test]
    fn preview_reports_directory_symlink() {
        use std::os::unix::fs::symlink;

        let root = std::env::temp_dir().join(format!(
            "chezmoi_tui_preview_symlink_dir_{}_{}",
            std::process::id(),
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .expect("time")
                .as_nanos()
        ));
        let real_dir = root.join("real");
        std::fs::create_dir_all(&real_dir).expect("create real dir");
        std::fs::write(real_dir.join("inside.txt"), "inside").expect("write file");
        let link = root.join("linkdir");
        symlink(&real_dir, &link).expect("create symlink");

        let got = load_file_preview(&link).expect("preview");
        assert!(got.contains("directory symlink"));
        let _ = std::fs::remove_dir_all(root);
    }

    #[cfg(unix)]
    #[test]
    fn preview_reports_broken_symlink() {
        use std::os::unix::fs::symlink;

        let root = std::env::temp_dir().join(format!(
            "chezmoi_tui_preview_broken_symlink_{}_{}",
            std::process::id(),
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .expect("time")
                .as_nanos()
        ));
        std::fs::create_dir_all(&root).expect("create root");
        let link = root.join("broken");
        symlink(root.join("missing.txt"), &link).expect("create broken symlink");

        let got = load_file_preview(&link).expect("preview");
        assert!(got.contains("broken symlink"));
        let _ = std::fs::remove_dir_all(root);
    }
}