elio 1.7.0

Snappy, batteries-included terminal file manager with rich previews, inline images, bulk actions, and trash support.
Documentation
#[cfg(unix)]
use super::resolve_write_path;
use super::{
    MANAGED_END, MANAGED_START, Shell, ShellDetection, binary_command, detect_shell_from_command,
    init_script, managed_script, nu_string_literal, remove_managed_blocks, shell_name_from_command,
    uninstall_reload_command, upsert_managed_block, write_text_atomic,
};
use std::{
    fs,
    path::{Path, PathBuf},
    time::{SystemTime, UNIX_EPOCH},
};

fn temp_path(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-shell-integration-{label}-{unique}"))
}

#[test]
fn binary_command_uses_path_for_local_invocations() {
    assert_eq!(
        binary_command(
            Shell::Bash,
            Some("target/debug/elio"),
            Path::new("/repo/target/debug/elio")
        ),
        "'/repo/target/debug/elio'"
    );
}

#[test]
fn binary_command_uses_path_for_absolute_invocations() {
    assert_eq!(
        binary_command(
            Shell::Bash,
            Some("/opt/elio/bin/elio"),
            Path::new("/opt/elio/bin/elio")
        ),
        "'/opt/elio/bin/elio'"
    );
}

#[test]
fn binary_command_uses_path_for_windows_invocations() {
    assert_eq!(
        binary_command(
            Shell::Bash,
            Some(r"C:\repo\target\debug\elio.exe"),
            Path::new(r"C:\repo\target\debug\elio.exe")
        ),
        r"'C:\repo\target\debug\elio.exe'"
    );
}

#[test]
fn binary_command_uses_path_lookup_for_normal_invocations() {
    assert_eq!(
        binary_command(Shell::Bash, Some("elio"), Path::new("/versioned/path/elio")),
        "command elio"
    );
}

#[test]
fn binary_command_formats_nu_invocations_for_run_external() {
    assert_eq!(
        binary_command(Shell::Nu, Some("elio"), Path::new("/versioned/path/elio")),
        r#""elio""#
    );
    assert_eq!(
        binary_command(
            Shell::Nu,
            Some("target/debug/elio"),
            Path::new("/repo/target/debug/elio")
        ),
        r#""/repo/target/debug/elio""#
    );
}

