elio 1.7.0

Snappy, batteries-included terminal file manager with rich previews, inline images, bulk actions, and trash support.
Documentation
#![cfg(unix)]

#[allow(dead_code)]
mod support;

use std::{
    error::Error,
    ffi::OsString,
    fs,
    os::unix::fs::{PermissionsExt, symlink},
    path::{Path, PathBuf},
    process::Command,
};

use support::temp_path;

const FAKE_ELIO: &str = r#"#!/usr/bin/env sh
if [ "$1" = "--cwd-file" ]; then
  if [ "${3-}" = "empty" ]; then
    : > "$2"
    exit 9
  fi
  printf '%s' /tmp > "$2"
  exit 7
fi

if [ "$1" = "--help" ]; then
  printf 'HELP-PASSTHROUGH\n'
  exit 3
fi

if [ "$1" = "shell" ]; then
  printf 'SHELL-PASSTHROUGH\n'
  exit 4
fi

exit 5
"#;

struct TempRoot {
    path: PathBuf,
}

impl TempRoot {
    fn new(label: &str) -> Result<Self, Box<dyn Error>> {
        let path = temp_path(label);
        fs::create_dir_all(&path)?;
        Ok(Self { path })
    }

    fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for TempRoot {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}

struct RuntimeFixture {
    _root: TempRoot,
    fake_dir: PathBuf,
    init_script: PathBuf,
    start_dir: PathBuf,
    home_dir: PathBuf,
    config_home: PathBuf,
    data_home: PathBuf,
    zdotdir: PathBuf,
}

#[derive(Clone, Copy)]
enum ShellSyntax {
    Posix,
    Fish,
    Nu,
}

#[test]
fn generated_bash_function_runs_when_executed() -> Result<(), Box<dyn Error>> {
    run_generated_function("bash", ShellSyntax::Posix)
}

#[test]
fn generated_zsh_function_runs_when_executed() -> Result<(), Box<dyn Error>> {
    run_generated_function("zsh", ShellSyntax::Posix)
}

#[test]
fn generated_fish_function_runs_when_executed() -> Result<(), Box<dyn Error>> {
    run_generated_function("fish", ShellSyntax::Fish)
}

#[test]
fn generated_nu_function_runs_when_executed() -> Result<(), Box<dyn Error>> {
    run_generated_function("nu", ShellSyntax::Nu)
}

fn run_generated_function(shell: &str, syntax: ShellSyntax) -> Result<(), Box<dyn Error>> {
    if !shell_available(shell) {
        return Ok(());
    }

    let fixture = runtime_fixture(shell)?;
    let runtime_script = match syntax {
        ShellSyntax::Posix => posix_runtime_script(&fixture.init_script, &fixture.start_dir),
        ShellSyntax::Fish => fish_runtime_script(&fixture.init_script, &fixture.start_dir),
        ShellSyntax::Nu => nu_runtime_script(&fixture.init_script, &fixture.start_dir),
    };

    let mut command = Command::new(shell);
    if matches!(syntax, ShellSyntax::Fish) {
        command.arg("--no-config");
    }
    if matches!(syntax, ShellSyntax::Nu) {
        command.arg("--no-config-file");
    }
    command.arg("-c").arg(runtime_script);
    configure_runtime_environment(&mut command, &fixture)?;
    let output = command.output()?;

    assert_runtime_output(output, &fixture.start_dir);
    Ok(())
}

fn runtime_fixture(shell: &str) -> Result<RuntimeFixture, Box<dyn Error>> {
    let root = TempRoot::new(&format!("shell-runtime-{shell}"))?;
    let gen_dir = root.path().join("gen-bin");
    let fake_dir = root.path().join("fake-bin");
    let start_dir = root.path().join("start");
    let home_dir = root.path().join("home");
    let config_home = root.path().join("config");
    let data_home = root.path().join("data");
    let zdotdir = root.path().join("zdotdir");
    fs::create_dir_all(&gen_dir)?;
    fs::create_dir_all(&fake_dir)?;
    fs::create_dir_all(&start_dir)?;
    fs::create_dir_all(&home_dir)?;
    fs::create_dir_all(&config_home)?;
    fs::create_dir_all(&data_home)?;
    fs::create_dir_all(&zdotdir)?;

    symlink(env!("CARGO_BIN_EXE_elio"), gen_dir.join("elio"))?;
    write_fake_elio(&fake_dir.join("elio"))?;

    let output = Command::new("elio")
        .args(["shell", "init", shell])
        .env("PATH", path_with_prefix(&gen_dir)?)
        .output()?;
    assert!(
        output.status.success(),
        "failed to generate {shell} init script\nstderr:\n{}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        output.stderr.is_empty(),
        "unexpected stderr while generating {shell} init script:\n{}",
        String::from_utf8_lossy(&output.stderr)
    );

    let script = String::from_utf8(output.stdout)?;
    let expected_path_call = if shell == "nu" {
        r#"run-external "elio""#
    } else {
        "command elio"
    };
    assert!(
        script.contains(expected_path_call),
        "official-install {shell} init script should call elio from PATH:\n{script}"
    );
    assert!(
        !script.contains(env!("CARGO_BIN_EXE_elio")),
        "official-install {shell} init script should not contain the test binary path:\n{script}"
    );
    assert!(
        !script.contains("target/debug/elio"),
        "official-install {shell} init script should not contain target/debug/elio:\n{script}"
    );

    let init_script = root.path().join(format!("init.{shell}"));
    fs::write(&init_script, script)?;

    Ok(RuntimeFixture {
        _root: root,
        fake_dir,
        init_script,
        start_dir,
        home_dir,
        config_home,
        data_home,
        zdotdir,
    })
}

fn configure_runtime_environment(
    command: &mut Command,
    fixture: &RuntimeFixture,
) -> Result<(), Box<dyn Error>> {
    command
        .env("PATH", path_with_prefix(&fixture.fake_dir)?)
        .env("HOME", &fixture.home_dir)
        .env("XDG_CONFIG_HOME", &fixture.config_home)
        .env("XDG_DATA_HOME", &fixture.data_home)
        .env("ZDOTDIR", &fixture.zdotdir)
        .env_remove("BASH_ENV")
        .env_remove("ENV");

    Ok(())
}

fn write_fake_elio(path: &Path) -> Result<(), Box<dyn Error>> {
    fs::write(path, FAKE_ELIO)?;
    let mut permissions = fs::metadata(path)?.permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(path, permissions)?;
    Ok(())
}

fn posix_runtime_script(init_script: &Path, start_dir: &Path) -> String {
    format!(
        r#"source {}
mkdir -p {}
cd {}

elio
printf 'cwd=%s code=%s\n' "$PWD" "$?"

cd {}
elio empty
printf 'empty_cwd=%s empty_code=%s\n' "$PWD" "$?"

elio --help
printf 'help_code=%s\n' "$?"

elio shell status
printf 'shell_code=%s\n' "$?"
"#,
        shell_quote(init_script),
        shell_quote(start_dir),
        shell_quote(start_dir),
        shell_quote(start_dir),
    )
}

fn fish_runtime_script(init_script: &Path, start_dir: &Path) -> String {
    format!(
        r#"source {}
mkdir -p {}
cd {}

elio
printf 'cwd=%s code=%s\n' "$PWD" "$status"

cd {}
elio empty
printf 'empty_cwd=%s empty_code=%s\n' "$PWD" "$status"

elio --help
printf 'help_code=%s\n' "$status"

elio shell status
printf 'shell_code=%s\n' "$status"
"#,
        shell_quote(init_script),
        shell_quote(start_dir),
        shell_quote(start_dir),
        shell_quote(start_dir),
    )
}

fn nu_runtime_script(init_script: &Path, start_dir: &Path) -> String {
    let pipeline_output = start_dir.join("nu-shell-pipeline.txt");
    format!(
        r#"source {}
mkdir {}
cd {}

elio
print $"cwd=($env.PWD) code=($env.LAST_EXIT_CODE)"

cd {}
elio empty
print $"empty_cwd=($env.PWD) empty_code=($env.LAST_EXIT_CODE)"

elio --help
print $"help_code=($env.LAST_EXIT_CODE)"

elio shell status
print $"shell_code=($env.LAST_EXIT_CODE)"

elio shell status | save -f {}
print $"shell_pipeline_code=($env.LAST_EXIT_CODE)"
print $"shell_pipeline=(open --raw {})"
"#,
        nu_quote(init_script),
        nu_quote(start_dir),
        nu_quote(start_dir),
        nu_quote(start_dir),
        nu_quote(&pipeline_output),
        nu_quote(&pipeline_output),
    )
}

fn assert_runtime_output(output: std::process::Output, start_dir: &Path) {
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "shell runtime script failed\nstdout:\n{stdout}\nstderr:\n{stderr}"
    );
    assert!(
        stderr.is_empty(),
        "shell runtime script printed stderr:\n{stderr}\nstdout:\n{stdout}"
    );
    assert!(
        stdout.contains("cwd=/tmp code=7"),
        "normal call should cd to /tmp and return fake status\nstdout:\n{stdout}"
    );
    assert!(
        stdout.contains(&format!("empty_cwd={} empty_code=9", start_dir.display())),
        "empty cwd file should leave the shell in the original directory\nstdout:\n{stdout}"
    );
    let has_nu_pipeline_checks = stdout.contains("shell_pipeline_code=");
    if !has_nu_pipeline_checks {
        assert!(
            stdout.contains("HELP-PASSTHROUGH"),
            "--help should pass through to the real executable\nstdout:\n{stdout}"
        );
    }
    assert!(
        stdout.contains("help_code=3"),
        "--help should preserve the real executable status\nstdout:\n{stdout}"
    );
    if !has_nu_pipeline_checks {
        assert!(
            stdout.contains("SHELL-PASSTHROUGH"),
            "shell subcommands should pass through to the real executable\nstdout:\n{stdout}"
        );
    }
    assert!(
        stdout.contains("shell_code=4"),
        "shell subcommands should preserve the real executable status\nstdout:\n{stdout}"
    );
    if has_nu_pipeline_checks {
        assert!(
            stdout.contains("shell_pipeline_code=4"),
            "Nu shell subcommand pipelines should preserve the real executable status\nstdout:\n{stdout}"
        );
        assert!(
            stdout.contains("shell_pipeline=SHELL-PASSTHROUGH"),
            "Nu shell subcommand pipelines should receive the real executable stdout\nstdout:\n{stdout}"
        );
    }
}

fn shell_available(shell: &str) -> bool {
    Command::new("sh")
        .arg("-c")
        .arg(format!("command -v {shell}"))
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .is_ok_and(|status| status.success())
}

fn path_with_prefix(prefix: &Path) -> Result<OsString, Box<dyn Error>> {
    let mut paths = vec![prefix.to_path_buf()];
    if let Some(path) = std::env::var_os("PATH") {
        paths.extend(std::env::split_paths(&path));
    }
    Ok(std::env::join_paths(paths)?)
}

fn shell_quote(path: &Path) -> String {
    let value = path.to_string_lossy();
    format!("'{}'", value.replace('\'', "'\\''"))
}

fn nu_quote(path: &Path) -> String {
    let value = path.to_string_lossy();
    format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}