elio 1.7.0

Snappy, batteries-included terminal file manager with rich previews, inline images, bulk actions, and trash support.
Documentation
use anyhow::{Context, Result};
use std::{
    env,
    path::{Path, PathBuf},
};

mod install;
mod scripts;

pub(crate) use install::{install, uninstall};
pub(crate) use scripts::{binary_command, init_script};

#[cfg(all(test, unix))]
use install::resolve_write_path;
#[cfg(test)]
use install::{
    MANAGED_END, MANAGED_START, managed_script, remove_managed_blocks, uninstall_reload_command,
    upsert_managed_block, write_text_atomic,
};
#[cfg(test)]
use scripts::nu_string_literal;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Shell {
    Bash,
    Zsh,
    Fish,
    Nu,
}

impl Shell {
    pub(crate) fn parse(value: &str) -> Result<Self, String> {
        match value {
            "bash" => Ok(Self::Bash),
            "zsh" => Ok(Self::Zsh),
            "fish" => Ok(Self::Fish),
            "nu" | "nushell" => Ok(Self::Nu),
            shell => Err(format!(
                "error: unsupported shell '{shell}'

supported shells: bash, zsh, fish, nu"
            )),
        }
    }

    pub(crate) fn name(self) -> &'static str {
        match self {
            Self::Bash => "bash",
            Self::Zsh => "zsh",
            Self::Fish => "fish",
            Self::Nu => "nu",
        }
    }
}

#[derive(Clone, Copy)]
pub(crate) enum ShellIntegrationAction {
    Install,
    Uninstall,
}

impl ShellIntegrationAction {
    fn command(self) -> &'static str {
        match self {
            Self::Install => "install",
            Self::Uninstall => "uninstall",
        }
    }

    #[cfg(any(unix, test))]
    fn active_shell_description(self) -> &'static str {
        match self {
            Self::Install => "installs integration for",
            Self::Uninstall => "removes integration from",
        }
    }
}

#[derive(Debug, Eq, PartialEq)]
pub(crate) enum ShellDetection {
    #[cfg(any(unix, test))]
    Supported(Shell),
    #[cfg(any(unix, test))]
    Unsupported(String),
    Unknown,
}

pub(crate) struct InstallReport {
    pub(crate) shell: Shell,
    pub(crate) path: PathBuf,
    pub(crate) reload_command: String,
}

pub(crate) struct UninstallReport {
    pub(crate) shell: Shell,
    pub(crate) path: PathBuf,
    pub(crate) reload_command: String,
    pub(crate) changed: bool,
    pub(crate) removed_file: bool,
}

pub(crate) fn detect_shell(action: ShellIntegrationAction) -> Result<Shell> {
    match detect_parent_shell() {
        #[cfg(any(unix, test))]
        ShellDetection::Supported(shell) => Ok(shell),
        #[cfg(any(unix, test))]
        ShellDetection::Unsupported(shell) => Err(anyhow::anyhow!(
            unsupported_active_shell_message(action, &shell)
        )),
        ShellDetection::Unknown => detect_shell_from_environment(action),
    }
}

fn detect_shell_from_environment(action: ShellIntegrationAction) -> Result<Shell> {
    let shell = env::var("SHELL").with_context(|| {
        format!(
            "error: could not detect your shell from the parent process or $SHELL\n\n{}",
            explicit_shell_guidance(action)
        )
    })?;
    let name = shell_name_from_command(&shell).unwrap_or(shell);

    Shell::parse(&name).map_err(anyhow::Error::msg)
}

#[cfg(unix)]
fn detect_parent_shell() -> ShellDetection {
    let parent_pid = unsafe { libc::getppid() }.to_string();
    let output = std::process::Command::new("ps")
        .args(["-p", &parent_pid, "-o", "comm="])
        .output()
        .ok();
    let Some(output) = output else {
        return ShellDetection::Unknown;
    };

    if !output.status.success() {
        return ShellDetection::Unknown;
    }

    let Ok(command) = String::from_utf8(output.stdout) else {
        return ShellDetection::Unknown;
    };

    detect_shell_from_command(&command)
}

#[cfg(not(unix))]
fn detect_parent_shell() -> ShellDetection {
    ShellDetection::Unknown
}

#[cfg(any(unix, test))]
fn detect_shell_from_command(command: &str) -> ShellDetection {
    let Some(name) = shell_name_from_command(command) else {
        return ShellDetection::Unknown;
    };

    match Shell::parse(&name) {
        Ok(shell) => ShellDetection::Supported(shell),
        Err(_) if is_known_unsupported_shell(&name) => ShellDetection::Unsupported(name),
        Err(_) => ShellDetection::Unknown,
    }
}

#[cfg(any(unix, test))]
fn is_known_unsupported_shell(name: &str) -> bool {
    matches!(
        name,
        "sh" | "dash"
            | "ash"
            | "ksh"
            | "mksh"
            | "pdksh"
            | "yash"
            | "csh"
            | "tcsh"
            | "xonsh"
            | "elvish"
            | "ion"
            | "oil"
            | "osh"
            | "pwsh"
            | "powershell"
    )
}

#[cfg(any(unix, test))]
fn unsupported_active_shell_message(action: ShellIntegrationAction, shell: &str) -> String {
    format!(
        "error: unsupported active shell '{shell}'\n\n`elio shell {}` {} the active shell.\nsupported shells: bash, zsh, fish, nu\n\n{}",
        action.command(),
        action.active_shell_description(),
        explicit_shell_guidance(action)
    )
}

fn explicit_shell_guidance(action: ShellIntegrationAction) -> String {
    let command = action.command();
    format!(
        "Run one of these explicitly if you want to target another shell:\n  elio shell {command} fish\n  elio shell {command} bash\n  elio shell {command} zsh\n  elio shell {command} nu"
    )
}

fn shell_name_from_command(command: &str) -> Option<String> {
    let command = command.trim();
    if command.is_empty() {
        return None;
    }

    let first_word = command.split_whitespace().next()?;
    let without_login_prefix = first_word.strip_prefix('-').unwrap_or(first_word);
    let file_name = Path::new(without_login_prefix)
        .file_name()
        .and_then(|name| name.to_str())?;
    Some(file_name.strip_prefix('-').unwrap_or(file_name).to_string())
}

#[cfg(test)]
mod tests;