#[test]
fn nu_string_literal_escapes_backslashes_and_quotes() {
    assert_eq!(
        nu_string_literal(Path::new(r#"/tmp/path with spaces/eli"o\bin"#)),
        r#""/tmp/path with spaces/eli\"o\\bin""#
    );
}

#[test]
fn posix_init_script_passes_cli_commands_through() {
    let script = init_script(Shell::Bash, "command elio");

    assert!(script.contains("case \"${1-}\" in"));
    assert!(script.contains("shell|-*)"));
    assert!(script.contains("command elio \"$@\""));
    assert!(script.contains("local tmp cwd status_code"));
    assert!(script.contains("command elio --cwd-file \"$tmp\" \"$@\""));
    assert!(script.contains("status_code=$?"));
    assert!(script.contains("return \"$status_code\""));
    assert!(!script.contains("local tmp cwd status\n"));
}

#[test]
fn fish_init_script_passes_cli_commands_through() {
    let script = init_script(Shell::Fish, "command elio");

    assert!(script.contains("switch \"$argv[1]\""));
    assert!(script.contains("case shell '-*'"));
    assert!(script.contains("command elio $argv"));
    assert!(script.contains("command elio --cwd-file \"$tmp\" $argv"));
    assert!(script.contains("cd \"$cwd\"; or return $status"));
}

#[test]
fn nu_init_script_passes_cli_commands_through_without_posix_syntax() {
    let script = init_script(Shell::Nu, r#""elio""#);

    assert!(script.contains("def --env --wrapped elio [...args]"));
    assert!(script.contains("run-external \"elio\" ...$args"));
    assert!(script.contains("mktemp -t \"elio-cwd.XXXXXX\""));
    assert!(script.contains("let command_args = ([\"--cwd-file\", $tmp] ++ $args)"));
    assert!(script.contains("run-external \"elio\" ...$command_args"));
    assert!(script.contains("$env.LAST_EXIT_CODE = $status_code"));
    assert!(script.contains("$e.exit_code? | default 127"));
    assert!(script.contains("cd $cwd"));
    assert!(!script.contains("local tmp"));
    assert!(!script.contains("case \"${1-}\""));
    assert!(!script.contains("command elio"));
    assert!(!script.contains("return $status_code"));
}

#[test]
fn shell_name_from_command_handles_paths_login_shells_and_arguments() {
    assert_eq!(
        shell_name_from_command("/usr/bin/zsh\n").as_deref(),
        Some("zsh")
    );
    assert_eq!(shell_name_from_command("-zsh").as_deref(), Some("zsh"));
    assert_eq!(
        shell_name_from_command("/opt/homebrew/bin/fish --login").as_deref(),
        Some("fish")
    );
    assert_eq!(shell_name_from_command("  "), None);
}

#[test]
fn detect_shell_from_command_distinguishes_supported_unsupported_and_unknown() {
    assert_eq!(
        detect_shell_from_command("/usr/bin/fish\n"),
        ShellDetection::Supported(Shell::Fish)
    );
    assert_eq!(
        detect_shell_from_command("/usr/bin/nu --login\n"),
        ShellDetection::Supported(Shell::Nu)
    );
    assert_eq!(
        detect_shell_from_command("-nushell\n"),
        ShellDetection::Supported(Shell::Nu)
    );
    assert_eq!(
        detect_shell_from_command("shell_integration_cli\n"),
        ShellDetection::Unknown
    );
}

#[test]
fn uninstall_reload_command_removes_loaded_function() {
    assert_eq!(uninstall_reload_command(Shell::Bash), "unset -f elio");
    assert_eq!(
        uninstall_reload_command(Shell::Zsh),
        "unfunction elio 2>/dev/null || true"
    );
    assert_eq!(
        uninstall_reload_command(Shell::Fish),
        "functions --erase elio"
    );
    assert_eq!(uninstall_reload_command(Shell::Nu), "hide elio");
}

#[test]
fn write_text_atomic_replaces_existing_file_and_removes_temp_file() {
    let root = temp_path("atomic-replace");
    fs::create_dir_all(&root).expect("temp directory should be created");
    let path = root.join(".bashrc");
    fs::write(&path, "old").expect("existing file should be written");

    write_text_atomic(&path, "new\n").expect("file should be replaced atomically");

    assert_eq!(
        fs::read_to_string(&path).expect("updated file should be readable"),
        "new\n"
    );
    let temp_files = fs::read_dir(&root)
        .expect("temp directory should be readable")
        .filter_map(|entry| entry.ok())
        .filter(|entry| entry.file_name().to_string_lossy().contains(".elio-tmp-"))
        .count();
    assert_eq!(temp_files, 0);

    fs::remove_dir_all(root).expect("temp directory should be removed");
}

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

    let root = temp_path("atomic-permissions");
    fs::create_dir_all(&root).expect("temp directory should be created");
    let path = root.join(".zshrc");
    fs::write(&path, "old").expect("existing file should be written");
    fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
        .expect("permissions should be set");

    write_text_atomic(&path, "new").expect("file should be replaced atomically");

    let mode = fs::metadata(&path)
        .expect("updated file should have metadata")
        .permissions()
        .mode()
        & 0o777;
    assert_eq!(mode, 0o600);

    fs::remove_dir_all(root).expect("temp directory should be removed");
}

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

    let root = temp_path("atomic-symlink");
    let home = root.join("home");
    let dotfiles = root.join("dotfiles");
    fs::create_dir_all(&home).expect("home directory should be created");
    fs::create_dir_all(&dotfiles).expect("dotfiles directory should be created");
    let target = dotfiles.join("bashrc");
    let link = home.join(".bashrc");
    fs::write(&target, "old\n").expect("target should be written");
    symlink(&target, &link).expect("symlink should be created");

    write_text_atomic(&link, "new\n").expect("symlink target should be replaced atomically");

    assert!(
        fs::symlink_metadata(&link)
            .expect("link metadata should be readable")
            .file_type()
            .is_symlink(),
        "shell integration writes should preserve symlinked startup files"
    );
    assert_eq!(
        fs::read_to_string(&target).expect("target should be readable"),
        "new\n"
    );
    assert_eq!(
        fs::read_to_string(&link).expect("link should still resolve"),
        "new\n"
    );

    fs::remove_dir_all(root).expect("temp directory should be removed");
}

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

    let root = temp_path("relative-symlink");
    let home = root.join("home");
    fs::create_dir_all(&home).expect("home directory should be created");
    let target = home.join("actual-zshrc");
    let link = home.join(".zshrc");
    fs::write(&target, "old\n").expect("target should be written");
    symlink("actual-zshrc", &link).expect("relative symlink should be created");

    assert_eq!(
        resolve_write_path(&link).expect("relative symlink should resolve"),
        target
    );

    fs::remove_dir_all(root).expect("temp directory should be removed");
}

