use std::collections::HashSet;
use std::fs::{self, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use anstyle::Style;
use worktrunk::path::format_path_for_display;
use worktrunk::shell::{self, Shell};
use worktrunk::styling::{
INFO_SYMBOL, SUCCESS_SYMBOL, eprint, eprintln, format_bash_with_gutter, format_toml,
format_with_gutter, hint_message, prompt_message, warning_message,
};
use crate::output::prompt::{PromptResponse, prompt_yes_no_preview};
pub struct ConfigureResult {
pub shell: Shell,
pub path: PathBuf,
pub action: ConfigAction,
pub config_line: String,
}
pub struct UninstallResult {
pub shell: Shell,
pub path: PathBuf,
pub action: UninstallAction,
pub superseded_by: Option<PathBuf>,
}
pub struct UninstallScanResult {
pub results: Vec<UninstallResult>,
pub completion_results: Vec<CompletionUninstallResult>,
pub not_found: Vec<(Shell, PathBuf)>,
pub completion_not_found: Vec<(Shell, PathBuf)>,
}
pub struct CompletionUninstallResult {
pub shell: Shell,
pub path: PathBuf,
pub action: UninstallAction,
}
pub struct ScanResult {
pub configured: Vec<ConfigureResult>,
pub completion_results: Vec<CompletionResult>,
pub skipped: Vec<(Shell, PathBuf)>, pub zsh_needs_compinit: bool,
pub legacy_cleanups: Vec<PathBuf>,
}
pub struct CompletionResult {
pub shell: Shell,
pub path: PathBuf,
pub action: ConfigAction,
}
#[derive(Debug, PartialEq)]
pub enum UninstallAction {
Removed,
WouldRemove,
}
impl UninstallAction {
pub fn description(&self) -> &str {
match self {
UninstallAction::Removed => "Removed",
UninstallAction::WouldRemove => "Will remove",
}
}
pub fn symbol(&self) -> &'static str {
match self {
UninstallAction::Removed => SUCCESS_SYMBOL,
UninstallAction::WouldRemove => INFO_SYMBOL,
}
}
}
#[derive(Debug, PartialEq)]
pub enum ConfigAction {
Added,
AlreadyExists,
Created,
WouldAdd,
WouldCreate,
}
impl ConfigAction {
pub fn description(&self) -> &str {
match self {
ConfigAction::Added => "Added",
ConfigAction::AlreadyExists => "Already configured",
ConfigAction::Created => "Created",
ConfigAction::WouldAdd => "Will add",
ConfigAction::WouldCreate => "Will create",
}
}
pub fn symbol(&self) -> &'static str {
match self {
ConfigAction::Added | ConfigAction::Created => SUCCESS_SYMBOL,
ConfigAction::AlreadyExists => INFO_SYMBOL,
ConfigAction::WouldAdd | ConfigAction::WouldCreate => INFO_SYMBOL,
}
}
}
fn is_worktrunk_managed_content(content: &str, cmd: &str) -> bool {
content.contains(&format!("{cmd} config shell init")) && content.contains("| source")
}
fn cleanup_legacy_fish_conf_d(configured: &[ConfigureResult], cmd: &str) -> Vec<PathBuf> {
let mut cleaned = Vec::new();
let fish_targeted = configured.iter().any(|r| r.shell == Shell::Fish);
if !fish_targeted {
return cleaned;
}
let Ok(legacy_path) = Shell::legacy_fish_conf_d_path(cmd) else {
return cleaned;
};
if !legacy_path.exists() {
return cleaned;
}
let Ok(content) = fs::read_to_string(&legacy_path) else {
return cleaned;
};
if !is_worktrunk_managed_content(&content, cmd) {
return cleaned;
}
match fs::remove_file(&legacy_path) {
Ok(()) => {
cleaned.push(legacy_path);
}
Err(e) => {
eprintln!(
"{}",
warning_message(format!(
"Failed to remove deprecated {}: {e}",
format_path_for_display(&legacy_path)
))
);
}
}
cleaned
}
pub fn handle_configure_shell(
shell_filter: Option<Shell>,
skip_confirmation: bool,
dry_run: bool,
cmd: String,
) -> Result<ScanResult, String> {
let preview = scan_shell_configs(shell_filter, true, &cmd)?;
let shells: Vec<_> = preview.configured.iter().map(|r| r.shell).collect();
let completion_preview = process_shell_completions(&shells, true, &cmd)?;
if preview.configured.is_empty() {
return Ok(ScanResult {
configured: preview.configured,
completion_results: completion_preview,
skipped: preview.skipped,
zsh_needs_compinit: false,
legacy_cleanups: Vec::new(),
});
}
let needs_shell_changes = preview
.configured
.iter()
.any(|r| !matches!(r.action, ConfigAction::AlreadyExists));
let needs_completion_changes = completion_preview
.iter()
.any(|r| !matches!(r.action, ConfigAction::AlreadyExists));
if dry_run {
show_install_preview(&preview.configured, &completion_preview, &cmd);
return Ok(ScanResult {
configured: preview.configured,
completion_results: completion_preview,
skipped: preview.skipped,
zsh_needs_compinit: false,
legacy_cleanups: Vec::new(),
});
}
if !needs_shell_changes && !needs_completion_changes {
let legacy_cleanups = cleanup_legacy_fish_conf_d(&preview.configured, &cmd);
return Ok(ScanResult {
configured: preview.configured,
completion_results: completion_preview,
skipped: preview.skipped,
zsh_needs_compinit: false,
legacy_cleanups,
});
}
if !skip_confirmation
&& !prompt_for_install(
&preview.configured,
&completion_preview,
&cmd,
"Install shell integration?",
)?
{
return Err("Cancelled by user".to_string());
}
let result = scan_shell_configs(shell_filter, false, &cmd)?;
let completion_results = process_shell_completions(&shells, false, &cmd)?;
let zsh_was_configured = result
.configured
.iter()
.any(|r| r.shell == Shell::Zsh && !matches!(r.action, ConfigAction::AlreadyExists));
let should_check_compinit = zsh_was_configured
&& (shell_filter == Some(Shell::Zsh)
|| (shell_filter.is_none() && shell::current_shell() == Some(Shell::Zsh)));
let zsh_needs_compinit = should_check_compinit && shell::detect_zsh_compinit() == Some(false);
let legacy_cleanups = cleanup_legacy_fish_conf_d(&result.configured, &cmd);
Ok(ScanResult {
configured: result.configured,
completion_results,
skipped: result.skipped,
zsh_needs_compinit,
legacy_cleanups,
})
}
fn should_auto_configure_powershell() -> bool {
if let Ok(val) = std::env::var("WORKTRUNK_TEST_POWERSHELL_ENV") {
return val == "1";
}
#[cfg(windows)]
{
std::env::var_os("SHELL").is_none()
}
#[cfg(not(windows))]
{
std::env::var_os("PSModulePath").is_some()
}
}
fn is_nushell_available() -> bool {
if let Ok(val) = std::env::var("WORKTRUNK_TEST_NUSHELL_ENV") {
return val == "1";
}
which::which("nu").is_ok()
}
pub fn scan_shell_configs(
shell_filter: Option<Shell>,
dry_run: bool,
cmd: &str,
) -> Result<ScanResult, String> {
let mut default_shells = vec![Shell::Bash, Shell::Zsh, Shell::Fish, Shell::Nushell];
let in_powershell_env = should_auto_configure_powershell();
if in_powershell_env {
default_shells.push(Shell::PowerShell);
}
let nushell_available = is_nushell_available();
let shells = shell_filter.map_or(default_shells, |shell| vec![shell]);
let mut results = Vec::new();
let mut skipped = Vec::new();
for shell in shells {
let paths = shell
.config_paths(cmd)
.map_err(|e| format!("Failed to get config paths for {shell}: {e}"))?;
let target_path = paths.iter().find(|p| p.exists());
let has_config_location = if shell.is_wrapper_based() {
paths.iter().any(|p| p.parent().is_some_and(|d| d.exists())) || target_path.is_some()
} else {
target_path.is_some()
};
let in_detected_shell = (matches!(shell, Shell::PowerShell) && in_powershell_env)
|| (matches!(shell, Shell::Nushell) && nushell_available);
let should_configure = shell_filter.is_some() || has_config_location || in_detected_shell;
let allow_create = shell_filter.is_some() || in_detected_shell;
if should_configure {
let path = target_path.or_else(|| paths.first());
if let Some(path) = path {
match configure_shell_file(shell, path, dry_run, allow_create, cmd) {
Ok(Some(result)) => results.push(result),
Ok(None) => {} Err(e) => {
return Err(format!("Failed to configure {shell}: {e}"));
}
}
}
} else if shell_filter.is_none() {
let skipped_path = if shell.is_wrapper_based() {
paths
.first()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
} else {
paths.first().cloned()
};
if let Some(path) = skipped_path {
skipped.push((shell, path));
}
}
}
if results.is_empty() && shell_filter.is_none() && skipped.is_empty() {
return Err("No shell config files found".to_string());
}
Ok(ScanResult {
configured: results,
completion_results: Vec::new(), skipped,
zsh_needs_compinit: false, legacy_cleanups: Vec::new(), })
}
fn configure_shell_file(
shell: Shell,
path: &Path,
dry_run: bool,
allow_create: bool,
cmd: &str,
) -> Result<Option<ConfigureResult>, String> {
let config_line = shell.config_line(cmd);
if shell.is_wrapper_based() {
let init = shell::ShellInit::with_prefix(shell, cmd.to_string());
let wrapper = if matches!(shell, Shell::Fish) {
init.generate_fish_wrapper()
.map_err(|e| format!("Failed to generate fish wrapper: {e}"))?
} else {
init.generate()
.map_err(|e| format!("Failed to generate nushell wrapper: {e}"))?
};
return configure_wrapper_file(shell, path, &wrapper, dry_run, allow_create, &config_line);
}
if path.exists() {
let file = fs::File::open(path)
.map_err(|e| format!("Failed to read {}: {}", format_path_for_display(path), e))?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line.map_err(|e| {
format!(
"Failed to read line from {}: {}",
format_path_for_display(path),
e
)
})?;
if line.trim() == config_line {
return Ok(Some(ConfigureResult {
shell,
path: path.to_path_buf(),
action: ConfigAction::AlreadyExists,
config_line: config_line.clone(),
}));
}
}
if dry_run {
return Ok(Some(ConfigureResult {
shell,
path: path.to_path_buf(),
action: ConfigAction::WouldAdd,
config_line: config_line.clone(),
}));
}
let mut file = OpenOptions::new().append(true).open(path).map_err(|e| {
format!(
"Failed to open {} for writing: {}",
format_path_for_display(path),
e
)
})?;
write!(file, "\n{}\n", config_line).map_err(|e| {
format!(
"Failed to write to {}: {}",
format_path_for_display(path),
e
)
})?;
Ok(Some(ConfigureResult {
shell,
path: path.to_path_buf(),
action: ConfigAction::Added,
config_line: config_line.clone(),
}))
} else {
if allow_create {
if dry_run {
return Ok(Some(ConfigureResult {
shell,
path: path.to_path_buf(),
action: ConfigAction::WouldCreate,
config_line: config_line.clone(),
}));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!(
"Failed to create directory {}: {}",
format_path_for_display(parent),
e
)
})?;
}
fs::write(path, format!("{}\n", config_line)).map_err(|e| {
format!(
"Failed to write to {}: {}",
format_path_for_display(path),
e
)
})?;
Ok(Some(ConfigureResult {
shell,
path: path.to_path_buf(),
action: ConfigAction::Created,
config_line: config_line.clone(),
}))
} else {
Ok(None)
}
}
}
fn fish_code_lines(source: &str) -> Vec<&str> {
source
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect()
}
fn configure_wrapper_file(
shell: Shell,
path: &Path,
content: &str,
dry_run: bool,
allow_create: bool,
config_line: &str,
) -> Result<Option<ConfigureResult>, String> {
if let Ok(existing_content) = fs::read_to_string(path) {
if fish_code_lines(&existing_content) == fish_code_lines(content) {
return Ok(Some(ConfigureResult {
shell,
path: path.to_path_buf(),
action: ConfigAction::AlreadyExists,
config_line: config_line.to_string(),
}));
}
}
if !allow_create && !path.exists() {
if !path.parent().is_some_and(|p| p.exists()) {
return Ok(None);
}
}
if dry_run {
let action = if path.exists() {
ConfigAction::WouldAdd
} else {
ConfigAction::WouldCreate
};
return Ok(Some(ConfigureResult {
shell,
path: path.to_path_buf(),
action,
config_line: config_line.to_string(),
}));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!(
"Failed to create directory {}: {e}",
format_path_for_display(parent)
)
})?;
}
fs::write(path, format!("{}\n", content))
.map_err(|e| format!("Failed to write {}: {e}", format_path_for_display(path)))?;
Ok(Some(ConfigureResult {
shell,
path: path.to_path_buf(),
action: ConfigAction::Created,
config_line: config_line.to_string(),
}))
}
pub fn show_install_preview(
results: &[ConfigureResult],
completion_results: &[CompletionResult],
cmd: &str,
) {
let bold = Style::new().bold();
for result in results {
if matches!(result.action, ConfigAction::AlreadyExists) {
continue;
}
let shell = result.shell;
let path = format_path_for_display(&result.path);
let what = if matches!(shell, Shell::Bash | Shell::Zsh) {
"shell extension & completions"
} else {
"shell extension"
};
eprintln!(
"{} {} {what} for {bold}{shell}{bold:#} @ {bold}{path}{bold:#}",
result.action.symbol(),
result.action.description(),
);
let content = if matches!(shell, Shell::Fish) {
shell::ShellInit::with_prefix(shell, cmd.to_string())
.generate_fish_wrapper()
.unwrap_or_else(|_| result.config_line.clone())
} else {
result.config_line.clone()
};
eprintln!("{}", format_bash_with_gutter(&content));
if matches!(shell, Shell::Nushell) {
eprintln!("{}", hint_message("Nushell support is experimental"));
}
eprintln!(); }
for result in completion_results {
if matches!(result.action, ConfigAction::AlreadyExists) {
continue;
}
let shell = result.shell;
let path = format_path_for_display(&result.path);
eprintln!(
"{} {} completions for {bold}{shell}{bold:#} @ {bold}{path}{bold:#}",
result.action.symbol(),
result.action.description(),
);
let fish_completion = fish_completion_content(cmd);
eprintln!("{}", format_bash_with_gutter(fish_completion.trim()));
eprintln!(); }
}
pub fn show_uninstall_preview(
results: &[UninstallResult],
completion_results: &[CompletionUninstallResult],
) {
let bold = Style::new().bold();
for result in results {
let shell = result.shell;
let path = format_path_for_display(&result.path);
if let Some(canonical) = &result.superseded_by {
let canonical_path = format_path_for_display(canonical);
eprintln!(
"{INFO_SYMBOL} {} {bold}{path}{bold:#} (deprecated; now using {bold}{canonical_path}{bold:#})",
result.action.description(),
);
} else {
let what = if matches!(shell, Shell::Fish) {
"shell extension"
} else {
"shell extension & completions"
};
eprintln!(
"{} {} {what} for {bold}{shell}{bold:#} @ {bold}{path}{bold:#}",
result.action.symbol(),
result.action.description(),
);
}
}
for result in completion_results {
let shell = result.shell;
let path = format_path_for_display(&result.path);
eprintln!(
"{} {} completions for {bold}{shell}{bold:#} @ {bold}{path}{bold:#}",
result.action.symbol(),
result.action.description(),
);
}
}
pub fn prompt_for_install(
results: &[ConfigureResult],
completion_results: &[CompletionResult],
cmd: &str,
prompt_text: &str,
) -> Result<bool, String> {
let response = prompt_yes_no_preview(prompt_text, || {
show_install_preview(results, completion_results, cmd);
})
.map_err(|e| e.to_string())?;
Ok(response == PromptResponse::Accepted)
}
fn prompt_yes_no() -> Result<bool, String> {
eprintln!();
eprint!(
"{} ",
prompt_message(color_print::cformat!("Proceed? <bold>[y/N]</>"))
);
io::stderr().flush().map_err(|e| e.to_string())?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|e| e.to_string())?;
let response = input.trim().to_lowercase();
Ok(response == "y" || response == "yes")
}
fn fish_completion_content(cmd: &str) -> String {
format!(
r#"# worktrunk completions for fish
complete --keep-order --exclusive --command {cmd} --arguments "(test -n \"\$WORKTRUNK_BIN\"; or set -l WORKTRUNK_BIN (type -P {cmd} 2>/dev/null); and COMPLETE=fish \$WORKTRUNK_BIN -- (commandline --current-process --tokenize --cut-at-cursor) (commandline --current-token))"
"#
)
}
pub fn process_shell_completions(
shells: &[Shell],
dry_run: bool,
cmd: &str,
) -> Result<Vec<CompletionResult>, String> {
let mut results = Vec::new();
let fish_completion = fish_completion_content(cmd);
for &shell in shells {
if shell != Shell::Fish {
continue;
}
let completion_path = shell
.completion_path(cmd)
.map_err(|e| format!("Failed to get completion path for {shell}: {e}"))?;
if let Ok(existing) = fs::read_to_string(&completion_path)
&& existing == fish_completion
{
results.push(CompletionResult {
shell,
path: completion_path,
action: ConfigAction::AlreadyExists,
});
continue;
}
if dry_run {
let action = if completion_path.exists() {
ConfigAction::WouldAdd
} else {
ConfigAction::WouldCreate
};
results.push(CompletionResult {
shell,
path: completion_path,
action,
});
continue;
}
if let Some(parent) = completion_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!(
"Failed to create directory {}: {e}",
format_path_for_display(parent)
)
})?;
}
fs::write(&completion_path, &fish_completion).map_err(|e| {
format!(
"Failed to write {}: {e}",
format_path_for_display(&completion_path)
)
})?;
results.push(CompletionResult {
shell,
path: completion_path,
action: ConfigAction::Created,
});
}
Ok(results)
}
pub fn handle_unconfigure_shell(
shell_filter: Option<Shell>,
skip_confirmation: bool,
dry_run: bool,
cmd: &str,
) -> Result<UninstallScanResult, String> {
let preview = scan_for_uninstall(shell_filter, true, cmd)?;
if preview.results.is_empty() && preview.completion_results.is_empty() {
return Ok(preview);
}
if dry_run {
show_uninstall_preview(&preview.results, &preview.completion_results);
return Ok(preview);
}
if !skip_confirmation
&& !prompt_for_uninstall_confirmation(&preview.results, &preview.completion_results)?
{
return Err("Cancelled by user".to_string());
}
scan_for_uninstall(shell_filter, false, cmd)
}
fn remove_config_file(path: &std::path::Path) -> Result<(), String> {
fs::remove_file(path)
.map_err(|e| format!("Failed to remove {}: {e}", format_path_for_display(path)))
}
fn scan_for_uninstall(
shell_filter: Option<Shell>,
dry_run: bool,
cmd: &str,
) -> Result<UninstallScanResult, String> {
let default_shells = vec![
Shell::Bash,
Shell::Zsh,
Shell::Fish,
Shell::Nushell,
Shell::PowerShell,
];
let shells = shell_filter.map_or(default_shells, |shell| vec![shell]);
let mut results = Vec::new();
let mut not_found = Vec::new();
for &shell in &shells {
let paths = shell
.config_paths(cmd)
.map_err(|e| format!("Failed to get config paths for {shell}: {e}"))?;
if matches!(shell, Shell::Fish) {
let mut found_any = false;
if let Some(fish_path) = paths.first()
&& fish_path.exists()
{
let is_worktrunk_managed = fs::read_to_string(fish_path)
.map(|content| is_worktrunk_managed_content(&content, cmd))
.unwrap_or(false);
if is_worktrunk_managed {
found_any = true;
if dry_run {
results.push(UninstallResult {
shell,
path: fish_path.clone(),
action: UninstallAction::WouldRemove,
superseded_by: None,
});
} else {
remove_config_file(fish_path)?;
results.push(UninstallResult {
shell,
path: fish_path.clone(),
action: UninstallAction::Removed,
superseded_by: None,
});
}
}
}
let canonical_path = paths.first().cloned();
if let Ok(legacy_path) = Shell::legacy_fish_conf_d_path(cmd)
&& legacy_path.exists()
{
let is_worktrunk_managed = fs::read_to_string(&legacy_path)
.map(|content| is_worktrunk_managed_content(&content, cmd))
.unwrap_or(false);
if is_worktrunk_managed {
found_any = true;
if dry_run {
results.push(UninstallResult {
shell,
path: legacy_path.clone(),
action: UninstallAction::WouldRemove,
superseded_by: canonical_path.clone(),
});
} else {
remove_config_file(&legacy_path)?;
results.push(UninstallResult {
shell,
path: legacy_path,
action: UninstallAction::Removed,
superseded_by: canonical_path,
});
}
}
}
if !found_any && let Some(fish_path) = paths.first() {
not_found.push((shell, fish_path.clone()));
}
continue;
}
if matches!(shell, Shell::Nushell) {
let mut found_any = false;
for config_path in &paths {
if !config_path.exists() {
continue;
}
found_any = true;
if dry_run {
results.push(UninstallResult {
shell,
path: config_path.clone(),
action: UninstallAction::WouldRemove,
superseded_by: None,
});
} else {
remove_config_file(config_path)?;
results.push(UninstallResult {
shell,
path: config_path.clone(),
action: UninstallAction::Removed,
superseded_by: None,
});
}
}
if !found_any && let Some(config_path) = paths.first() {
not_found.push((shell, config_path.clone()));
}
continue;
}
let mut found = false;
for path in &paths {
if !path.exists() {
continue;
}
match uninstall_from_file(shell, path, dry_run, cmd) {
Ok(Some(result)) => {
results.push(result);
found = true;
break; }
Ok(None) => {} Err(e) => return Err(e),
}
}
if !found && let Some(first_path) = paths.first() {
not_found.push((shell, first_path.clone()));
}
}
let mut completion_results = Vec::new();
let mut completion_not_found = Vec::new();
for &shell in &shells {
if shell != Shell::Fish {
continue;
}
let completion_path = shell
.completion_path(cmd)
.map_err(|e| format!("Failed to get completion path for {}: {}", shell, e))?;
if completion_path.exists() {
if dry_run {
completion_results.push(CompletionUninstallResult {
shell,
path: completion_path,
action: UninstallAction::WouldRemove,
});
} else {
remove_config_file(&completion_path)?;
completion_results.push(CompletionUninstallResult {
shell,
path: completion_path,
action: UninstallAction::Removed,
});
}
} else {
completion_not_found.push((shell, completion_path));
}
}
Ok(UninstallScanResult {
results,
completion_results,
not_found,
completion_not_found,
})
}
fn uninstall_from_file(
shell: Shell,
path: &Path,
dry_run: bool,
cmd: &str,
) -> Result<Option<UninstallResult>, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", format_path_for_display(path), e))?;
let lines: Vec<&str> = content.lines().collect();
let integration_lines: Vec<(usize, &str)> = lines
.iter()
.enumerate()
.filter(|(_, line)| shell::is_shell_integration_line_for_uninstall(line, cmd))
.map(|(i, line)| (i, *line))
.collect();
if integration_lines.is_empty() {
return Ok(None);
}
if dry_run {
return Ok(Some(UninstallResult {
shell,
path: path.to_path_buf(),
action: UninstallAction::WouldRemove,
superseded_by: None,
}));
}
let mut indices_to_remove: HashSet<usize> = integration_lines.iter().map(|(i, _)| *i).collect();
for &(i, _) in &integration_lines {
if i > 0 && lines[i - 1].trim().is_empty() {
indices_to_remove.insert(i - 1);
}
}
let new_lines: Vec<&str> = lines
.iter()
.enumerate()
.filter(|(i, _)| !indices_to_remove.contains(i))
.map(|(_, line)| *line)
.collect();
let new_content = new_lines.join("\n");
let new_content = if content.ends_with('\n') {
format!("{}\n", new_content)
} else {
new_content
};
fs::write(path, new_content)
.map_err(|e| format!("Failed to write {}: {}", format_path_for_display(path), e))?;
Ok(Some(UninstallResult {
shell,
path: path.to_path_buf(),
action: UninstallAction::Removed,
superseded_by: None,
}))
}
fn prompt_for_uninstall_confirmation(
results: &[UninstallResult],
completion_results: &[CompletionUninstallResult],
) -> Result<bool, String> {
for result in results {
let bold = Style::new().bold();
let shell = result.shell;
let path = format_path_for_display(&result.path);
let what = if matches!(shell, Shell::Bash | Shell::Zsh) {
"shell extension & completions"
} else {
"shell extension"
};
eprintln!(
"{} {} {what} for {bold}{shell}{bold:#} @ {bold}{path}{bold:#}",
result.action.symbol(),
result.action.description(),
);
}
for result in completion_results {
let bold = Style::new().bold();
let shell = result.shell;
let path = format_path_for_display(&result.path);
eprintln!(
"{} {} completions for {bold}{shell}{bold:#} @ {bold}{path}{bold:#}",
result.action.symbol(),
result.action.description(),
);
}
prompt_yes_no()
}
pub fn handle_show_theme() {
use color_print::cformat;
use worktrunk::styling::{
error_message, hint_message, info_message, progress_message, success_message,
};
eprintln!(
"{}",
progress_message(cformat!("Rebasing <bold>feature</> onto <bold>main</>..."))
);
eprintln!(
"{}",
success_message(cformat!(
"Created worktree for <bold>feature</> @ <bold>/path/to/worktree</>"
))
);
eprintln!(
"{}",
error_message(cformat!("Branch <bold>feature</> not found"))
);
eprintln!(
"{}",
warning_message(cformat!("Branch <bold>feature</> has uncommitted changes"))
);
eprintln!(
"{}",
hint_message(cformat!("To rebase onto main, run <underline>wt merge</>"))
);
eprintln!("{}", info_message(cformat!("Showing <bold>5</> worktrees")));
eprintln!();
eprintln!("{}", info_message("Gutter formatting (error details):"));
eprintln!(
"{}",
format_with_gutter("expected `=`, found newline at line 3 column 1", None,)
);
eprintln!();
eprintln!("{}", info_message("Gutter formatting (config):"));
eprintln!(
"{}",
format_toml("[commit.generation]\ncommand = \"llm --model claude\"")
);
eprintln!();
eprintln!("{}", info_message("Gutter formatting (shell code):"));
eprintln!(
"{}",
format_bash_with_gutter(
"eval \"$(wt config shell init bash)\"\necho 'This is a long command that will wrap to the next line when the terminal is narrow enough to require wrapping.'\necho 'hello\nworld'\ncargo build --release &&\ncargo test\ncp {{ repo_root }}/target {{ worktree }}/target"
)
);
eprintln!();
eprintln!("{}", info_message("Prompt formatting:"));
eprintln!("{} ", prompt_message("Proceed? [y/N]"));
eprintln!();
eprintln!("{}", info_message("Color palette:"));
use anstyle::{AnsiColor, Color};
let fg = |c: AnsiColor| Some(Color::Ansi(c));
let palette: &[(&str, Style)] = &[
("red", Style::new().fg_color(fg(AnsiColor::Red))),
("green", Style::new().fg_color(fg(AnsiColor::Green))),
("yellow", Style::new().fg_color(fg(AnsiColor::Yellow))),
("blue", Style::new().fg_color(fg(AnsiColor::Blue))),
("cyan", Style::new().fg_color(fg(AnsiColor::Cyan))),
("bold", Style::new().bold()),
("dim", Style::new().dimmed()),
("bold red", Style::new().fg_color(fg(AnsiColor::Red)).bold()),
(
"bold green",
Style::new().fg_color(fg(AnsiColor::Green)).bold(),
),
(
"bold yellow",
Style::new().fg_color(fg(AnsiColor::Yellow)).bold(),
),
(
"bold cyan",
Style::new().fg_color(fg(AnsiColor::Cyan)).bold(),
),
(
"dim bright-black",
Style::new().fg_color(fg(AnsiColor::BrightBlack)).dimmed(),
),
(
"dim blue",
Style::new().fg_color(fg(AnsiColor::Blue)).dimmed(),
),
(
"dim green",
Style::new().fg_color(fg(AnsiColor::Green)).dimmed(),
),
(
"dim cyan",
Style::new().fg_color(fg(AnsiColor::Cyan)).dimmed(),
),
(
"dim magenta",
Style::new().fg_color(fg(AnsiColor::Magenta)).dimmed(),
),
(
"dim yellow",
Style::new().fg_color(fg(AnsiColor::Yellow)).dimmed(),
),
];
let palette_text: String = palette
.iter()
.map(|(name, style)| format!("{style}{name}{style:#}"))
.collect::<Vec<_>>()
.join("\n");
eprintln!("{}", format_with_gutter(&palette_text, None));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_uninstall_action_description() {
assert_eq!(UninstallAction::Removed.description(), "Removed");
assert_eq!(UninstallAction::WouldRemove.description(), "Will remove");
}
#[test]
fn test_uninstall_action_emoji() {
assert_eq!(UninstallAction::Removed.symbol(), SUCCESS_SYMBOL);
assert_eq!(UninstallAction::WouldRemove.symbol(), INFO_SYMBOL);
}
#[test]
fn test_config_action_description() {
assert_eq!(ConfigAction::Added.description(), "Added");
assert_eq!(
ConfigAction::AlreadyExists.description(),
"Already configured"
);
assert_eq!(ConfigAction::Created.description(), "Created");
assert_eq!(ConfigAction::WouldAdd.description(), "Will add");
assert_eq!(ConfigAction::WouldCreate.description(), "Will create");
}
#[test]
fn test_config_action_emoji() {
assert_eq!(ConfigAction::Added.symbol(), SUCCESS_SYMBOL);
assert_eq!(ConfigAction::Created.symbol(), SUCCESS_SYMBOL);
assert_eq!(ConfigAction::AlreadyExists.symbol(), INFO_SYMBOL);
assert_eq!(ConfigAction::WouldAdd.symbol(), INFO_SYMBOL);
assert_eq!(ConfigAction::WouldCreate.symbol(), INFO_SYMBOL);
}
#[test]
fn test_is_shell_integration_line() {
assert!(shell::is_shell_integration_line(
"eval \"$(wt config shell init bash)\"",
"wt"
));
assert!(shell::is_shell_integration_line(
" eval \"$(wt config shell init zsh)\" ",
"wt"
));
assert!(shell::is_shell_integration_line(
"if command -v wt; then eval \"$(wt config shell init bash)\"; fi",
"wt"
));
assert!(shell::is_shell_integration_line(
"source <(wt config shell init fish)",
"wt"
));
assert!(shell::is_shell_integration_line(
"eval \"$(git-wt config shell init bash)\"",
"git-wt"
));
assert!(!shell::is_shell_integration_line(
"eval \"$(wt config shell init bash)\"",
"git-wt"
));
assert!(!shell::is_shell_integration_line(
"# eval \"$(wt config shell init bash)\"",
"wt"
));
assert!(!shell::is_shell_integration_line(
"wt config shell init bash",
"wt"
));
assert!(!shell::is_shell_integration_line(
"echo wt config shell init bash",
"wt"
));
}
#[test]
fn test_fish_completion_content() {
insta::assert_snapshot!(fish_completion_content("wt"));
}
#[test]
fn test_fish_completion_content_custom_cmd() {
insta::assert_snapshot!(fish_completion_content("myapp"));
}
#[test]
fn test_fish_code_lines_strips_comments_and_blanks() {
let source = "# comment\n\nfunction wt\n command wt $argv\nend\n";
assert_eq!(
fish_code_lines(source),
vec!["function wt", "command wt $argv", "end"]
);
}
#[test]
fn test_fish_code_lines_matches_despite_different_comments() {
let old = "# Docs: https://worktrunk.dev/docs/shell-integration\nfunction wt\n command wt $argv\nend";
let new = "# Docs: https://worktrunk.dev/config/#shell-integration\nfunction wt\n command wt $argv\nend";
assert_eq!(fish_code_lines(old), fish_code_lines(new));
}
}