use std::io::IsTerminal;
use color_print::cformat;
use worktrunk::config::UserConfig;
use worktrunk::path::format_path_for_display;
use worktrunk::shell::{Shell, current_shell, extract_filename_from_path};
use worktrunk::styling::{
eprintln, format_bash_with_gutter, hint_message, info_message, success_message, warning_message,
};
use crate::commands::configure_shell::{
ConfigAction, UninstallScanResult, handle_configure_shell, prompt_for_install,
scan_shell_configs,
};
pub(crate) fn shell_integration_hint() -> String {
cformat!("To enable automatic cd, run <underline>wt config shell install</>")
}
pub(crate) fn shell_restart_hint() -> &'static str {
"Restart shell to activate shell integration"
}
fn shell_integration_unsupported_shell(shell_path: &str) -> String {
let shell_name = extract_filename_from_path(shell_path).unwrap_or(shell_path);
format!(
"Shell integration not yet supported for {shell_name} (supports bash, zsh, fish, nu, PowerShell)"
)
}
pub(crate) fn git_subcommand_warning() -> String {
cformat!("For automatic cd, invoke directly (with the <underline>-</>): <underline>git-wt</>")
}
pub(crate) fn explicit_path_hint(branch: &str) -> String {
let wraps = crate::binary_name();
cformat!("To change directory, run <underline>{wraps} switch {branch}</>")
}
pub(crate) fn should_show_explicit_path_hint() -> bool {
crate::was_invoked_with_explicit_path()
&& current_shell()
.and_then(|shell| shell.is_shell_configured(&crate::binary_name()).ok())
.unwrap_or(false)
}
pub(crate) fn compute_shell_warning_reason() -> String {
let is_configured = current_shell()
.and_then(|shell| shell.is_shell_configured(&crate::binary_name()).ok())
.unwrap_or(false);
let explicit_path = crate::was_invoked_with_explicit_path();
let invoked = crate::invocation_path();
let wraps = crate::binary_name();
compute_shell_warning_reason_inner(is_configured, explicit_path, &invoked, &wraps)
}
fn compute_shell_warning_reason_inner(
is_configured: bool,
explicit_path: bool,
invoked: &str,
wraps: &str,
) -> String {
if is_configured {
if explicit_path {
let invoked_name = std::path::Path::new(invoked)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(invoked);
#[cfg(windows)]
{
let invoked_lower = invoked_name.to_lowercase();
let wraps_lower = wraps.to_lowercase();
if invoked_lower == format!("{wraps_lower}.exe") {
return cformat!(
"ran <bold>{invoked_name}</>; use <bold>{wraps}</> (without .exe) for auto-cd"
);
}
}
if invoked_name == wraps {
cformat!("ran <bold>{invoked}</>; shell integration wraps <bold>{wraps}</>")
} else {
cformat!("ran <bold>{invoked_name}</>; shell integration wraps <bold>{wraps}</>")
}
} else {
"shell requires restart".to_string()
}
} else {
"shell integration not installed".to_string()
}
}
pub fn print_skipped_shells(
skipped: &[(worktrunk::shell::Shell, std::path::PathBuf)],
) -> anyhow::Result<()> {
for (shell, path) in skipped {
let path = format_path_for_display(path);
eprintln!(
"{}",
hint_message(cformat!(
"Skipped <underline>{shell}</>; <underline>{path}</> not found"
))
);
}
Ok(())
}
fn shell_extension_label(shell: Shell) -> &'static str {
if matches!(shell, Shell::Bash | Shell::Zsh) {
"shell extension & completions"
} else {
"shell extension"
}
}
fn print_config_action_result(action: &ConfigAction, message: String) {
match action {
ConfigAction::Added | ConfigAction::Created => {
eprintln!("{}", success_message(message));
}
ConfigAction::AlreadyExists => {
eprintln!("{}", info_message(message));
}
ConfigAction::WouldAdd | ConfigAction::WouldCreate => {
unreachable!("Preview actions handled by confirmation prompt")
}
}
}
pub fn print_shell_install_result(
scan_result: &crate::commands::configure_shell::ScanResult,
) -> anyhow::Result<()> {
let shells_configured_count = scan_result
.configured
.iter()
.filter(|ext_result| {
let ext_changed = !matches!(ext_result.action, ConfigAction::AlreadyExists);
let comp_changed = scan_result
.completion_results
.iter()
.find(|c| c.shell == ext_result.shell)
.is_some_and(|c| !matches!(c.action, ConfigAction::AlreadyExists));
ext_changed || comp_changed
})
.count();
for result in &scan_result.configured {
let shell = result.shell;
let path = format_path_for_display(&result.path);
let what = shell_extension_label(shell);
let message = cformat!(
"{} {what} for <bold>{shell}</> @ <bold>{path}</>",
result.action.description()
);
print_config_action_result(&result.action, message);
if matches!(shell, Shell::Nushell) && !matches!(result.action, ConfigAction::AlreadyExists)
{
eprintln!("{}", hint_message("Nushell support is experimental"));
}
if let Some(comp_result) = scan_result
.completion_results
.iter()
.find(|r| r.shell == shell)
{
let comp_path = format_path_for_display(&comp_result.path);
let comp_message = cformat!(
"{} completions for <bold>{shell}</> @ <bold>{comp_path}</>",
comp_result.action.description()
);
print_config_action_result(&comp_result.action, comp_message);
}
}
for legacy_path in &scan_result.legacy_cleanups {
let old_path = format_path_for_display(legacy_path);
let new_path = scan_result
.configured
.iter()
.find(|r| r.shell == Shell::Fish)
.map(|r| format_path_for_display(&r.path))
.unwrap_or_else(|| "~/.config/fish/functions/".to_string());
eprintln!(
"{}",
info_message(cformat!(
"Removed <bold>{old_path}</> (deprecated; now using <bold>{new_path}</>)"
))
);
}
print_skipped_shells(&scan_result.skipped)?;
if shells_configured_count > 0 {
eprintln!();
let plural = if shells_configured_count == 1 {
""
} else {
"s"
};
eprintln!(
"{}",
success_message(format!(
"Configured {shells_configured_count} shell{plural}"
))
);
} else {
eprintln!("{}", success_message("All shells already configured"));
}
if scan_result.zsh_needs_compinit {
eprintln!(
"{}",
warning_message("Completions require compinit; add to ~/.zshrc before the wt line:",)
);
eprintln!(
"{}",
format_bash_with_gutter("autoload -Uz compinit && compinit")
);
}
if shells_configured_count > 0 {
let current_shell = std::env::var("SHELL")
.ok()
.and_then(|s| extract_filename_from_path(&s).map(String::from));
let current_shell_result = current_shell.as_ref().and_then(|shell_name| {
scan_result
.configured
.iter()
.filter(|r| !matches!(r.action, ConfigAction::AlreadyExists))
.find(|r| r.shell.to_string().eq_ignore_ascii_case(shell_name))
});
if current_shell_result.is_some() {
eprintln!("{}", hint_message(shell_restart_hint()));
}
}
Ok(())
}
pub fn prompt_shell_integration(
config: &mut UserConfig,
binary_name: &str,
skip_prompt: bool,
) -> anyhow::Result<bool> {
if crate::is_git_subcommand() {
return Ok(false);
}
let is_tty = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
let shell_env = std::env::var("SHELL").ok();
if current_shell().is_none() {
let msg = match &shell_env {
Some(path) => shell_integration_unsupported_shell(path),
None => shell_integration_hint(),
};
eprintln!("{}", hint_message(msg));
return Ok(false);
};
let scan = scan_shell_configs(None, true, binary_name)
.map_err(|e| anyhow::anyhow!("Failed to scan shell configs: {e}"))?;
if scan.configured.is_empty() {
eprintln!("{}", hint_message(shell_integration_hint()));
return Ok(false);
}
let current_shell_installed = scan
.configured
.iter()
.filter(|r| Some(r.shell) == current_shell())
.any(|r| matches!(r.action, ConfigAction::AlreadyExists));
if current_shell_installed {
if !crate::was_invoked_with_explicit_path() {
eprintln!("{}", hint_message(shell_restart_hint()));
}
return Ok(false);
}
if config.skip_shell_integration_prompt || !is_tty || skip_prompt {
eprintln!("{}", hint_message(shell_integration_hint()));
return Ok(false);
}
let confirmed = prompt_for_install(
&scan.configured,
&scan.completion_results,
binary_name,
"Install shell integration?",
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
if !confirmed {
let _ = config.set_skip_shell_integration_prompt(None);
eprintln!("{}", hint_message(shell_integration_hint()));
return Ok(false);
}
let install_result = handle_configure_shell(None, true, false, binary_name.to_string())
.map_err(|e| anyhow::anyhow!("Failed to configure shell integration: {e}"))?;
print_shell_install_result(&install_result)?;
Ok(true)
}
pub fn print_shell_uninstall_result(scan_result: &UninstallScanResult, explicit_shell: bool) {
let mut shells: Vec<_> = scan_result.results.iter().map(|r| r.shell).collect();
shells.sort_by_key(|s| s.to_string());
shells.dedup();
let shell_count = shells.len();
let completion_count = scan_result.completion_results.len();
let total_changes = shell_count + completion_count;
for result in &scan_result.results {
let shell = result.shell;
let path = format_path_for_display(&result.path);
let what = shell_extension_label(shell);
eprintln!(
"{}",
success_message(cformat!(
"{} {what} for <bold>{shell}</> @ <bold>{path}</>",
result.action.description(),
))
);
}
for result in &scan_result.completion_results {
let shell = result.shell;
let path = format_path_for_display(&result.path);
eprintln!(
"{}",
success_message(cformat!(
"{} completions for <bold>{shell}</> @ <bold>{path}</>",
result.action.description(),
))
);
}
for (shell, path) in &scan_result.not_found {
let path = format_path_for_display(path);
let what = shell_extension_label(*shell);
if explicit_shell {
eprintln!("{}", warning_message(format!("No {what} found in {path}")));
} else {
eprintln!(
"{}",
hint_message(cformat!("No <underline>{shell}</> {what} in {path}"))
);
}
}
for (shell, path) in &scan_result.completion_not_found {
let shell_was_removed = scan_result.results.iter().any(|r| r.shell == *shell);
if shell_was_removed {
continue; }
let path = format_path_for_display(path);
if explicit_shell {
eprintln!(
"{}",
warning_message(format!("No completions found in {path}"))
);
} else {
eprintln!(
"{}",
hint_message(cformat!("No <underline>{shell}</> completions in {path}"))
);
}
}
let all_not_found = scan_result.not_found.len() + scan_result.completion_not_found.len();
if total_changes == 0 {
if all_not_found == 0 {
eprintln!();
eprintln!("{}", hint_message("No shell integration found to remove"));
}
return;
}
eprintln!();
let plural = if shell_count == 1 { "" } else { "s" };
eprintln!(
"{}",
success_message(format!(
"Removed integration from {shell_count} shell{plural}"
))
);
let current_shell = std::env::var("SHELL")
.ok()
.and_then(|s| extract_filename_from_path(&s).map(String::from));
let current_shell_affected = current_shell.as_ref().is_some_and(|shell_name| {
scan_result
.results
.iter()
.any(|r| r.shell.to_string().eq_ignore_ascii_case(shell_name))
});
if current_shell_affected {
eprintln!("{}", hint_message("Restart shell to complete uninstall"));
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
#[test]
fn test_shell_integration_hint() {
let hint = shell_integration_hint();
assert_snapshot!(hint, @"To enable automatic cd, run [4mwt config shell install[24m");
}
#[test]
fn test_git_subcommand_warning() {
let warning = git_subcommand_warning();
assert_snapshot!(warning, @"For automatic cd, invoke directly (with the [4m-[24m): [4mgit-wt[24m");
}
#[test]
fn test_compute_shell_warning_reason() {
let reason = compute_shell_warning_reason_inner(false, false, "wt", "wt");
assert_eq!(reason, "shell integration not installed");
let reason = compute_shell_warning_reason_inner(true, true, "./target/debug/wt", "wt");
assert_snapshot!(reason, @"ran [1m./target/debug/wt[22m; shell integration wraps [1mwt[22m");
let reason = compute_shell_warning_reason_inner(true, true, "/usr/local/bin/git-wt", "wt");
assert_snapshot!(reason, @"ran [1mgit-wt[22m; shell integration wraps [1mwt[22m");
let reason = compute_shell_warning_reason_inner(true, false, "wt", "wt");
assert_eq!(reason, "shell requires restart");
}
#[test]
#[cfg(windows)]
fn test_compute_shell_warning_reason_windows() {
let reason = compute_shell_warning_reason_inner(
true,
true,
r"C:\Users\user\AppData\Local\Microsoft\WinGet\Packages\git-wt.exe",
"git-wt",
);
assert!(!reason.contains(r"C:\Users")); assert!(reason.contains("git-wt.exe"), "{reason}");
assert!(reason.contains("without .exe"), "{reason}");
let reason = compute_shell_warning_reason_inner(true, true, r"C:\path\to\WT.EXE", "wt");
assert!(
reason.contains("WT.EXE") || reason.contains(".exe"),
"{reason}"
);
}
#[test]
fn test_explicit_path_hint_format() {
let hint = explicit_path_hint("feature-branch");
let mut settings = insta::Settings::clone_current();
settings.add_filter(r"wt-[0-9a-f]+", "wt");
settings.bind(|| {
assert_snapshot!(hint, @"To change directory, run [4mwt switch feature-branch[24m");
});
}
}