#[test]
fn remove_managed_blocks_preserves_user_content() {
    let block = managed_script(Shell::Bash, "command elio");
    let existing = format!("alias ll='ls -la'\n\n{block}\nexport EDITOR=nvim\n");

    let updated = remove_managed_blocks(&existing)
        .expect("managed block should be removable")
        .expect("managed block should be found");

    assert_eq!(updated, "alias ll='ls -la'\n\nexport EDITOR=nvim\n");
}

#[test]
fn remove_managed_blocks_removes_duplicate_blocks() {
    let block = managed_script(Shell::Bash, "command elio");
    let existing = format!("{block}\nexport EDITOR=nvim\n\n{block}");

    let updated = remove_managed_blocks(&existing)
        .expect("managed blocks should be removable")
        .expect("managed blocks should be found");

    assert_eq!(updated, "export EDITOR=nvim\n");
}

#[test]
fn remove_managed_blocks_rejects_unclosed_block() {
    let existing = format!("before\n{MANAGED_START}\nfunction elio\n");

    let error = remove_managed_blocks(&existing)
        .expect_err("unclosed managed block should return an error");

    assert!(
        error
            .to_string()
            .contains("start marker without end marker")
    );
}

#[test]
fn upsert_managed_block_replaces_existing_block() {
    let old = format!("{MANAGED_START}\nold\n{MANAGED_END}\n");
    let new = format!("{MANAGED_START}\nnew\n{MANAGED_END}\n");

    let updated = upsert_managed_block(&old, &new).expect("managed block should be replaced");

    assert_eq!(updated.matches(MANAGED_START).count(), 1);
    assert!(updated.contains("new"));
    assert!(!updated.contains("old"));
}

#[test]
fn upsert_managed_block_collapses_duplicate_existing_blocks() {
    let old = format!("{MANAGED_START}\nold\n{MANAGED_END}\n");
    let new = format!("{MANAGED_START}\nnew\n{MANAGED_END}\n");
    let existing = format!("before\n\n{old}\nafter\n\n{old}tail\n");

    let updated = upsert_managed_block(&existing, &new)
        .expect("managed blocks should be replaced and deduplicated");

    assert_eq!(updated.matches(MANAGED_START).count(), 1);
    assert!(updated.contains("new"));
    assert!(updated.contains("before"));
    assert!(updated.contains("after"));
    assert!(updated.contains("tail"));
    assert!(!updated.contains("old"));
}

#[test]
fn upsert_managed_block_rejects_unclosed_block() {
    let existing = format!("{MANAGED_START}\nold\n");
    let new = format!("{MANAGED_START}\nnew\n{MANAGED_END}\n");

    let error =
        upsert_managed_block(&existing, &new).expect_err("unclosed block should return an error");

    assert!(
        error
            .to_string()
            .contains("start marker without end marker")
    );
}