onpath 0.2.0

Get your tools on the PATH — cross-shell, cross-platform, zero fuss
Documentation
mod bash;
pub(crate) mod fish;
mod nushell;
mod posix;
mod powershell;
mod tcsh;
mod xonsh;
mod zsh;

use std::path::{Path, PathBuf};

use crate::config::Position;
use crate::context::SystemContext;
pub use crate::shell_kind::ShellKind;

/// Defines how a shell manages PATH via config files and env scripts.
pub trait Shell: Send + Sync {
    /// Discriminant for matching.
    fn kind(&self) -> ShellKind;

    /// File extension for the env script (e.g., `""`, `".fish"`, `".nu"`).
    fn env_extension(&self) -> &'static str;

    /// Generate the content of a self-guarding env script that adds `dir` to PATH.
    fn env_script(&self, dir: &Path, position: Position) -> String;

    /// Generate the source/import line to add to RC files.
    fn source_line(&self, env_script_path: &Path) -> String;

    /// All RC file candidates for this shell (for detection and modification).
    fn rc_candidates(&self, ctx: &SystemContext) -> Vec<PathBuf>;

    /// The primary RC file to create if none of the candidates exist.
    fn primary_rc(&self, ctx: &SystemContext) -> PathBuf;
}

/// Generate a POSIX-compatible self-guarding env script.
///
/// Used by Bash, Zsh, and POSIX `sh`, which all share the same env script format.
pub(crate) fn posix_env_script(dir: &Path, position: Position) -> String {
    let dir = dir.display();
    let path_expr = match position {
        Position::Prepend => format!("\"{dir}:$PATH\""),
        Position::Append => format!("\"$PATH:{dir}\""),
    };
    format!(
        r#"#!/bin/sh
# Generated by onpath. Do not edit.
case ":${{PATH}}:" in
    *:"{dir}":*)
        ;;
    *)
        export PATH={path_expr}
        ;;
esac
"#
    )
}

/// Generate a POSIX-compatible source line (`. "path"`).
///
/// Used by Bash, Zsh, and POSIX `sh`.
pub(crate) fn posix_source_line(env_script_path: &Path) -> String {
    format!(". \"{}\"", env_script_path.display())
}

/// Returns all supported shell implementations.
pub fn all_shells() -> Vec<Box<dyn Shell>> {
    vec![
        Box::new(posix::Posix),
        Box::new(bash::Bash),
        Box::new(zsh::Zsh),
        Box::new(fish::Fish),
        Box::new(nushell::Nushell),
        Box::new(powershell::PowerShell),
        Box::new(tcsh::Tcsh),
        Box::new(xonsh::Xonsh),
    ]
}

/// Returns shells that appear to be available on this system.
///
/// A shell is "available" if any of its RC file candidates exist,
/// or if it's the user's default shell (from `$SHELL`).
pub fn detect_shells(ctx: &SystemContext) -> Vec<Box<dyn Shell>> {
    let user_shell = ctx.user_shell_name();

    all_shells()
        .into_iter()
        .filter(|shell| {
            // Always include POSIX sh on non-Windows
            if shell.kind() == ShellKind::Posix {
                return cfg!(not(windows));
            }

            // Check if any RC file exists
            let has_rc_files = shell.rc_candidates(ctx).iter().any(|p| p.exists());

            // Check if this is the user's default shell
            let is_user_shell = user_shell.is_some_and(|s| {
                matches!(
                    (s, shell.kind()),
                    ("bash", ShellKind::Bash)
                        | ("zsh", ShellKind::Zsh)
                        | ("fish", ShellKind::Fish)
                        | ("nu" | "nushell", ShellKind::Nushell)
                        | ("pwsh" | "powershell", ShellKind::PowerShell)
                        | ("tcsh" | "csh", ShellKind::Tcsh)
                        | ("xonsh", ShellKind::Xonsh)
                )
            });

            has_rc_files || is_user_shell
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use super::*;

    #[cfg(not(windows))]
    #[test]
    fn detect_shells_always_includes_posix() {
        let ctx = SystemContext::with_home(PathBuf::from("/nonexistent/home"));
        let shells = detect_shells(&ctx);
        let kinds: Vec<ShellKind> = shells.iter().map(|s| s.kind()).collect();
        assert!(kinds.contains(&ShellKind::Posix));
    }

    #[test]
    fn detect_shells_includes_user_shell() {
        let mut env = HashMap::new();
        env.insert("SHELL".to_owned(), "/usr/bin/fish".to_owned());
        let ctx = SystemContext::with_home_and_env(PathBuf::from("/nonexistent/home"), env);
        let shells = detect_shells(&ctx);
        let kinds: Vec<ShellKind> = shells.iter().map(|s| s.kind()).collect();
        assert!(kinds.contains(&ShellKind::Fish));
    }

    #[test]
    fn detect_shells_includes_shell_with_existing_rc() {
        let tmp = tempfile::tempdir().unwrap();
        let home = tmp.path().to_owned();
        // Create a .bashrc file so bash is detected
        std::fs::write(home.join(".bashrc"), "").unwrap();
        let ctx = SystemContext::with_home(home);
        let shells = detect_shells(&ctx);
        let kinds: Vec<ShellKind> = shells.iter().map(|s| s.kind()).collect();
        assert!(kinds.contains(&ShellKind::Bash));
    }

    #[cfg(not(windows))]
    #[test]
    fn detect_shells_empty_home_returns_only_posix() {
        let ctx = SystemContext::with_home(PathBuf::from("/nonexistent/home"));
        let shells = detect_shells(&ctx);
        let kinds: Vec<ShellKind> = shells.iter().map(|s| s.kind()).collect();
        assert_eq!(kinds, vec![ShellKind::Posix]);
    }

    #[test]
    fn all_shells_returns_all_eight() {
        assert_eq!(all_shells().len(), 8);
    }
}