use std::path::PathBuf;
use clap::ValueEnum;
const BASH: &str = include_str!("shells/bash.sh");
const ZSH: &str = include_str!("shells/zsh.sh");
const FISH: &str = include_str!("shells/fish.fish");
const POWERSHELL: &str = include_str!("shells/powershell.ps1");
const NUSHELL: &str = include_str!("shells/nushell.nu");
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Shell {
Bash,
Zsh,
Fish,
Powershell,
#[value(alias = "nu")]
Nushell,
}
impl Shell {
pub fn as_str(self) -> &'static str {
match self {
Shell::Bash => "bash",
Shell::Zsh => "zsh",
Shell::Fish => "fish",
Shell::Powershell => "powershell",
Shell::Nushell => "nushell",
}
}
}
pub fn init_snippet(shell: Shell) -> &'static str {
match shell {
Shell::Bash => BASH,
Shell::Zsh => ZSH,
Shell::Fish => FISH,
Shell::Powershell => POWERSHELL,
Shell::Nushell => NUSHELL,
}
}
pub fn detect_shell(env: impl Fn(&str) -> Option<String>) -> Option<Shell> {
if let Some(shell) = env("SHELL").as_deref().and_then(classify_shell_path) {
return Some(shell);
}
if env("PSModulePath").is_some() {
return Some(Shell::Powershell);
}
None
}
fn classify_shell_path(path: &str) -> Option<Shell> {
let basename = path.rsplit(['/', '\\']).next().unwrap_or(path);
let bare = basename
.split('.')
.next()
.unwrap_or(basename)
.to_ascii_lowercase();
match bare.as_str() {
"bash" => Some(Shell::Bash),
"zsh" => Some(Shell::Zsh),
"fish" => Some(Shell::Fish),
"pwsh" | "powershell" => Some(Shell::Powershell),
"nu" | "nushell" => Some(Shell::Nushell),
_ => None,
}
}
pub fn default_rc_path(shell: Shell) -> Option<PathBuf> {
let home = dirs::home_dir()?;
Some(match shell {
Shell::Bash => home.join(".bashrc"),
Shell::Zsh => home.join(".zshrc"),
Shell::Fish => home.join(".config").join("fish").join("config.fish"),
Shell::Powershell => home
.join("Documents")
.join("PowerShell")
.join("Microsoft.PowerShell_profile.ps1"),
Shell::Nushell => home.join(".config").join("nushell").join("config.nu"),
})
}
pub fn source_line(shell: Shell) -> &'static str {
match shell {
Shell::Bash => "eval \"$(splashboard init bash)\"",
Shell::Zsh => "eval \"$(splashboard init zsh)\"",
Shell::Fish => "splashboard init fish | source",
Shell::Powershell => "Invoke-Expression (& splashboard init powershell | Out-String)",
Shell::Nushell => NUSHELL,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::TEST_ENV_LOCK;
struct EnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
previous: Vec<(&'static str, Option<String>)>,
}
impl EnvGuard {
fn set(pairs: &[(&'static str, Option<&str>)]) -> Self {
let lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let previous = pairs
.iter()
.map(|(key, _)| (*key, std::env::var(key).ok()))
.collect::<Vec<_>>();
pairs.iter().for_each(|(key, value)| unsafe {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
});
Self {
_lock: lock,
previous,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
self.previous.iter().for_each(|(key, value)| unsafe {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
});
}
}
fn env_with(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
move |k: &str| {
pairs
.iter()
.find(|(key, _)| *key == k)
.map(|(_, v)| (*v).to_string())
}
}
#[test]
fn bash_snippet_hooks_prompt_command() {
let s = init_snippet(Shell::Bash);
assert!(s.contains("PROMPT_COMMAND"));
assert!(s.contains("__splashboard_on_chpwd"));
}
#[test]
fn zsh_snippet_uses_chpwd_hook() {
let s = init_snippet(Shell::Zsh);
assert!(s.contains("add-zsh-hook chpwd"));
}
#[test]
fn fish_snippet_watches_pwd_variable() {
let s = init_snippet(Shell::Fish);
assert!(s.contains("--on-variable PWD"));
}
#[test]
fn powershell_snippet_sets_location_changed_action() {
let s = init_snippet(Shell::Powershell);
assert!(s.contains("LocationChangedAction"));
}
#[test]
fn nushell_snippet_appends_pwd_env_change_hook() {
let s = init_snippet(Shell::Nushell);
assert!(s.contains("$env.config.hooks.env_change.PWD"));
assert!(s.contains("$before != null"));
}
#[test]
fn all_snippets_guard_interactivity() {
assert!(init_snippet(Shell::Bash).contains("$-"));
assert!(init_snippet(Shell::Zsh).contains("interactive"));
assert!(init_snippet(Shell::Fish).contains("is-interactive"));
}
#[test]
fn cd_hooks_call_on_cd_flag_not_bare_splash() {
for shell in [
Shell::Bash,
Shell::Zsh,
Shell::Fish,
Shell::Powershell,
Shell::Nushell,
] {
let s = init_snippet(shell);
assert!(
s.contains("splashboard --on-cd"),
"{:?} snippet missing --on-cd",
shell
);
}
}
#[test]
fn detect_maps_known_shells_from_shell_env() {
assert_eq!(
detect_shell(env_with(&[("SHELL", "/bin/zsh")])),
Some(Shell::Zsh)
);
assert_eq!(
detect_shell(env_with(&[("SHELL", "/usr/local/bin/bash")])),
Some(Shell::Bash)
);
assert_eq!(
detect_shell(env_with(&[("SHELL", "/opt/homebrew/bin/fish")])),
Some(Shell::Fish)
);
assert_eq!(
detect_shell(env_with(&[("SHELL", "/usr/local/bin/nu")])),
Some(Shell::Nushell)
);
assert_eq!(
detect_shell(env_with(&[("SHELL", "/opt/homebrew/bin/nushell")])),
Some(Shell::Nushell)
);
}
#[test]
fn detect_falls_back_to_powershell_when_psmodulepath_is_set() {
let env = env_with(&[(
"PSModulePath",
"C:\\Users\\x\\Documents\\PowerShell\\Modules",
)]);
assert_eq!(detect_shell(env), Some(Shell::Powershell));
}
#[test]
fn detect_returns_none_for_unknown_shell() {
assert_eq!(detect_shell(env_with(&[("SHELL", "/bin/ksh")])), None);
assert_eq!(detect_shell(env_with(&[])), None);
}
#[test]
fn detect_handles_windows_style_paths() {
assert_eq!(
detect_shell(env_with(&[(
"SHELL",
"C:\\Program Files\\PowerShell\\7\\pwsh.exe"
)])),
Some(Shell::Powershell)
);
assert_eq!(
detect_shell(env_with(&[("SHELL", "C:\\cygwin64\\bin\\zsh.exe")])),
Some(Shell::Zsh)
);
}
#[test]
fn default_rc_path_for_known_shells_ends_in_expected_file() {
let rc = default_rc_path(Shell::Zsh).unwrap();
assert!(rc.ends_with(".zshrc"));
let rc = default_rc_path(Shell::Bash).unwrap();
assert!(rc.ends_with(".bashrc"));
let rc = default_rc_path(Shell::Fish).unwrap();
assert!(rc.ends_with("config.fish"));
let rc = default_rc_path(Shell::Nushell).unwrap();
assert!(rc.ends_with("config.nu"));
}
#[test]
fn as_str_returns_cli_names_for_every_shell() {
assert_eq!(Shell::Bash.as_str(), "bash");
assert_eq!(Shell::Zsh.as_str(), "zsh");
assert_eq!(Shell::Fish.as_str(), "fish");
assert_eq!(Shell::Powershell.as_str(), "powershell");
assert_eq!(Shell::Nushell.as_str(), "nushell");
}
#[cfg(not(target_os = "windows"))]
#[test]
fn default_rc_path_uses_powershell_profile_under_home() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path().to_string_lossy().into_owned();
let _guard = EnvGuard::set(&[
("HOME", Some(home.as_str())),
("USERPROFILE", Some(home.as_str())),
("HOMEDRIVE", None),
("HOMEPATH", None),
]);
let rc = default_rc_path(Shell::Powershell).unwrap();
assert_eq!(
rc,
temp.path()
.join("Documents")
.join("PowerShell")
.join("Microsoft.PowerShell_profile.ps1")
);
}
#[test]
fn source_line_covers_all_shells() {
for shell in [
Shell::Bash,
Shell::Zsh,
Shell::Fish,
Shell::Powershell,
Shell::Nushell,
] {
let line = source_line(shell);
assert!(line.contains("splashboard"), "{:?}", shell);
}
}
#[test]
fn nushell_source_line_embeds_full_snippet() {
let line = source_line(Shell::Nushell);
assert_eq!(line, init_snippet(Shell::Nushell));
assert!(line.contains("env_change.PWD"));
assert!(line.contains("^splashboard"));
}
}