elio 1.1.0

Snappy, batteries-included terminal file manager with rich previews, inline images, bulk actions, and trash support.
Documentation
use super::{ImageProtocol, TerminalIdentity};
use std::{env, fs, path::Path};

pub(super) fn pdf_preview_tools_available() -> bool {
    command_exists("pdfinfo") && command_exists("pdftocairo")
}

pub(in crate::app) fn detect_terminal_identity() -> TerminalIdentity {
    let term = env::var("TERM").unwrap_or_default().to_ascii_lowercase();
    let term_program = env::var("TERM_PROGRAM")
        .unwrap_or_default()
        .to_ascii_lowercase();
    let kitty_window_id = env::var_os("KITTY_WINDOW_ID").is_some();

    if kitty_window_id || term.contains("xterm-kitty") || term_program == "kitty" {
        TerminalIdentity::Kitty
    } else if term.contains("ghostty") || term_program == "ghostty" {
        TerminalIdentity::Ghostty
    } else if term.contains("wezterm") || term_program == "wezterm" {
        TerminalIdentity::WezTerm
    } else if term_program.contains("warp") || env::var_os("WARP_SESSION_ID").is_some() {
        TerminalIdentity::Warp
    } else if term_program == "iterm.app" {
        TerminalIdentity::ITerm2
    } else if term.contains("alacritty")
        || term_program.contains("alacritty")
        || env::var_os("ALACRITTY_SOCKET").is_some()
    {
        TerminalIdentity::Alacritty
    } else if term == "foot" || term == "foot-extra" {
        // Foot sets TERM=foot or TERM=foot-extra and supports Sixel natively.
        TerminalIdentity::Foot
    } else if env::var_os("WT_SESSION").is_some() {
        // WT_SESSION is a GUID set by Windows Terminal in every shell it hosts.
        // WT_PROFILE_ID is also available but WT_SESSION is the canonical marker.
        TerminalIdentity::WindowsTerminal
    } else {
        TerminalIdentity::Other
    }
}

pub(in crate::app) fn select_image_protocol(
    identity: TerminalIdentity,
    image_previews_override: bool,
) -> ImageProtocol {
    match identity {
        TerminalIdentity::Kitty => ImageProtocol::KittyGraphics,
        TerminalIdentity::Ghostty => ImageProtocol::KittyGraphics,
        TerminalIdentity::Warp => ImageProtocol::KittyGraphics,
        TerminalIdentity::WezTerm | TerminalIdentity::ITerm2 => ImageProtocol::ItermInline,
        // Foot and Windows Terminal ≥ 1.22 both support Sixel graphics.
        TerminalIdentity::Foot | TerminalIdentity::WindowsTerminal => ImageProtocol::Sixel,
        // ELIO_IMAGE_PREVIEWS=1 force-enables KittyGraphics on unrecognised terminals
        // for testing. Alacritty is excluded — it does not support image protocols.
        TerminalIdentity::Other if image_previews_override => ImageProtocol::KittyGraphics,
        TerminalIdentity::Alacritty | TerminalIdentity::Other => ImageProtocol::None,
    }
}

pub(in crate::app) fn command_exists(program: &str) -> bool {
    if program.is_empty() {
        return false;
    }

    let program_path = Path::new(program);
    if program_path.components().count() > 1 {
        return executable_file_exists(program_path)
            || cfg!(windows) && executable_file_exists(&program_path.with_extension("exe"));
    }

    env::var_os("PATH").is_some_and(|paths| {
        env::split_paths(&paths).any(|dir| {
            let candidate = dir.join(program);
            executable_file_exists(&candidate)
                || cfg!(windows) && executable_file_exists(&candidate.with_extension("exe"))
        })
    })
}

