use std::{
fmt, fs,
io::{self, Write},
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shell {
Bash,
Zsh,
Fish,
Pwsh,
Nu,
}
impl Shell {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"bash" => Some(Self::Bash),
"zsh" => Some(Self::Zsh),
"fish" => Some(Self::Fish),
"powershell" | "pwsh" => Some(Self::Pwsh),
"nu" | "nushell" => Some(Self::Nu),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Zsh => "zsh",
Self::Fish => "fish",
Self::Pwsh => "powershell",
Self::Nu => "nushell",
}
}
}
impl fmt::Display for Shell {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
pub fn detect_shell() -> Option<Shell> {
if std::env::var_os("NU_VERSION").is_some() {
return Some(Shell::Nu);
}
if cfg!(windows) {
if std::env::var_os("PSModulePath").is_some() {
return Some(Shell::Pwsh);
}
return None;
}
let shell_var = std::env::var("SHELL").ok()?;
let name = Path::new(&shell_var).file_name()?.to_str()?;
Shell::from_str(name)
}
pub(crate) fn nu_config_dir_default() -> Option<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
return Some(PathBuf::from(xdg).join("nushell"));
}
#[cfg(target_os = "macos")]
{
if let Some(home) = std::env::var_os("HOME") {
return Some(
PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("nushell"),
);
}
None
}
#[cfg(windows)]
{
if let Some(appdata) = std::env::var_os("APPDATA") {
return Some(PathBuf::from(appdata).join("nushell"));
}
return None;
}
#[cfg(not(any(target_os = "macos", windows)))]
{
if let Some(home) = std::env::var_os("HOME") {
return Some(PathBuf::from(home).join(".config").join("nushell"));
}
None
}
}
const SENTINEL: &str = "# tfe-shell-init";
pub const SOURCE_DIRECTIVE_PREFIX: &str = "source:";
pub fn snippet(shell: Shell) -> String {
let template = match shell {
Shell::Bash | Shell::Zsh => include_str!("shell_init/templates/tfe.bash"),
Shell::Fish => include_str!("shell_init/templates/tfe.fish"),
Shell::Pwsh => include_str!("shell_init/templates/tfe.ps1"),
Shell::Nu => include_str!("shell_init/templates/tfe.nu"),
};
format!("\n{}", template.replace("{{SENTINEL}}", SENTINEL))
}
pub fn rc_path_with(
shell: Shell,
home: Option<&Path>,
xdg_config_home: Option<&Path>,
zdotdir: Option<&Path>,
bash_profile: Option<&Path>,
zshenv: Option<&Path>,
nu_config_dir: Option<&Path>,
) -> Option<PathBuf> {
match shell {
Shell::Bash => {
if let Some(bp) = bash_profile {
if bp.exists() {
return Some(bp.to_path_buf());
}
}
home.map(|h| h.join(".bashrc"))
}
Shell::Zsh => {
if let Some(z) = zdotdir {
return Some(z.join(".zshrc"));
}
if let Some(h) = home {
let zshrc = h.join(".zshrc");
if zshrc.exists() {
return Some(zshrc);
}
if let Some(env) = zshenv {
if env.exists() {
return Some(env.to_path_buf());
}
}
return Some(zshrc);
}
None
}
Shell::Fish => {
let config = xdg_config_home
.map(std::path::Path::to_path_buf)
.or_else(|| home.map(|h| h.join(".config")))?;
Some(config.join("fish/functions/tfe.fish"))
}
Shell::Pwsh => {
if let Some(profile) = std::env::var_os("PROFILE") {
return Some(PathBuf::from(profile));
}
home.map(|h| {
if cfg!(windows) {
h.join("Documents")
.join("PowerShell")
.join("Microsoft.PowerShell_profile.ps1")
} else {
h.join(".config")
.join("powershell")
.join("Microsoft.PowerShell_profile.ps1")
}
})
}
Shell::Nu => nu_config_dir.map(|d| d.join("config.nu")),
}
}
fn home() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
fn xdg_config_home() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from)
}
fn zdotdir() -> Option<PathBuf> {
std::env::var_os("ZDOTDIR").map(PathBuf::from)
}
fn bash_profile(home: Option<&Path>) -> Option<PathBuf> {
home.map(|h| h.join(".bash_profile"))
}
fn zshenv(home: Option<&Path>) -> Option<PathBuf> {
home.map(|h| h.join(".zshenv"))
}
fn nu_config_dir() -> Option<PathBuf> {
nu_config_dir_default()
}
pub fn is_installed(path: &Path) -> bool {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
};
if content.contains(SENTINEL) {
return true;
}
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
let window_end = (i + 7).min(lines.len());
if (trimmed == "tfe() {" || trimmed == "function tfe")
&& lines[i..window_end]
.iter()
.any(|l| l.contains("command tfe"))
{
return true;
}
if trimmed.starts_with("def --wrapped tfe")
&& lines[i..window_end].iter().any(|l| l.contains("^tfe"))
{
return true;
}
}
false
}
pub fn install(shell: Shell, rc: &Path) -> io::Result<()> {
if let Some(parent) = rc.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?;
}
}
let mut file = fs::OpenOptions::new().create(true).append(true).open(rc)?;
file.write_all(snippet(shell).as_bytes())?;
file.flush()
}
pub fn emit_source_directive(rc_path: &Path) {
let _ = std::io::stdout()
.write_all(format!("{}{}\n", SOURCE_DIRECTIVE_PREFIX, rc_path.display()).as_bytes());
let _ = std::io::stdout().flush();
}
pub fn auto_install() -> InitOutcome {
let h = home();
auto_install_with(
None,
h.as_deref(),
xdg_config_home().as_deref(),
zdotdir().as_deref(),
bash_profile(h.as_deref()).as_deref(),
zshenv(h.as_deref()).as_deref(),
nu_config_dir().as_deref(),
)
}
pub(crate) fn auto_install_with(
shell: Option<Shell>,
home: Option<&Path>,
xdg_config_home: Option<&Path>,
zdotdir: Option<&Path>,
bash_profile: Option<&Path>,
zshenv: Option<&Path>,
nu_config_dir: Option<&Path>,
) -> InitOutcome {
let shell = match shell.or_else(detect_shell) {
Some(s) => s,
None => return InitOutcome::UnknownShell, };
let candidates: Vec<PathBuf> = match shell {
Shell::Zsh => {
let mut v = Vec::new();
if let Some(z) = zdotdir {
v.push(z.join(".zshrc"));
}
if let Some(h) = home {
v.push(h.join(".zshrc"));
v.push(h.join(".zshenv"));
v.push(h.join(".zprofile"));
}
v
}
Shell::Bash => {
let mut v = Vec::new();
if let Some(h) = home {
v.push(h.join(".bashrc"));
v.push(h.join(".bash_profile"));
v.push(h.join(".profile"));
}
v
}
Shell::Fish => {
let config = xdg_config_home
.map(std::path::Path::to_path_buf)
.or_else(|| home.map(|h| h.join(".config")));
if let Some(c) = config {
vec![c.join("fish/functions/tfe.fish")]
} else {
vec![]
}
}
Shell::Pwsh => {
if let Some(profile) = std::env::var_os("PROFILE") {
vec![PathBuf::from(profile)]
} else if let Some(h) = home {
vec![h
.join(".config")
.join("powershell")
.join("Microsoft.PowerShell_profile.ps1")]
} else {
vec![]
}
}
Shell::Nu => {
if let Some(d) = nu_config_dir {
vec![d.join("config.nu")]
} else {
vec![]
}
}
};
if candidates.iter().any(|p| is_installed(p)) {
return InitOutcome::AlreadyInstalled(
candidates
.into_iter()
.find(|p| is_installed(p))
.unwrap_or_default(),
);
}
install_or_print_to(
Some(shell),
home,
xdg_config_home,
zdotdir,
bash_profile,
zshenv,
nu_config_dir,
)
}
#[derive(Debug, PartialEq, Eq)]
pub enum InitOutcome {
Installed(PathBuf),
AlreadyInstalled(PathBuf),
PrintedToStdout,
UnknownShell,
}
pub fn install_or_print(shell: Option<Shell>) -> InitOutcome {
let h = home();
install_or_print_to(
shell,
h.as_deref(),
xdg_config_home().as_deref(),
zdotdir().as_deref(),
bash_profile(h.as_deref()).as_deref(),
zshenv(h.as_deref()).as_deref(),
nu_config_dir().as_deref(),
)
}
pub(crate) fn install_or_print_to(
shell: Option<Shell>,
home: Option<&Path>,
xdg_config_home: Option<&Path>,
zdotdir: Option<&Path>,
bash_profile: Option<&Path>,
zshenv: Option<&Path>,
nu_config_dir: Option<&Path>,
) -> InitOutcome {
if cfg!(windows) {
if let Some(s) = shell {
if s != Shell::Pwsh && s != Shell::Nu {
eprintln!(
"tfe: on Windows only PowerShell and Nushell are supported.\n\
Use: tfe --init powershell or tfe --init nushell\n\
For WSL (bash/zsh/fish) run tfe --init <shell> inside WSL."
);
return InitOutcome::UnknownShell;
}
}
}
let resolved = if let Some(s) = shell.or_else(detect_shell) {
s
} else {
eprintln!(
"tfe: could not detect shell from $SHELL. \
Re-run with an explicit shell: tfe --init zsh"
);
print!("{}", snippet(Shell::Bash));
return InitOutcome::UnknownShell;
};
let rc = if let Some(p) = rc_path_with(
resolved,
home,
xdg_config_home,
zdotdir,
bash_profile,
zshenv,
nu_config_dir,
) {
p
} else {
eprintln!(
"tfe: could not determine rc file path ($HOME is not set). \
Add the following to your shell config manually:"
);
print!("{}", snippet(resolved));
return InitOutcome::PrintedToStdout;
};
if is_installed(&rc) {
return InitOutcome::AlreadyInstalled(rc);
}
match install(resolved, &rc) {
Ok(()) => InitOutcome::Installed(rc),
Err(e) => {
eprintln!(
"tfe: could not write to {}: {e}\n\
Add the following to your shell config manually:",
rc.display()
);
print!("{}", snippet(resolved));
InitOutcome::PrintedToStdout
}
}
}
#[cfg(test)]
mod tests;