mod detection;
mod paths;
mod utils;
use std::io::{BufRead, BufReader};
use askama::Template;
pub use detection::{
BypassAlias, DetectedLine, FileDetectionResult, is_shell_integration_line,
is_shell_integration_line_for_uninstall, scan_for_detection_details,
};
pub use paths::{completion_path, config_paths, legacy_fish_conf_d_path};
pub use utils::{current_shell, detect_zsh_compinit, extract_filename_from_path};
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, strum::EnumString)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
#[strum(serialize_all = "kebab-case", ascii_case_insensitive)]
pub enum Shell {
Bash,
Fish,
#[strum(serialize = "nu")]
#[cfg_attr(feature = "cli", clap(name = "nu"))]
Nushell,
Zsh,
#[strum(serialize = "powershell")]
#[cfg_attr(feature = "cli", clap(name = "powershell"))]
PowerShell,
}
impl Shell {
pub fn is_wrapper_based(&self) -> bool {
matches!(self, Self::Fish | Self::Nushell)
}
pub fn config_paths(&self, cmd: &str) -> Result<Vec<std::path::PathBuf>, std::io::Error> {
paths::config_paths(*self, cmd)
}
pub fn legacy_fish_conf_d_path(cmd: &str) -> Result<std::path::PathBuf, std::io::Error> {
paths::legacy_fish_conf_d_path(cmd)
}
pub fn completion_path(&self, cmd: &str) -> Result<std::path::PathBuf, std::io::Error> {
paths::completion_path(*self, cmd)
}
pub fn config_line(&self, cmd: &str) -> String {
match self {
Self::Bash | Self::Zsh => {
format!(
"if command -v {cmd} >/dev/null 2>&1; then eval \"$(command {cmd} config shell init {})\"; fi",
self
)
}
Self::Fish => {
format!(
"if type -q {cmd}; command {cmd} config shell init {} | source; end",
self
)
}
Self::Nushell => {
format!(
"if (which {cmd} | is-not-empty) {{ {cmd} config shell init nu | save --force ($nu.default-config-dir | path join vendor/autoload/{cmd}.nu) }}",
)
}
Self::PowerShell => {
format!(
"if (Get-Command {cmd} -ErrorAction SilentlyContinue) {{ Invoke-Expression (& {cmd} config shell init powershell | Out-String) }}",
)
}
}
}
pub fn is_shell_configured(&self, cmd: &str) -> Result<bool, std::io::Error> {
let config_paths = self.config_paths(cmd)?;
let mut paths_to_check = config_paths;
if matches!(self, Shell::Fish)
&& let Ok(legacy) = Shell::legacy_fish_conf_d_path(cmd)
{
paths_to_check.push(legacy);
}
for path in paths_to_check {
if !path.exists() {
continue;
}
if Self::file_has_integration(&path, cmd)? {
return Ok(true);
}
}
Ok(false)
}
fn file_has_integration(path: &std::path::Path, cmd: &str) -> Result<bool, std::io::Error> {
let file = std::fs::File::open(path)?;
for line in BufReader::new(file).lines() {
if is_shell_integration_line(&line?, cmd) {
return Ok(true);
}
}
Ok(false)
}
}
pub struct ShellInit {
pub shell: Shell,
pub cmd: String,
}
impl ShellInit {
pub fn with_prefix(shell: Shell, cmd: String) -> Self {
Self { shell, cmd }
}
pub fn generate(&self) -> Result<String, askama::Error> {
match self.shell {
Shell::Bash => {
let template = BashTemplate {
shell_name: self.shell.to_string(),
cmd: &self.cmd,
};
template.render()
}
Shell::Zsh => {
let template = ZshTemplate { cmd: &self.cmd };
template.render()
}
Shell::Fish => {
let template = FishTemplate { cmd: &self.cmd };
template.render()
}
Shell::Nushell => {
let template = NushellTemplate { cmd: &self.cmd };
template.render()
}
Shell::PowerShell => {
let template = PowerShellTemplate { cmd: &self.cmd };
template.render()
}
}
}
pub fn generate_fish_wrapper(&self) -> Result<String, askama::Error> {
let template = FishWrapperTemplate { cmd: &self.cmd };
template.render()
}
}
#[derive(Template)]
#[template(path = "bash.sh", escape = "none")]
struct BashTemplate<'a> {
shell_name: String,
cmd: &'a str,
}
#[derive(Template)]
#[template(path = "zsh.zsh", escape = "none")]
struct ZshTemplate<'a> {
cmd: &'a str,
}
#[derive(Template)]
#[template(path = "fish.fish", escape = "none")]
struct FishTemplate<'a> {
cmd: &'a str,
}
#[derive(Template)]
#[template(path = "fish_wrapper.fish", escape = "none")]
struct FishWrapperTemplate<'a> {
cmd: &'a str,
}
#[derive(Template)]
#[template(path = "nushell.nu", escape = "none")]
struct NushellTemplate<'a> {
cmd: &'a str,
}
#[derive(Template)]
#[template(path = "powershell.ps1", escape = "none")]
struct PowerShellTemplate<'a> {
cmd: &'a str,
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn test_shell_from_str() {
assert!(matches!("bash".parse::<Shell>(), Ok(Shell::Bash)));
assert!(matches!("BASH".parse::<Shell>(), Ok(Shell::Bash)));
assert!(matches!("fish".parse::<Shell>(), Ok(Shell::Fish)));
assert!(matches!("zsh".parse::<Shell>(), Ok(Shell::Zsh)));
assert!(matches!(
"powershell".parse::<Shell>(),
Ok(Shell::PowerShell)
));
assert!(matches!(
"POWERSHELL".parse::<Shell>(),
Ok(Shell::PowerShell)
));
assert!("invalid".parse::<Shell>().is_err());
}
#[test]
fn test_shell_display() {
assert_eq!(Shell::Bash.to_string(), "bash");
assert_eq!(Shell::Fish.to_string(), "fish");
assert_eq!(Shell::Zsh.to_string(), "zsh");
assert_eq!(Shell::PowerShell.to_string(), "powershell");
}
#[test]
fn test_shell_config_line() {
insta::assert_snapshot!("config_line_bash", Shell::Bash.config_line("wt"));
insta::assert_snapshot!("config_line_zsh", Shell::Zsh.config_line("wt"));
insta::assert_snapshot!("config_line_fish", Shell::Fish.config_line("wt"));
insta::assert_snapshot!("config_line_nu", Shell::Nushell.config_line("wt"));
insta::assert_snapshot!(
"config_line_powershell",
Shell::PowerShell.config_line("wt")
);
}
#[test]
fn test_config_line_uses_custom_prefix() {
insta::assert_snapshot!("config_line_bash_custom", Shell::Bash.config_line("git-wt"));
insta::assert_snapshot!("config_line_zsh_custom", Shell::Zsh.config_line("git-wt"));
insta::assert_snapshot!("config_line_fish_custom", Shell::Fish.config_line("git-wt"));
insta::assert_snapshot!(
"config_line_nu_custom",
Shell::Nushell.config_line("git-wt")
);
insta::assert_snapshot!(
"config_line_powershell_custom",
Shell::PowerShell.config_line("git-wt")
);
}
#[test]
fn test_shell_init_generate() {
for shell in [
Shell::Bash,
Shell::Zsh,
Shell::Fish,
Shell::Nushell,
Shell::PowerShell,
] {
let init = ShellInit::with_prefix(shell, "wt".to_string());
let output = init.generate().expect("Failed to generate");
insta::assert_snapshot!(format!("init_{shell}"), output);
}
}
#[test]
fn test_shell_config_paths_returns_paths() {
let shells = [
Shell::Bash,
Shell::Zsh,
Shell::Fish,
Shell::Nushell,
Shell::PowerShell,
];
for shell in shells {
let result = shell.config_paths("wt");
assert!(result.is_ok(), "Failed to get config paths for {:?}", shell);
let paths = result.unwrap();
assert!(
!paths.is_empty(),
"No config paths returned for {:?}",
shell
);
}
}
#[test]
fn test_shell_completion_path_returns_path() {
let shells = [
Shell::Bash,
Shell::Zsh,
Shell::Fish,
Shell::Nushell,
Shell::PowerShell,
];
for shell in shells {
let result = shell.completion_path("wt");
assert!(
result.is_ok(),
"Failed to get completion path for {:?}",
shell
);
let path = result.unwrap();
assert!(
!path.as_os_str().is_empty(),
"Empty completion path for {:?}",
shell
);
}
}
#[test]
fn test_shell_config_paths_with_custom_prefix() {
let prefix = "custom-wt";
let fish_paths = Shell::Fish.config_paths(prefix).unwrap();
assert!(
fish_paths[0]
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains("custom-wt.fish")),
"Fish config should include prefix in filename"
);
let bash_paths = Shell::Bash.config_paths(prefix).unwrap();
assert!(
bash_paths[0]
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains(".bashrc")),
"Bash config should be .bashrc"
);
let zsh_paths = Shell::Zsh.config_paths(prefix).unwrap();
assert!(
zsh_paths[0]
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains(".zshrc")),
"Zsh config should be .zshrc"
);
}
#[test]
fn test_shell_completion_path_with_custom_prefix() {
let prefix = "my-prefix";
let bash_path = Shell::Bash.completion_path(prefix).unwrap();
assert!(
bash_path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains("my-prefix")),
"Bash completion should include prefix"
);
let fish_path = Shell::Fish.completion_path(prefix).unwrap();
assert!(
fish_path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains("my-prefix.fish")),
"Fish completion should include prefix in filename"
);
let zsh_path = Shell::Zsh.completion_path(prefix).unwrap();
assert!(
zsh_path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains("_my-prefix")),
"Zsh completion should include underscore prefix"
);
}
#[test]
fn test_shell_init_with_custom_prefix() {
let init = ShellInit::with_prefix(Shell::Bash, "custom".to_string());
insta::assert_snapshot!(init.generate().expect("Should generate with custom prefix"));
}
#[rstest]
fn test_config_line_detected_by_is_shell_integration_line(
#[values(
Shell::Bash,
Shell::Zsh,
Shell::Fish,
Shell::Nushell,
Shell::PowerShell
)]
shell: Shell,
#[values("wt", "git-wt")] prefix: &str,
) {
let line = shell.config_line(prefix);
assert!(
is_shell_integration_line(&line, prefix),
"{shell} config_line({prefix:?}) not detected:\n {line}"
);
}
#[test]
fn test_file_has_integration() {
use std::io::Write;
let temp_dir = tempfile::tempdir().unwrap();
let bashrc = temp_dir.path().join(".bashrc");
let mut file = std::fs::File::create(&bashrc).unwrap();
writeln!(
file,
r#"if command -v wt >/dev/null 2>&1; then eval "$(command wt config shell init bash)"; fi"#
)
.unwrap();
assert!(Shell::file_has_integration(&bashrc, "wt").unwrap());
assert!(!Shell::file_has_integration(&bashrc, "git-wt").unwrap());
let empty_file = temp_dir.path().join(".zshrc");
std::fs::write(&empty_file, "# just a comment\n").unwrap();
assert!(!Shell::file_has_integration(&empty_file, "wt").unwrap());
}
}