fn executable_file_exists(path: &Path) -> bool {
    let Ok(metadata) = fs::metadata(path) else {
        return false;
    };
    if !metadata.is_file() {
        return false;
    }

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        metadata.permissions().mode() & 0o111 != 0
    }

    #[cfg(not(unix))]
    {
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{
        ffi::OsString,
        fs,
        path::PathBuf,
        sync::{Mutex, OnceLock},
        time::{SystemTime, UNIX_EPOCH},
    };

    fn temp_root(label: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time should be after unix epoch")
            .as_nanos();
        std::env::temp_dir().join(format!("elio-inline-image-{label}-{unique}"))
    }

    fn terminal_env_lock() -> std::sync::MutexGuard<'static, ()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
    }

    struct TerminalEnvGuard {
        saved: Vec<(&'static str, Option<OsString>)>,
    }

    impl TerminalEnvGuard {
        fn isolate() -> Self {
            const VARS: &[&str] = &[
                "TERM",
                "TERM_PROGRAM",
                "KITTY_WINDOW_ID",
                "WARP_SESSION_ID",
                "ALACRITTY_SOCKET",
                "WT_SESSION",
            ];

            let saved = VARS
                .iter()
                .map(|name| (*name, env::var_os(name)))
                .collect::<Vec<_>>();
            for name in VARS {
                unsafe {
                    env::remove_var(name);
                }
            }

            Self { saved }
        }
    }

    impl Drop for TerminalEnvGuard {
        fn drop(&mut self) {
            for (name, value) in &self.saved {
                if let Some(value) = value {
                    unsafe {
                        env::set_var(name, value);
                    }
                } else {
                    unsafe {
                        env::remove_var(name);
                    }
                }
            }
        }
    }

    #[test]
    fn detect_terminal_identity_recognizes_iterm2_term_program() {
        let _lock = terminal_env_lock();
        let _guard = TerminalEnvGuard::isolate();

        unsafe {
            env::set_var("TERM_PROGRAM", "iTerm.app");
        }

        assert_eq!(detect_terminal_identity(), TerminalIdentity::ITerm2);
    }

    #[test]
    fn select_image_protocol_kitty_always_enabled() {
        assert_eq!(
            select_image_protocol(TerminalIdentity::Kitty, false),
            ImageProtocol::KittyGraphics
        );
        assert_eq!(
            select_image_protocol(TerminalIdentity::Kitty, true),
            ImageProtocol::KittyGraphics
        );
    }

    #[test]
    fn select_image_protocol_ghostty_always_enabled() {
        assert_eq!(
            select_image_protocol(TerminalIdentity::Ghostty, false),
            ImageProtocol::KittyGraphics
        );
        assert_eq!(
            select_image_protocol(TerminalIdentity::Ghostty, true),
            ImageProtocol::KittyGraphics
        );
    }

    #[test]
    fn select_image_protocol_wezterm_always_enabled() {
        assert_eq!(
            select_image_protocol(TerminalIdentity::WezTerm, false),
            ImageProtocol::ItermInline
        );
        assert_eq!(
            select_image_protocol(TerminalIdentity::WezTerm, true),
            ImageProtocol::ItermInline
        );
    }

    #[test]
    fn select_image_protocol_iterm2_always_enabled() {
        assert_eq!(
            select_image_protocol(TerminalIdentity::ITerm2, false),
            ImageProtocol::ItermInline
        );
        assert_eq!(
            select_image_protocol(TerminalIdentity::ITerm2, true),
            ImageProtocol::ItermInline
        );
    }

    #[test]
    fn select_image_protocol_warp_always_enabled() {
        assert_eq!(
            select_image_protocol(TerminalIdentity::Warp, false),
            ImageProtocol::KittyGraphics
        );
        assert_eq!(
            select_image_protocol(TerminalIdentity::Warp, true),
            ImageProtocol::KittyGraphics
        );
    }

    #[test]
    fn select_image_protocol_alacritty_disabled_and_other_override_enabled() {
        assert_eq!(
            select_image_protocol(TerminalIdentity::Alacritty, true),
            ImageProtocol::None
        );
        assert_eq!(
            select_image_protocol(TerminalIdentity::Other, false),
            ImageProtocol::None
        );
        assert_eq!(
            select_image_protocol(TerminalIdentity::Other, true),
            ImageProtocol::KittyGraphics
        );
    }

    #[test]
    fn detect_terminal_identity_recognizes_foot_term() {
        let _lock = terminal_env_lock();
        let _guard = TerminalEnvGuard::isolate();

        unsafe {
            env::set_var("TERM", "foot");
        }

        assert_eq!(detect_terminal_identity(), TerminalIdentity::Foot);
    }

    #[test]
    fn detect_terminal_identity_recognizes_foot_extra_term() {
        let _lock = terminal_env_lock();
        let _guard = TerminalEnvGuard::isolate();

        unsafe {
            env::set_var("TERM", "foot-extra");
        }

        assert_eq!(detect_terminal_identity(), TerminalIdentity::Foot);
    }

    #[test]
    fn select_image_protocol_foot_uses_sixel() {
        assert_eq!(
            select_image_protocol(TerminalIdentity::Foot, false),
            ImageProtocol::Sixel
        );
        assert_eq!(
            select_image_protocol(TerminalIdentity::Foot, true),
            ImageProtocol::Sixel
        );
    }

    #[test]
    fn detect_terminal_identity_recognizes_windows_terminal_wt_session() {
        let _lock = terminal_env_lock();
        let _guard = TerminalEnvGuard::isolate();

        unsafe {
            env::set_var("WT_SESSION", "00000000-0000-0000-0000-000000000001");
        }

        assert_eq!(
            detect_terminal_identity(),
            TerminalIdentity::WindowsTerminal
        );
    }

    #[test]
    fn select_image_protocol_windows_terminal_uses_sixel() {
        assert_eq!(
            select_image_protocol(TerminalIdentity::WindowsTerminal, false),
            ImageProtocol::Sixel
        );
        assert_eq!(
            select_image_protocol(TerminalIdentity::WindowsTerminal, true),
            ImageProtocol::Sixel
        );
    }

    #[test]
    fn windows_terminal_takes_precedence_over_other_fallback() {
        let _lock = terminal_env_lock();
        let _guard = TerminalEnvGuard::isolate();

        // WT_SESSION present, no other terminal markers → WindowsTerminal
        unsafe {
            env::set_var("WT_SESSION", "some-guid");
        }

        assert_eq!(
            detect_terminal_identity(),
            TerminalIdentity::WindowsTerminal
        );
    }

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

        let root = temp_root("command-exists-direct-path");
        fs::create_dir_all(&root).expect("failed to create temp root");

        let executable = root.join("demo-tool");
        fs::write(&executable, b"#!/bin/sh\nexit 0\n").expect("failed to write test executable");

        let mut permissions = fs::metadata(&executable)
            .expect("test executable metadata should exist")
            .permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(&executable, permissions).expect("failed to mark test executable");

        assert!(command_exists(
            executable.to_str().expect("path should be valid utf-8")
        ));

        let not_executable = root.join("demo-data");
        fs::write(&not_executable, b"plain data").expect("failed to write plain file");
        assert!(!command_exists(
            not_executable.to_str().expect("path should be valid utf-8")
        ));
    }
}