use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Shell {
Bash,
Zsh,
Fish,
Unknown(String),
}
#[must_use]
pub(crate) fn detect_shell() -> Shell {
let shell = match std::env::var("SHELL") {
Ok(val) => val,
Err(_) => return Shell::Unknown(String::new()),
};
let name = std::path::Path::new(&shell)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("")
.to_lowercase();
match name.as_str() {
"bash" => Shell::Bash,
"zsh" => Shell::Zsh,
"fish" => Shell::Fish,
_ => Shell::Unknown(shell),
}
}
#[must_use]
pub(crate) fn shell_config_file(shell: &Shell) -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
let home_path = PathBuf::from(&home);
match shell {
Shell::Bash => {
let bashrc = home_path.join(".bashrc");
#[cfg(target_os = "macos")]
{
if bashrc.exists() {
Some(bashrc)
} else {
Some(home_path.join(".bash_profile"))
}
}
#[cfg(not(target_os = "macos"))]
{
Some(bashrc)
}
}
Shell::Zsh => Some(home_path.join(".zshrc")),
Shell::Fish => Some(home_path.join(".config").join("fish").join("config.fish")),
Shell::Unknown(_) => None,
}
}
#[must_use]
pub(crate) fn cargo_bin_dir() -> PathBuf {
if let Ok(cargo_home) = std::env::var("CARGO_HOME") {
PathBuf::from(cargo_home).join("bin")
} else {
let home = std::env::var("HOME").expect("HOME must be set");
PathBuf::from(home).join(".cargo").join("bin")
}
}
#[must_use]
pub(crate) fn path_contains_cargo_bin() -> bool {
let cargo_bin = cargo_bin_dir();
let path = match std::env::var("PATH") {
Ok(p) => p,
Err(_) => return false,
};
path.split(':')
.any(|component| std::path::Path::new(component) == cargo_bin)
}
#[must_use]
pub(crate) fn config_has_cargo_bin_path(shell: &Shell) -> bool {
let config_path = match shell_config_file(shell) {
Some(p) => p,
None => return false,
};
if !config_path.exists() {
return false;
}
let contents = match std::fs::read_to_string(&config_path) {
Ok(c) => c,
Err(_) => return false,
};
let cargo_bin_str = cargo_bin_dir().to_string_lossy().to_string();
for line in contents.lines() {
let trimmed = line.trim();
if (trimmed.starts_with("export PATH=") || trimmed.starts_with("PATH="))
&& trimmed.contains(&cargo_bin_str)
{
return true;
}
}
false
}
pub(crate) fn ensure_path_in_shell_config(shell: &Shell) -> Result<bool, anyhow::Error> {
let config_path = shell_config_file(shell).ok_or_else(|| {
anyhow::anyhow!("Cannot determine shell config file path for shell: {shell:?}")
})?;
let cargo_bin_str = cargo_bin_dir().to_string_lossy().to_string();
let mut content = String::new();
if config_path.exists() {
content = std::fs::read_to_string(&config_path)?;
}
for line in content.lines() {
let trimmed = line.trim();
if (trimmed.starts_with("export PATH=") || trimmed.starts_with("PATH="))
&& trimmed.contains(&cargo_bin_str)
{
return Ok(false);
}
}
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let export_line = "export PATH=\"$HOME/.cargo/bin:$PATH\"\n".to_string();
std::fs::write(&config_path, content + &export_line)?;
Ok(true)
}
pub(crate) fn print_post_init_hint(shell: &Shell, was_modified: bool) {
let config_path = shell_config_file(shell);
if was_modified {
if let Some(ref path) = config_path {
println!("Added ~/.cargo/bin to PATH in {}", path.display());
println!(
" Run `source {}` or restart your shell to apply the change.",
path.display()
);
} else {
println!("Added ~/.cargo/bin to PATH in your shell configuration.");
println!(" Restart your shell to apply the change.");
}
} else if path_contains_cargo_bin() {
println!("~/.cargo/bin is already in your PATH — nothing to do.");
} else if config_has_cargo_bin_path(shell) {
if let Some(ref path) = config_path {
println!(
"~/.cargo/bin is configured in {} but not yet in your current PATH.",
path.display()
);
println!(
" Run `source {}` or restart your shell to apply the change.",
path.display()
);
} else {
println!("~/.cargo/bin is configured but not yet in your current PATH.");
println!(" Restart your shell to apply the change.");
}
} else {
println!("~/.cargo/bin is already configured — everything looks good.");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_shell_returns_some_variant() {
let shell = detect_shell();
match shell {
Shell::Bash | Shell::Zsh | Shell::Fish | Shell::Unknown(_) => {}
}
}
#[test]
fn test_shell_config_file_bash_ends_with_bashrc_or_profile() {
if let Some(path) = shell_config_file(&Shell::Bash) {
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
assert!(
name == ".bashrc" || name == ".bash_profile",
"expected .bashrc or .bash_profile, got {name}"
);
}
}
#[test]
fn test_shell_config_file_zsh_ends_with_zshrc() {
if let Some(path) = shell_config_file(&Shell::Zsh) {
assert_eq!(path.file_name().and_then(|s| s.to_str()), Some(".zshrc"));
}
}
#[test]
fn test_shell_config_file_fish_ends_with_config_fish() {
if let Some(path) = shell_config_file(&Shell::Fish) {
assert!(path.ends_with("config.fish"));
}
}
#[test]
fn test_shell_config_file_unknown_is_none() {
assert!(shell_config_file(&Shell::Unknown("x".into())).is_none());
}
#[test]
fn test_cargo_bin_dir_ends_with_bin() {
let dir = cargo_bin_dir();
assert_eq!(dir.file_name().and_then(|s| s.to_str()), Some("bin"));
}
#[test]
fn test_path_contains_cargo_bin_does_not_panic() {
let _ = path_contains_cargo_bin();
}
#[test]
fn test_config_has_cargo_bin_path_with_temp_dir() {
assert!(!config_has_cargo_bin_path(&Shell::Zsh));
}
#[test]
fn test_ensure_path_in_shell_config_unknown() {
let result = ensure_path_in_shell_config(&Shell::Unknown("nope".into()));
assert!(result.is_err());
}
}