Files
max-sixty__worktrunk/tests/integration_tests/configure_shell.rs
Worktrunk Bot 13bc27e70a fix(shell): preview and confirm legacy-file removal on shell install (#3648)
## Problem

`wt config shell install` reclaims its own legacy wrapper paths after
installing to the canonical location — the fish `conf.d/{cmd}.fish`
(#566) and stranded nushell autoload copies (#2878). That reclaim is
deliberate and documented, but the deletion never appeared in anything
the user saw *before* it happened (#3644):

- `--dry-run` returned an empty `legacy_cleanups`, so a preview run
never mentioned the file it would remove.
- The confirmation prompt was passed only the files to *add* — the
legacy removals weren't in the `?` preview.
- When everything was already configured, the cleanup ran and returned
**without prompting at all**, deleting a hand-written `conf.d/wt.fish`
as a silent side effect.

Removals were reported after the fact (`Removed <old> (deprecated; now
using <new>)`), so nothing was silent — but it was after the fact, and
repo policy is that destructive operations get explicit consent.

## Solution

The two cleanup helpers now take a `dry_run` flag: with it set, the same
detection runs but nothing is removed, so the paths a real install
*would* delete can be computed up front. `handle_configure_shell`
collects that list once and threads it into `show_install_preview` and
`prompt_for_install`, so both `--dry-run` and the `?` preview render a
`Will remove <old> (deprecated; now using <new>)` line — the
after-the-fact message in the future tense.

The already-configured branch now prompts (`Remove deprecated shell
integration files?`) when there is anything to remove, and preserves the
file on decline. `--yes` still skips the prompt and removes as before,
so no existing non-interactive flow changes.

## Testing

- New `test_configure_shell_fish_dry_run_previews_legacy_removal` —
`--dry-run` now names the legacy `conf.d` removal on stdout and leaves
the file in place.
- New
`test_configure_shell_fish_legacy_removal_declined_when_already_configured`
— declining the prompt in the already-configured branch exits non-zero
and preserves the file (the core consent gap).
- The existing
`test_configure_shell_fish_dry_run_does_not_delete_legacy` snapshot was
updated to include the new `Will remove …` preview line; its "must NOT
delete" file assertion still holds.
- Verified manually end-to-end (dry-run preview + declined removal both
preserve the file), and ran the full `configure_shell`,
`shell_integration_prompt`, and nushell test suites plus clippy and the
doc-sync test.

---
Closes #3644 — automated triage

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-07-28 20:35:13 -07:00

3022 lines
113 KiB
Rust
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::common::{
TestRepo, canonical_temp_home, repo, set_temp_home_env, set_xdg_config_path,
setup_home_snapshot_settings, temp_home, wt_command,
};
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::fs;
use tempfile::TempDir;
#[rstest]
fn test_configure_shell_with_yes(repo: TestRepo, temp_home: TempDir) {
// Create a fake .zshrc file
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(&zshrc_path, "# Existing config\n").unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
// Force compinit warning for deterministic tests across environments
cmd.env("WORKTRUNK_TEST_COMPINIT_MISSING", "1");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
✓ Added shell extension & completions for zsh @ ~/.zshrc
✓ Configured 1 shell
▲ Completions require compinit; add to ~/.zshrc before the wt line:
  autoload -Uz compinit && compinit
↳ Restart shell to activate shell integration
");
});
// Verify the file was modified
let content = fs::read_to_string(&zshrc_path).unwrap();
assert!(content.contains("eval \"$(command wt config shell init zsh)\""));
}
#[rstest]
fn test_configure_shell_specific_shell(repo: TestRepo, temp_home: TempDir) {
// Create a fake .zshrc file
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(&zshrc_path, "# Existing config\n").unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
// Force compinit warning for deterministic tests across environments
cmd.env("WORKTRUNK_TEST_COMPINIT_MISSING", "1");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("zsh")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
✓ Added shell extension & completions for zsh @ ~/.zshrc
✓ Configured 1 shell
▲ Completions require compinit; add to ~/.zshrc before the wt line:
  autoload -Uz compinit && compinit
↳ Restart shell to activate shell integration
");
});
// Verify the file was modified
let content = fs::read_to_string(&zshrc_path).unwrap();
assert!(content.contains("eval \"$(command wt config shell init zsh)\""));
}
#[rstest]
fn test_configure_shell_rejects_unsafe_cmd_without_modifying_rc(
repo: TestRepo,
temp_home: TempDir,
) {
let zshrc_path = temp_home.path().join(".zshrc");
let original = "# Existing config\n";
fs::write(&zshrc_path, original).unwrap();
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("zsh")
.arg("--yes")
.arg("--cmd")
.arg("wt; touch /tmp/pwn")
.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(!output.status.success());
assert!(
output.stdout.is_empty(),
"unsafe command name must not emit shell code:\n{}",
String::from_utf8_lossy(&output.stdout)
);
assert!(
String::from_utf8_lossy(&output.stderr).contains("Invalid shell integration command name"),
"expected validation error, got:\n{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(fs::read_to_string(&zshrc_path).unwrap(), original);
}
#[rstest]
fn test_configure_shell_already_exists(repo: TestRepo, temp_home: TempDir) {
// Create a fake .zshrc file with the line already present
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(
&zshrc_path,
"# Existing config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init zsh)\"; fi\n",
)
.unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("zsh")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
○ Already configured shell extension & completions for zsh @ ~/.zshrc
○ All shells already configured
");
});
// Verify the file was not modified (no duplicate)
let content = fs::read_to_string(&zshrc_path).unwrap();
let count = content.matches("wt config shell init").count();
assert_eq!(count, 1, "Should only have one wt config shell init line");
}
#[rstest]
fn test_configure_shell_already_exists_noncanonical_line(repo: TestRepo, temp_home: TempDir) {
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(
&zshrc_path,
"# Existing config\neval \"$(wt config shell init zsh)\"\n",
)
.unwrap();
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.env("WORKTRUNK_TEST_COMPINIT_CONFIGURED", "1");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("zsh")
.arg("--yes")
.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"install should treat the existing manual line as configured:\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let content = fs::read_to_string(&zshrc_path).unwrap();
let count = content.matches("wt config shell init").count();
assert_eq!(count, 1, "Should not append a duplicate shell init line");
}
#[rstest]
fn test_configure_shell_fish(repo: TestRepo, temp_home: TempDir) {
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("fish")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
✓ Created shell extension for fish @ ~/.config/fish/functions/wt.fish
✓ Created completions for fish @ ~/.config/fish/completions/wt.fish
✓ Configured 1 shell
↳ Restart shell to activate shell integration
");
});
// Verify the fish conf.d file was created
let fish_config = temp_home.path().join(".config/fish/functions/wt.fish");
assert!(fish_config.exists());
let content = fs::read_to_string(&fish_config).unwrap();
assert!(
content.contains("function wt"),
"Should contain function definition: {}",
content
);
}
/// Test install dry-run shows preview with gutter-formatted config content
#[rstest]
fn test_configure_shell_fish_dry_run(repo: TestRepo, temp_home: TempDir) {
// Create fish functions directory (but no wt.fish - so it will be "created")
let functions = temp_home.path().join(".config/fish/functions");
fs::create_dir_all(&functions).unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("fish")
.arg("--dry-run")
.current_dir(repo.root_path());
// Dry-run should show "Will create" and the actual content in gutter
assert_cmd_snapshot!(cmd);
});
// Verify no files were actually created
let fish_config = functions.join("wt.fish");
assert!(
!fish_config.exists(),
"Dry-run should not create files: {:?}",
fish_config
);
}
/// Test that installing when extension exists shows "Already configured"
#[rstest]
fn test_configure_shell_fish_extension_exists(repo: TestRepo, temp_home: TempDir) {
// Create fish functions directory with wt.fish (extension exists at new canonical location)
let functions = temp_home.path().join(".config/fish/functions");
fs::create_dir_all(&functions).unwrap();
let fish_config = functions.join("wt.fish");
// Write the exact wrapper content that install would create
let init =
worktrunk::shell::ShellInit::with_prefix(worktrunk::shell::Shell::Fish, "wt".to_string());
let wrapper_content = init.generate_fish_wrapper().unwrap();
fs::write(&fish_config, format!("{}\n", wrapper_content)).unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("fish")
.arg("--yes")
.current_dir(repo.root_path());
// Fish shell extension exists but completions are in a separate file.
// Shell extension shows as "Already configured", completions show as "Created".
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
○ Already configured shell extension for fish @ ~/.config/fish/functions/wt.fish
✓ Created completions for fish @ ~/.config/fish/completions/wt.fish
✓ Configured 1 shell
");
});
// Fish completions should be in a separate file with WORKTRUNK_BIN fallback
let completions_file = temp_home.path().join(".config/fish/completions/wt.fish");
assert!(
completions_file.exists(),
"Fish completions file should be created"
);
let contents = std::fs::read_to_string(&completions_file).unwrap();
assert!(
contents.contains(r#"test -n \"\$WORKTRUNK_BIN\""#),
"Fish completions should check WORKTRUNK_BIN is non-empty with fallback"
);
}
#[rstest]
fn test_configure_shell_fish_all_already_configured(repo: TestRepo, temp_home: TempDir) {
// Create fish functions directory with wt.fish (extension exists at new canonical location)
let functions = temp_home.path().join(".config/fish/functions");
fs::create_dir_all(&functions).unwrap();
let fish_config = functions.join("wt.fish");
// Write the exact wrapper content that install would create
let init =
worktrunk::shell::ShellInit::with_prefix(worktrunk::shell::Shell::Fish, "wt".to_string());
let wrapper_content = init.generate_fish_wrapper().unwrap();
fs::write(&fish_config, format!("{}\n", wrapper_content)).unwrap();
// Also create completions file
let completions_d = temp_home.path().join(".config/fish/completions");
fs::create_dir_all(&completions_d).unwrap();
let completions_file = completions_d.join("wt.fish");
fs::write(&completions_file, "# existing completions").unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("fish")
.arg("--yes")
.current_dir(repo.root_path());
// Both extension and completions already exist
assert_cmd_snapshot!(cmd);
});
}
/// Test that installing fish shell integration cleans up legacy conf.d file
///
/// Before issue #566, fish integration was installed to conf.d/wt.fish.
/// Now it installs to functions/wt.fish. This test ensures we clean up the old location.
#[rstest]
fn test_configure_shell_fish_legacy_conf_d_cleanup(repo: TestRepo, temp_home: TempDir) {
// Create legacy conf.d file (old location)
let conf_d = temp_home.path().join(".config/fish/conf.d");
fs::create_dir_all(&conf_d).unwrap();
let legacy_file = conf_d.join("wt.fish");
// Use realistic content with worktrunk marker so it's detected as worktrunk-managed
fs::write(&legacy_file, "wt config shell init fish | source").unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("fish")
.arg("--yes")
.current_dir(repo.root_path());
// Should create new file and clean up legacy
assert_cmd_snapshot!(cmd);
});
// Verify new location exists
let new_file = temp_home.path().join(".config/fish/functions/wt.fish");
assert!(
new_file.exists(),
"Should create functions/wt.fish: {:?}",
new_file
);
// Verify legacy location was cleaned up
assert!(
!legacy_file.exists(),
"Should remove legacy conf.d/wt.fish: {:?}",
legacy_file
);
}
/// Installing fish integration reclaims `conf.d/{cmd}.fish` whatever it holds.
///
/// The path names the command being installed, so it's worktrunk's; the file's
/// contents don't enter into it. Leaving this one would break the install
/// besides — `conf.d` is sourced at startup, so a `function wt` defined there
/// is already loaded when fish would otherwise autoload `functions/wt.fish`.
#[rstest]
fn test_configure_shell_fish_reclaims_conf_d_path(repo: TestRepo, temp_home: TempDir) {
let conf_d = temp_home.path().join(".config/fish/conf.d");
fs::create_dir_all(&conf_d).unwrap();
let stale = conf_d.join("wt.fish");
// No worktrunk header, and nothing a content test would recognize.
fs::write(&stale, "function wt\n command wt-old $argv\nend\n").unwrap();
// A neighbour under another name is untouched: only `{cmd}.fish` is ours.
let neighbour = conf_d.join("aliases.fish");
let neighbour_content = "# reminder: wt config shell init fish\nalias ll 'ls -l'\n";
fs::write(&neighbour, neighbour_content).unwrap();
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.args(["config", "shell", "install", "fish", "--yes"])
.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(output.status.success(), "install failed: {output:?}");
assert!(
temp_home
.path()
.join(".config/fish/functions/wt.fish")
.exists(),
"install should write functions/wt.fish"
);
assert!(
!stale.exists(),
"install should reclaim conf.d/wt.fish: {stale:?}"
);
assert_eq!(
fs::read_to_string(&neighbour).unwrap(),
neighbour_content,
"a conf.d file under another name is not worktrunk's"
);
// The removal is reported, not silent.
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("deprecated"),
"install should report the conf.d cleanup:\n{stderr}"
);
}
/// Test that legacy cleanup happens even when new file already exists with correct content
///
/// This handles the case where:
/// 1. User had old conf.d/wt.fish (pre-#566)
/// 2. User manually created functions/wt.fish with correct content
/// 3. User runs `wt config shell install fish`
///
/// The legacy file should still be cleaned up even though install reports "Already configured"
#[rstest]
fn test_configure_shell_fish_legacy_cleanup_even_when_already_exists(
repo: TestRepo,
temp_home: TempDir,
) {
// Create functions/wt.fish with the EXACT content that install would create
let functions = temp_home.path().join(".config/fish/functions");
fs::create_dir_all(&functions).unwrap();
let new_file = functions.join("wt.fish");
let init =
worktrunk::shell::ShellInit::with_prefix(worktrunk::shell::Shell::Fish, "wt".to_string());
let wrapper_content = init.generate_fish_wrapper().unwrap();
fs::write(&new_file, format!("{}\n", wrapper_content)).unwrap();
// Also create completions (so it reports "all already configured")
let completions_d = temp_home.path().join(".config/fish/completions");
fs::create_dir_all(&completions_d).unwrap();
fs::write(
completions_d.join("wt.fish"),
"# worktrunk completions for fish\ncomplete --command wt\n",
)
.unwrap();
// Create legacy conf.d file (old location that should be cleaned up)
let conf_d = temp_home.path().join(".config/fish/conf.d");
fs::create_dir_all(&conf_d).unwrap();
let legacy_file = conf_d.join("wt.fish");
// Use realistic content with worktrunk marker so it's detected as worktrunk-managed
fs::write(&legacy_file, "wt config shell init fish | source").unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("fish")
.arg("--yes")
.current_dir(repo.root_path());
// Should report "Already configured" but still clean up legacy
assert_cmd_snapshot!(cmd);
});
// The key assertion: legacy file should be removed even though new file already existed
assert!(
!legacy_file.exists(),
"Should remove legacy conf.d/wt.fish even when functions/wt.fish already exists: {:?}",
legacy_file
);
// New file should still exist
assert!(
new_file.exists(),
"Should preserve existing functions/wt.fish: {:?}",
new_file
);
}
/// Test that uninstalling fish shell integration also cleans up legacy conf.d file
///
/// If a user has the old conf.d/wt.fish file, uninstall should remove it too.
#[rstest]
fn test_uninstall_shell_fish_legacy_conf_d_cleanup(repo: TestRepo, temp_home: TempDir) {
// Create both new location (functions) and legacy location (conf.d)
let functions = temp_home.path().join(".config/fish/functions");
fs::create_dir_all(&functions).unwrap();
let new_file = functions.join("wt.fish");
// Write the exact wrapper content that install would create
let init =
worktrunk::shell::ShellInit::with_prefix(worktrunk::shell::Shell::Fish, "wt".to_string());
let wrapper_content = init.generate_fish_wrapper().unwrap();
fs::write(&new_file, format!("{}\n", wrapper_content)).unwrap();
let conf_d = temp_home.path().join(".config/fish/conf.d");
fs::create_dir_all(&conf_d).unwrap();
let legacy_file = conf_d.join("wt.fish");
// Legacy content from main branch
fs::write(&legacy_file, "wt config shell init fish | source").unwrap();
// Also create completions
let completions_d = temp_home.path().join(".config/fish/completions");
fs::create_dir_all(&completions_d).unwrap();
let completions_file = completions_d.join("wt.fish");
fs::write(
&completions_file,
"# worktrunk completions for fish\ncomplete --command wt\n",
)
.unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.arg("config")
.arg("shell")
.arg("uninstall")
.arg("fish")
.arg("--yes")
.current_dir(repo.root_path());
// Should remove both new and legacy files
assert_cmd_snapshot!(cmd);
});
// Verify both locations were cleaned up
assert!(
!new_file.exists(),
"Should remove functions/wt.fish: {:?}",
new_file
);
assert!(
!legacy_file.exists(),
"Should remove legacy conf.d/wt.fish: {:?}",
legacy_file
);
assert!(
!completions_file.exists(),
"Should remove completions/wt.fish: {:?}",
completions_file
);
}
/// Test that the legacy cleanup warns (but does not fail) when removal errors.
///
/// Makes the legacy conf.d directory read-only so `fs::remove_file` returns
/// EACCES. The install still succeeds via the new `functions/` location and
/// surfaces a warning naming the legacy file.
#[rstest]
#[cfg(unix)]
fn test_configure_shell_fish_legacy_remove_failure_warns(repo: TestRepo, temp_home: TempDir) {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let conf_d = temp_home.path().join(".config/fish/conf.d");
fs::create_dir_all(&conf_d).unwrap();
let legacy_file = conf_d.join("wt.fish");
fs::write(&legacy_file, "wt config shell init fish | source").unwrap();
// Read-only parent dir → remove of contained file fails with EACCES.
fs::set_permissions(&conf_d, Permissions::from_mode(0o555)).unwrap();
// Skip when running as root — permissions don't restrict.
let probe = conf_d.join("__probe");
if fs::write(&probe, "").is_ok() {
let _ = fs::remove_file(&probe);
fs::set_permissions(&conf_d, Permissions::from_mode(0o755)).unwrap();
eprintln!("Skipping - running with elevated privileges");
return;
}
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("fish")
.arg("--yes")
.current_dir(repo.root_path());
let output = cmd.output().unwrap();
// Restore permissions so TempDir cleanup succeeds even if assertions fail.
fs::set_permissions(&conf_d, Permissions::from_mode(0o755)).unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"Install should succeed despite legacy cleanup failure, got: {stderr}"
);
assert!(
stderr.contains("Failed to remove deprecated") && stderr.contains("conf.d/wt.fish"),
"Expected legacy-cleanup warning naming the path; got: {stderr}"
);
}
/// Test that --dry-run does NOT delete legacy fish conf.d file
///
/// Regression test: Previously, --dry-run could delete the legacy file because
/// cleanup ran before the dry_run check. This must never happen.
#[rstest]
fn test_configure_shell_fish_dry_run_does_not_delete_legacy(repo: TestRepo, temp_home: TempDir) {
// Create functions/wt.fish with correct content (already configured)
let functions = temp_home.path().join(".config/fish/functions");
fs::create_dir_all(&functions).unwrap();
let new_file = functions.join("wt.fish");
let init =
worktrunk::shell::ShellInit::with_prefix(worktrunk::shell::Shell::Fish, "wt".to_string());
let wrapper_content = init.generate_fish_wrapper().unwrap();
fs::write(&new_file, format!("{}\n", wrapper_content)).unwrap();
// Create completions (so it reports "all already configured")
let completions_d = temp_home.path().join(".config/fish/completions");
fs::create_dir_all(&completions_d).unwrap();
fs::write(
completions_d.join("wt.fish"),
"# worktrunk completions for fish\ncomplete --command wt\n",
)
.unwrap();
// Create legacy conf.d file that should NOT be deleted in dry-run mode
let conf_d = temp_home.path().join(".config/fish/conf.d");
fs::create_dir_all(&conf_d).unwrap();
let legacy_file = conf_d.join("wt.fish");
fs::write(&legacy_file, "wt config shell init fish | source").unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("fish")
.arg("--dry-run")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd);
});
// CRITICAL: Legacy file must still exist after --dry-run
assert!(
legacy_file.exists(),
"--dry-run must NOT delete legacy conf.d/wt.fish: {:?}",
legacy_file
);
// New file should still exist too
assert!(
new_file.exists(),
"functions/wt.fish should be preserved: {:?}",
new_file
);
}
/// `--dry-run` must *preview* the legacy files it would remove (issue #3644).
///
/// The cleanup takes back the legacy fish `conf.d/{cmd}.fish`; a preview run has
/// to name that removal, not just the files it would add. Regression guard for
/// the gap where `--dry-run` returned an empty `legacy_cleanups`, so the deletion
/// never appeared before it happened.
#[rstest]
fn test_configure_shell_fish_dry_run_previews_legacy_removal(repo: TestRepo, temp_home: TempDir) {
// Bootstrap the canonical location with an actual install so functions/ and
// completions/ hold the exact content install writes (i.e. "already
// configured"), leaving only the legacy file as a pending change.
let mut bootstrap = wt_command();
repo.configure_wt_cmd(&mut bootstrap);
set_temp_home_env(&mut bootstrap, temp_home.path());
bootstrap.env("SHELL", "/bin/fish");
bootstrap
.args(["config", "shell", "install", "fish", "--yes"])
.current_dir(repo.root_path());
assert!(bootstrap.output().unwrap().status.success());
// A stale legacy conf.d file the cleanup would take back.
let conf_d = temp_home.path().join(".config/fish/conf.d");
fs::create_dir_all(&conf_d).unwrap();
let legacy_file = conf_d.join("wt.fish");
fs::write(&legacy_file, "wt config shell init fish | source").unwrap();
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.args(["config", "shell", "install", "fish", "--dry-run"])
.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(output.status.success(), "dry-run failed: {output:?}");
// The preview (stdout) must name the legacy removal.
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("deprecated") && stdout.contains("conf.d"),
"--dry-run should preview the legacy conf.d removal:\n{stdout}"
);
// ...and must not actually remove anything.
assert!(
legacy_file.exists(),
"--dry-run must not delete legacy conf.d/wt.fish: {legacy_file:?}"
);
}
/// When everything is already configured, removing the legacy file needs the
/// user's consent — declining the prompt preserves it (issue #3644).
///
/// The already-configured branch used to run the cleanup and return without
/// prompting at all, deleting a hand-written `conf.d/wt.fish` as a silent side
/// effect. Now it prompts; declining must leave the file in place.
#[rstest]
fn test_configure_shell_fish_legacy_removal_declined_when_already_configured(
repo: TestRepo,
temp_home: TempDir,
) {
// Bootstrap the canonical location so a second install is a pure no-op except
// for the legacy cleanup.
let mut bootstrap = wt_command();
repo.configure_wt_cmd(&mut bootstrap);
set_temp_home_env(&mut bootstrap, temp_home.path());
bootstrap.env("SHELL", "/bin/fish");
bootstrap
.args(["config", "shell", "install", "fish", "--yes"])
.current_dir(repo.root_path());
assert!(bootstrap.output().unwrap().status.success());
let conf_d = temp_home.path().join(".config/fish/conf.d");
fs::create_dir_all(&conf_d).unwrap();
let legacy_file = conf_d.join("wt.fish");
fs::write(&legacy_file, "wt config shell init fish | source").unwrap();
// Run without --yes and decline the prompt.
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.args(["config", "shell", "install", "fish"])
.current_dir(repo.root_path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().unwrap();
use std::io::Write as _;
child.stdin.take().unwrap().write_all(b"n\n").unwrap();
let output = child.wait_with_output().unwrap();
// Declining is a non-zero exit ("Cancelled by user"), and the file survives.
assert!(
!output.status.success(),
"declining should exit non-zero: {output:?}"
);
assert!(
legacy_file.exists(),
"declining must preserve legacy conf.d/wt.fish: {legacy_file:?}"
);
}
/// Accepting the prompt in the already-configured branch removes the legacy file
/// (issue #3644).
///
/// The complement of the decline test: confirming the `Remove deprecated shell
/// integration files?` prompt proceeds with the cleanup that used to run
/// unprompted.
#[rstest]
fn test_configure_shell_fish_legacy_removal_accepted_when_already_configured(
repo: TestRepo,
temp_home: TempDir,
) {
let mut bootstrap = wt_command();
repo.configure_wt_cmd(&mut bootstrap);
set_temp_home_env(&mut bootstrap, temp_home.path());
bootstrap.env("SHELL", "/bin/fish");
bootstrap
.args(["config", "shell", "install", "fish", "--yes"])
.current_dir(repo.root_path());
assert!(bootstrap.output().unwrap().status.success());
let conf_d = temp_home.path().join(".config/fish/conf.d");
fs::create_dir_all(&conf_d).unwrap();
let legacy_file = conf_d.join("wt.fish");
fs::write(&legacy_file, "wt config shell init fish | source").unwrap();
// Run without --yes and accept the prompt.
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.args(["config", "shell", "install", "fish"])
.current_dir(repo.root_path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().unwrap();
use std::io::Write as _;
child.stdin.take().unwrap().write_all(b"y\n").unwrap();
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"accepting should succeed: {output:?}"
);
assert!(
!legacy_file.exists(),
"accepting must remove legacy conf.d/wt.fish: {legacy_file:?}"
);
}
/// Test that detection finds fish integration in legacy conf.d location
///
/// `wt config show` should detect shell integration whether it's in the
/// old conf.d location or the new functions location.
#[rstest]
fn test_config_show_detects_fish_legacy_conf_d(mut repo: TestRepo, temp_home: TempDir) {
// Create ONLY the legacy conf.d file (simulating user who installed before #566)
let conf_d = temp_home.path().join(".config/fish/conf.d");
fs::create_dir_all(&conf_d).unwrap();
let legacy_file = conf_d.join("wt.fish");
// Write content that matches our detection pattern (old-style init sourcing)
fs::write(&legacy_file, "wt config shell init fish | source").unwrap();
// Mock claude as not found (consistent across environments)
repo.setup_mock_ci_tools_unauthenticated();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = repo.wt_command();
set_temp_home_env(&mut cmd, temp_home.path());
set_xdg_config_path(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.arg("config").arg("show").current_dir(repo.root_path());
assert_cmd_snapshot!(cmd);
});
}
/// Test config show when functions/ exists but wt.fish doesn't, with legacy conf.d
///
/// This tests a different code path than test_config_show_detects_fish_legacy_conf_d:
/// - That test: functions/ doesn't exist -> fish is "skipped"
/// - This test: functions/ exists but empty -> fish is "configured" with WouldCreate
///
/// Both should show the migration hint for the legacy conf.d location.
#[rstest]
fn test_config_show_fish_legacy_with_functions_dir(mut repo: TestRepo, temp_home: TempDir) {
// Create functions/ directory (empty - no wt.fish)
let functions = temp_home.path().join(".config/fish/functions");
fs::create_dir_all(&functions).unwrap();
// Create legacy conf.d file
let conf_d = temp_home.path().join(".config/fish/conf.d");
fs::create_dir_all(&conf_d).unwrap();
let legacy_file = conf_d.join("wt.fish");
fs::write(&legacy_file, "wt config shell init fish | source").unwrap();
// Mock claude as not found
repo.setup_mock_ci_tools_unauthenticated();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = repo.wt_command();
set_temp_home_env(&mut cmd, temp_home.path());
set_xdg_config_path(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.env("WORKTRUNK_TEST_FISH_INSTALLED", "1");
cmd.arg("config").arg("show").current_dir(repo.root_path());
assert_cmd_snapshot!(cmd);
});
}
#[rstest]
fn test_configure_shell_skipped_lists_only_installed_shells(repo: TestRepo, temp_home: TempDir) {
// Pairs with `test_configure_shell_no_files` (no shells installed → no
// Skipped lines). Together they cover both arms of the
// `Shell::is_installed()` guard in `scan_shell_configs`.
//
// Here bash and fish are flagged installed but their rc files don't exist;
// they should appear as Skipped. zsh and nu remain "not installed" and
// must not appear at all. PowerShell coverage lives in
// `test_powershell_skipped_when_installed_no_profile` (Unix-only because
// the profile path differs on Windows).
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.env("WORKTRUNK_TEST_BASH_INSTALLED", "1");
cmd.env("WORKTRUNK_TEST_FISH_INSTALLED", "1");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
↳ Skipped bash; ~/.bashrc not found
↳ Skipped fish; ~/.config/fish/functions not found
✗ No shell config files found
");
});
}
/// PowerShell now iterates unconditionally and surfaces in `Skipped` via
/// `is_installed()`, just like bash/zsh/fish/nushell. Unix-only because the
/// profile path differs on Windows (`~/Documents/PowerShell/...`).
#[rstest]
#[cfg(unix)]
fn test_powershell_skipped_when_installed_no_profile(repo: TestRepo, temp_home: TempDir) {
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.env("WORKTRUNK_TEST_POWERSHELL_INSTALLED", "1");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
↳ Skipped powershell; ~/.config/powershell/Microsoft.PowerShell_profile.ps1 not found
✗ No shell config files found
");
});
}
/// No shells installed and no rc files — clean error, no Skipped lines.
///
/// Pairs with `test_configure_shell_skipped_lists_only_installed_shells`,
/// which exercises the other arm (binary on PATH, rc absent → Skipped).
#[rstest]
fn test_configure_shell_no_files(repo: TestRepo, temp_home: TempDir) {
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
✗ No shell config files found
");
});
}
#[rstest]
fn test_configure_shell_multiple_configs(repo: TestRepo, temp_home: TempDir) {
// Create multiple shell config files
let bash_config_path = temp_home.path().join(".bashrc");
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(&bash_config_path, "# Existing bash config\n").unwrap();
fs::write(&zshrc_path, "# Existing zsh config\n").unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
// Force compinit warning for deterministic tests across environments
cmd.env("WORKTRUNK_TEST_COMPINIT_MISSING", "1");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
✓ Added shell extension & completions for bash @ ~/.bashrc
✓ Added shell extension & completions for zsh @ ~/.zshrc
✓ Configured 2 shells
▲ Completions require compinit; add to ~/.zshrc before the wt line:
  autoload -Uz compinit && compinit
↳ Restart shell to activate shell integration
");
});
// Verify both files were modified
let bash_content = fs::read_to_string(&bash_config_path).unwrap();
assert!(
bash_content.contains("eval \"$(command wt config shell init bash)\""),
"Bash config should be updated"
);
let zsh_content = fs::read_to_string(&zshrc_path).unwrap();
assert!(
zsh_content.contains("eval \"$(command wt config shell init zsh)\""),
"Zsh config should be updated"
);
}
#[rstest]
fn test_configure_shell_mixed_states(repo: TestRepo, temp_home: TempDir) {
// Create bash config with wt already configured
let bash_config_path = temp_home.path().join(".bashrc");
fs::write(
&bash_config_path,
"# Existing config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init bash)\"; fi\n",
)
.unwrap();
// Create zsh config without wt
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(&zshrc_path, "# Existing zsh config\n").unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
// Force compinit warning for deterministic tests across environments
cmd.env("WORKTRUNK_TEST_COMPINIT_MISSING", "1");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
○ Already configured shell extension & completions for bash @ ~/.bashrc
✓ Added shell extension & completions for zsh @ ~/.zshrc
✓ Configured 1 shell
▲ Completions require compinit; add to ~/.zshrc before the wt line:
  autoload -Uz compinit && compinit
↳ Restart shell to activate shell integration
");
});
// Verify bash was not modified (already configured)
let bash_content = fs::read_to_string(&bash_config_path).unwrap();
let bash_wt_count = bash_content.matches("wt config shell init").count();
assert_eq!(
bash_wt_count, 1,
"Bash should still have exactly one wt config shell init line"
);
// Verify zsh was modified
let zsh_content = fs::read_to_string(&zshrc_path).unwrap();
assert!(
zsh_content.contains("eval \"$(command wt config shell init zsh)\""),
"Zsh config should be updated"
);
}
#[rstest]
fn test_uninstall_shell(repo: TestRepo, temp_home: TempDir) {
// Create a fake .zshrc file with wt integration
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(
&zshrc_path,
"# Existing config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init zsh)\"; fi\n",
)
.unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
// Pin the nushell vendor-autoload dir so the "not found" path is
// deterministic across platforms and independent of whether `nu` is on
// the runner's PATH.
cmd.env(
"WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR",
canonical_temp_home(&temp_home).join(".local/share/nushell/vendor/autoload"),
);
cmd.arg("config")
.arg("shell")
.arg("uninstall")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
✓ Removed shell extension & completions for zsh @ ~/.zshrc
  if command -v wt >/dev/null 2>&1; then eval "$(command wt config shell init zsh)"; fi
↳ No bash shell extension & completions in ~/.bashrc
↳ No fish shell extension in ~/.config/fish/functions
↳ No nu shell extension & completions in ~/.local/share/nushell/vendor/autoload
↳ No fish completions in ~/.config/fish/completions
✓ Removed integration from 1 shell
↳ Restart shell to complete uninstall
"#);
});
// Verify the file no longer contains the integration
let content = fs::read_to_string(&zshrc_path).unwrap();
assert!(
!content.contains("wt config shell init"),
"Integration should be removed"
);
assert!(
content.contains("# Existing config"),
"Other content should be preserved"
);
}
#[rstest]
fn test_uninstall_shell_multiple(repo: TestRepo, temp_home: TempDir) {
// Create multiple shell configs with wt integration
let bash_config_path = temp_home.path().join(".bashrc");
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(
&bash_config_path,
"# Bash config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init bash)\"; fi\n",
)
.unwrap();
fs::write(
&zshrc_path,
"# Zsh config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init zsh)\"; fi\n",
)
.unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
// Pin the nushell vendor-autoload dir so the "not found" path is
// deterministic across platforms and independent of whether `nu` is on
// the runner's PATH.
cmd.env(
"WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR",
canonical_temp_home(&temp_home).join(".local/share/nushell/vendor/autoload"),
);
cmd.arg("config")
.arg("shell")
.arg("uninstall")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
✓ Removed shell extension & completions for bash @ ~/.bashrc
  if command -v wt >/dev/null 2>&1; then eval "$(command wt config shell init bash)"; fi
✓ Removed shell extension & completions for zsh @ ~/.zshrc
  if command -v wt >/dev/null 2>&1; then eval "$(command wt config shell init zsh)"; fi
↳ No fish shell extension in ~/.config/fish/functions
↳ No nu shell extension & completions in ~/.local/share/nushell/vendor/autoload
↳ No fish completions in ~/.config/fish/completions
✓ Removed integration from 2 shells
↳ Restart shell to complete uninstall
"#);
});
// Verify both files no longer contain the integration
let bash_content = fs::read_to_string(&bash_config_path).unwrap();
assert!(
!bash_content.contains("wt config shell init"),
"Bash integration should be removed"
);
let zsh_content = fs::read_to_string(&zshrc_path).unwrap();
assert!(
!zsh_content.contains("wt config shell init"),
"Zsh integration should be removed"
);
}
#[rstest]
fn test_uninstall_shell_not_found(repo: TestRepo, temp_home: TempDir) {
// Create a fake .zshrc file without wt integration
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(&zshrc_path, "# Existing config\n").unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.arg("config")
.arg("shell")
.arg("uninstall")
.arg("zsh")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
▲ No shell extension & completions found in ~/.zshrc
");
});
}
#[rstest]
fn test_uninstall_shell_fish(repo: TestRepo, temp_home: TempDir) {
// Create fish functions directory with wt.fish (new canonical location)
let functions = temp_home.path().join(".config/fish/functions");
fs::create_dir_all(&functions).unwrap();
let fish_config = functions.join("wt.fish");
// Write the exact wrapper content that install would create
let init =
worktrunk::shell::ShellInit::with_prefix(worktrunk::shell::Shell::Fish, "wt".to_string());
let wrapper_content = init.generate_fish_wrapper().unwrap();
fs::write(&fish_config, format!("{}\n", wrapper_content)).unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.arg("config")
.arg("shell")
.arg("uninstall")
.arg("fish")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
✓ Removed shell extension for fish @ ~/.config/fish/functions/wt.fish
✓ Removed integration from 1 shell
↳ Restart shell to complete uninstall
");
});
// Verify the fish config file was deleted
assert!(!fish_config.exists());
}
#[rstest]
fn test_install_uninstall_roundtrip(repo: TestRepo, temp_home: TempDir) {
// Create initial config file
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(
&zshrc_path,
"# Existing config\nexport PATH=$HOME/bin:$PATH\n",
)
.unwrap();
// First install
{
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("zsh")
.arg("--yes")
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute command");
assert!(output.status.success(), "Install should succeed");
}
// Verify installed
let content = fs::read_to_string(&zshrc_path).unwrap();
assert!(content.contains("wt config shell init zsh"));
// Then uninstall
{
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.arg("config")
.arg("shell")
.arg("uninstall")
.arg("zsh")
.arg("--yes")
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute command");
assert!(output.status.success(), "Uninstall should succeed");
}
// Verify uninstalled but other content preserved
let content = fs::read_to_string(&zshrc_path).unwrap();
assert!(
!content.contains("wt config shell init"),
"Integration should be removed"
);
assert!(
content.contains("# Existing config"),
"Comment should be preserved"
);
assert!(
content.contains("export PATH=$HOME/bin:$PATH"),
"PATH export should be preserved"
);
}
#[rstest]
fn test_uninstall_scan_removes_all_worktrunk_zsh_lines(repo: TestRepo, temp_home: TempDir) {
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(
&zshrc_path,
"# Existing config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init zsh)\"; fi\n",
)
.unwrap();
let mut install_cmd = wt_command();
repo.configure_wt_cmd(&mut install_cmd);
set_temp_home_env(&mut install_cmd, temp_home.path());
install_cmd.env("SHELL", "/bin/zsh");
install_cmd.env("WORKTRUNK_TEST_COMPINIT_CONFIGURED", "1");
install_cmd
.args([
"config", "shell", "install", "zsh", "--yes", "--cmd", "git-wt",
])
.current_dir(repo.root_path());
let install_output = install_cmd.output().expect("Failed to execute install");
assert!(
install_output.status.success(),
"Install should succeed:\nstderr: {}",
String::from_utf8_lossy(&install_output.stderr)
);
let installed = fs::read_to_string(&zshrc_path).unwrap();
assert!(installed.contains("git-wt config shell init zsh"));
// A hand-written line in the `git wt` dispatch form, which install never
// emits: uninstall recognizes worktrunk's integration by whatever name it
// is invoked under, not only the names install writes.
fs::write(
&zshrc_path,
format!("{installed}eval \"$(git wt config shell init zsh)\"\n"),
)
.unwrap();
let mut uninstall_cmd = wt_command();
repo.configure_wt_cmd(&mut uninstall_cmd);
set_temp_home_env(&mut uninstall_cmd, temp_home.path());
uninstall_cmd.env("SHELL", "/bin/zsh");
uninstall_cmd
.args(["config", "shell", "uninstall", "zsh", "--yes"])
.current_dir(repo.root_path());
let uninstall_output = uninstall_cmd.output().expect("Failed to execute uninstall");
assert!(
uninstall_output.status.success(),
"Uninstall should succeed:\nstderr: {}",
String::from_utf8_lossy(&uninstall_output.stderr)
);
let content = fs::read_to_string(&zshrc_path).unwrap();
assert!(
!content.contains("git-wt config shell init zsh"),
"Custom command integration should be removed"
);
assert!(
!content.contains("wt config shell init zsh"),
"Default command and `git wt` integration should also be removed (scan-all)"
);
assert!(
content.contains("# Existing config"),
"Unrelated comment should be preserved"
);
}
/// Uninstall deletes lines out of a file the user owns, so a line that merely
/// quotes an integration command must survive — and every line it does take
/// must be named in the output, since `--yes` skips the confirmation.
#[rstest]
fn test_uninstall_preserves_user_lines_quoting_the_init_command(
repo: TestRepo,
temp_home: TempDir,
) {
// Each survivor puts `wt config shell init` in argument position while an
// exec keyword sits elsewhere on the line — the shape that makes the
// whole-line execution-context check alone insufficient.
let survivors = [
r#"alias setup='echo "run: wt config shell init fish | source"'"#,
r#"echo "wt config shell init bash | source""#,
];
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(
&zshrc_path,
format!(
"{}\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init zsh)\"; fi\n",
survivors.join("\n"),
),
)
.unwrap();
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.args(["config", "shell", "uninstall", "zsh", "--yes"])
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute uninstall");
assert!(
output.status.success(),
"Uninstall should succeed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let content = fs::read_to_string(&zshrc_path).unwrap();
for survivor in survivors {
assert!(
content.contains(survivor),
"User's own line should survive uninstall: {survivor}\nremaining:\n{content}"
);
}
assert!(
!content.contains("command wt config shell init zsh"),
"worktrunk's own integration line should be removed:\n{content}"
);
// The removed line is named, so a --yes run still shows what it took.
// The gutter syntax-highlights it, so compare against stripped output.
let stderr = String::from_utf8_lossy(&output.stderr);
let plain = regex::Regex::new(r"\x1b\[[0-9;]*m")
.unwrap()
.replace_all(&stderr, "");
assert!(
plain.contains("command wt config shell init zsh"),
"Uninstall should show the line it removed:\n{plain}"
);
}
#[rstest]
fn test_uninstall_scan_removes_custom_cmd_fish_files(repo: TestRepo, temp_home: TempDir) {
let fish_functions = temp_home.path().join(".config/fish/functions");
let fish_completions = temp_home.path().join(".config/fish/completions");
fs::create_dir_all(&fish_functions).unwrap();
fs::create_dir_all(&fish_completions).unwrap();
let default_function = fish_functions.join("wt.fish");
let default_completion = fish_completions.join("wt.fish");
fs::write(&default_function, "function wt\nend\n").unwrap();
fs::write(&default_completion, "complete --command wt\n").unwrap();
// A second worktrunk-managed wrapper left over from an earlier install
// under another name, and an orphaned completion whose wrapper is already
// gone: both carry worktrunk's markers, so scan-all removes them too.
let stale_wrapper = fish_functions.join("old-wt.fish");
fs::write(
&stale_wrapper,
"# worktrunk shell integration for fish\nfunction old-wt\nend\n",
)
.unwrap();
let orphan_completion = fish_completions.join("stale.fish");
fs::write(
&orphan_completion,
"# worktrunk completions for fish\ncomplete --command stale\n",
)
.unwrap();
let mut install_cmd = wt_command();
repo.configure_wt_cmd(&mut install_cmd);
set_temp_home_env(&mut install_cmd, temp_home.path());
install_cmd.env("SHELL", "/bin/fish");
install_cmd
.args([
"config", "shell", "install", "fish", "--yes", "--cmd", "git-wt",
])
.current_dir(repo.root_path());
let install_output = install_cmd.output().expect("Failed to execute install");
assert!(
install_output.status.success(),
"Install should succeed:\nstderr: {}",
String::from_utf8_lossy(&install_output.stderr)
);
let custom_function = fish_functions.join("git-wt.fish");
let custom_completion = fish_completions.join("git-wt.fish");
assert!(custom_function.exists());
assert!(custom_completion.exists());
let mut uninstall_cmd = wt_command();
repo.configure_wt_cmd(&mut uninstall_cmd);
set_temp_home_env(&mut uninstall_cmd, temp_home.path());
uninstall_cmd.env("SHELL", "/bin/fish");
uninstall_cmd
.args(["config", "shell", "uninstall", "fish", "--yes"])
.current_dir(repo.root_path());
let uninstall_output = uninstall_cmd.output().expect("Failed to execute uninstall");
assert!(
uninstall_output.status.success(),
"Uninstall should succeed:\nstderr: {}",
String::from_utf8_lossy(&uninstall_output.stderr)
);
// Scan-all removes every worktrunk-managed file (git-wt install, the stale
// second wrapper, the orphaned completion) but leaves the hand-written
// wt.fish stubs alone — they carry no worktrunk marker.
assert!(!custom_function.exists());
assert!(!custom_completion.exists());
assert!(!stale_wrapper.exists());
assert!(!orphan_completion.exists());
assert!(default_function.exists());
assert!(default_completion.exists());
}
#[cfg(unix)]
#[rstest]
fn test_uninstall_shell_powershell_removes_integration_line(repo: TestRepo, temp_home: TempDir) {
// Unix-only: the Windows profile path comes from the real Documents
// folder, which a temp HOME does not redirect.
let profile_dir = temp_home.path().join(".config/powershell");
fs::create_dir_all(&profile_dir).unwrap();
let profile = profile_dir.join("Microsoft.PowerShell_profile.ps1");
fs::write(
&profile,
"# my profile\nInvoke-Expression (& wt config shell init powershell)\nSet-Alias ll Get-ChildItem\n",
)
.unwrap();
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.args(["config", "shell", "uninstall", "powershell", "--yes"])
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute uninstall");
assert!(
output.status.success(),
"Uninstall should succeed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
// The old pre-Out-String integration line is removed; everything else stays.
let content = fs::read_to_string(&profile).unwrap();
assert!(
!content.contains("config shell init"),
"Integration line should be removed:\n{content}"
);
assert!(content.contains("# my profile"));
assert!(content.contains("Set-Alias ll Get-ChildItem"));
}
#[rstest]
fn test_uninstall_scan_removes_custom_cmd_nushell_file(repo: TestRepo, temp_home: TempDir) {
let home = canonical_temp_home(&temp_home);
// Pin the vendor-autoload dir so the target is deterministic across
// platforms and independent of whether `nu` is on the runner's PATH.
let nu_autoload = home.join(".local/share/nushell/vendor/autoload");
fs::create_dir_all(&nu_autoload).unwrap();
let default_config = nu_autoload.join("wt.nu");
fs::write(&default_config, "def --env --wrapped wt [] {}\n").unwrap();
let mut install_cmd = wt_command();
repo.configure_wt_cmd(&mut install_cmd);
set_temp_home_env(&mut install_cmd, temp_home.path());
install_cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &nu_autoload);
install_cmd.env("SHELL", "/bin/nu");
install_cmd
.args([
"config", "shell", "install", "nu", "--yes", "--cmd", "git-wt",
])
.current_dir(repo.root_path());
let install_output = install_cmd.output().expect("Failed to execute install");
assert!(
install_output.status.success(),
"Install should succeed:\nstderr: {}",
String::from_utf8_lossy(&install_output.stderr)
);
let custom_config = nu_autoload.join("git-wt.nu");
assert!(custom_config.exists());
let mut uninstall_cmd = wt_command();
repo.configure_wt_cmd(&mut uninstall_cmd);
set_temp_home_env(&mut uninstall_cmd, temp_home.path());
uninstall_cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &nu_autoload);
uninstall_cmd.env("SHELL", "/bin/nu");
uninstall_cmd
.args(["config", "shell", "uninstall", "nu", "--yes"])
.current_dir(repo.root_path());
let uninstall_output = uninstall_cmd.output().expect("Failed to execute uninstall");
assert!(
uninstall_output.status.success(),
"Uninstall should succeed:\nstderr: {}",
String::from_utf8_lossy(&uninstall_output.stderr)
);
// Scan-all removes git-wt.nu (worktrunk-managed by content) but leaves the
// hand-written wt.nu stub alone (no `config shell init … | source` marker).
assert!(!custom_config.exists());
assert!(default_config.exists());
}
#[rstest]
fn test_install_uninstall_no_blank_line_accumulation(repo: TestRepo, temp_home: TempDir) {
// Create initial config file matching the user's real zshrc structure
let zshrc_path = temp_home.path().join(".zshrc");
let initial_content =
"[ -f ~/.fzf.zsh ] && source ~/.fzf.zsh\n\nautoload -Uz compinit && compinit\n";
fs::write(&zshrc_path, initial_content).unwrap();
// Install
{
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.args(["config", "shell", "install", "zsh", "--yes"]);
cmd.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute command");
assert!(output.status.success(), "Install should succeed");
}
let after_install = fs::read_to_string(&zshrc_path).unwrap();
assert!(
after_install.contains("wt config shell init zsh"),
"Integration should be added"
);
// Uninstall
{
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.args(["config", "shell", "uninstall", "zsh", "--yes"]);
cmd.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute command");
assert!(output.status.success(), "Uninstall should succeed");
}
let after_uninstall = fs::read_to_string(&zshrc_path).unwrap();
assert_eq!(
initial_content, after_uninstall,
"Uninstall should restore original content"
);
// Re-install
{
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.args(["config", "shell", "install", "zsh", "--yes"]);
cmd.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute command");
assert!(output.status.success(), "Re-install should succeed");
}
let after_reinstall = fs::read_to_string(&zshrc_path).unwrap();
// Key assertion: re-install should produce the same result as initial install
// (no accumulation of blank lines)
assert_eq!(
after_install, after_reinstall,
"Re-install should produce same result as initial install.\n\
After first install:\n{after_install}\n---\n\
After uninstall:\n{after_uninstall}\n---\n\
After re-install:\n{after_reinstall}"
);
}
#[rstest]
fn test_configure_shell_no_warning_when_compinit_enabled(repo: TestRepo, temp_home: TempDir) {
// Create a .zshrc that enables compinit - detection should find it
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(
&zshrc_path,
"# Existing config\nautoload -Uz compinit && compinit\n",
)
.unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
// Canonicalize to handle macOS /var -> /private/var symlinks
cmd.env("ZDOTDIR", crate::common::canonicalize(temp_home.path()).unwrap_or_else(|_| temp_home.path().to_path_buf()));
cmd.env("WORKTRUNK_TEST_COMPINIT_CONFIGURED", "1"); // Bypass zsh subprocess check (unreliable on CI)
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("zsh")
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
✓ Added shell extension & completions for zsh @ ~/.zshrc
✓ Configured 1 shell
↳ Restart shell to activate shell integration
");
});
}
/// Even when installing all shells, we don't warn bash users about zsh compinit
#[rstest]
fn test_configure_shell_no_warning_for_bash_user(repo: TestRepo, temp_home: TempDir) {
// Create config files for both shells (no compinit in zshrc)
let zshrc_path = temp_home.path().join(".zshrc");
let bashrc_path = temp_home.path().join(".bashrc");
fs::write(&zshrc_path, "# Existing zsh config\n").unwrap();
fs::write(&bashrc_path, "# Existing bash config\n").unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/bash"); // User's primary shell is bash
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("--yes")
.current_dir(repo.root_path());
// Should NOT show compinit warning - user is a bash user, not zsh
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
✓ Added shell extension & completions for bash @ ~/.bashrc
✓ Added shell extension & completions for zsh @ ~/.zshrc
✓ Configured 2 shells
↳ Restart shell to activate shell integration
");
});
}
/// Test that explicitly targeting a shell creates the config file when it doesn't exist
#[rstest]
fn test_configure_shell_create_zshrc_when_missing(repo: TestRepo, temp_home: TempDir) {
// Don't create .zshrc - it doesn't exist
let zshrc_path = temp_home.path().join(".zshrc");
assert!(!zshrc_path.exists(), "zshrc should not exist before test");
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
// Force compinit warning for deterministic tests across environments
cmd.env("WORKTRUNK_TEST_COMPINIT_MISSING", "1");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("zsh") // Explicitly target zsh
.arg("--yes")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
✓ Created shell extension & completions for zsh @ ~/.zshrc
✓ Configured 1 shell
▲ Completions require compinit; add to ~/.zshrc before the wt line:
  autoload -Uz compinit && compinit
↳ Restart shell to activate shell integration
");
});
// Verify the file was created with correct content
assert!(zshrc_path.exists(), "zshrc should exist after install");
let content = fs::read_to_string(&zshrc_path).unwrap();
assert!(
content.contains("eval \"$(command wt config shell init zsh)\""),
"Created file should contain wt integration: {}",
content
);
}
/// Only `install zsh` or `install` (all) should trigger zsh-specific warnings
#[rstest]
fn test_configure_shell_no_warning_for_fish_install(repo: TestRepo, temp_home: TempDir) {
// Create fish conf.d directory
let fish_conf_d = temp_home.path().join(".config/fish/conf.d");
fs::create_dir_all(&fish_conf_d).unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh"); // User is zsh user, but installing fish
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("fish") // Specifically installing fish, not zsh
.arg("--yes")
.current_dir(repo.root_path());
// Should NOT show compinit warning - we're installing fish, not zsh
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
✓ Created shell extension for fish @ ~/.config/fish/functions/wt.fish
✓ Created completions for fish @ ~/.config/fish/completions/wt.fish
✓ Configured 1 shell
");
});
}
#[rstest]
fn test_configure_shell_no_warning_when_already_configured(repo: TestRepo, temp_home: TempDir) {
// Create a .zshrc that ALREADY has wt integration (no compinit)
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(
&zshrc_path,
"# Existing config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init zsh)\"; fi\n",
)
.unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("zsh")
.arg("--yes")
.current_dir(repo.root_path());
// Should NOT show compinit warning - zsh is AlreadyExists, not newly added
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
○ Already configured shell extension & completions for zsh @ ~/.zshrc
○ All shells already configured
");
});
}
#[rstest]
fn test_configure_shell_no_warning_when_shell_unset(repo: TestRepo, temp_home: TempDir) {
// Create zsh and bash config files (no compinit)
let zshrc_path = temp_home.path().join(".zshrc");
let bashrc_path = temp_home.path().join(".bashrc");
fs::write(&zshrc_path, "# Existing zsh config\n").unwrap();
fs::write(&bashrc_path, "# Existing bash config\n").unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env_remove("SHELL"); // Explicitly unset SHELL
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("--yes")
.current_dir(repo.root_path());
// Should NOT show compinit warning - can't determine user's shell
assert_cmd_snapshot!(cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
✓ Added shell extension & completions for bash @ ~/.bashrc
✓ Added shell extension & completions for zsh @ ~/.zshrc
✓ Configured 2 shells
");
});
}
#[rstest]
fn test_configure_shell_dry_run(repo: TestRepo, temp_home: TempDir) {
// Create a fake .zshrc file
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(&zshrc_path, "# Existing config\n").unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("zsh")
.arg("--dry-run")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd);
});
// Verify the file was NOT modified
let content = fs::read_to_string(&zshrc_path).unwrap();
assert!(
!content.contains("wt config shell init"),
"File should not be modified with --dry-run"
);
assert_eq!(content, "# Existing config\n", "File should be unchanged");
}
#[rstest]
fn test_configure_shell_dry_run_multiple(repo: TestRepo, temp_home: TempDir) {
// Create multiple shell config files
let bash_config_path = temp_home.path().join(".bashrc");
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(&bash_config_path, "# Existing bash config\n").unwrap();
fs::write(&zshrc_path, "# Existing zsh config\n").unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("--dry-run")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd);
});
// Verify no files were modified
let bash_content = fs::read_to_string(&bash_config_path).unwrap();
assert!(
!bash_content.contains("wt config shell init"),
"Bash config should not be modified with --dry-run"
);
let zsh_content = fs::read_to_string(&zshrc_path).unwrap();
assert!(
!zsh_content.contains("wt config shell init"),
"Zsh config should not be modified with --dry-run"
);
}
#[rstest]
fn test_configure_shell_dry_run_already_configured(repo: TestRepo, temp_home: TempDir) {
// Create a fake .zshrc file with the line already present
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(
&zshrc_path,
"# Existing config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init zsh)\"; fi\n",
)
.unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("zsh")
.arg("--dry-run")
.current_dir(repo.root_path());
// Already configured - nothing to preview
assert_cmd_snapshot!(cmd);
});
}
#[rstest]
fn test_uninstall_shell_dry_run(repo: TestRepo, temp_home: TempDir) {
// Create a fake .zshrc file with wt integration
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(
&zshrc_path,
"# Existing config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init zsh)\"; fi\n",
)
.unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.arg("config")
.arg("shell")
.arg("uninstall")
.arg("zsh")
.arg("--dry-run")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd);
});
// Verify the file was NOT modified
let content = fs::read_to_string(&zshrc_path).unwrap();
assert!(
content.contains("wt config shell init"),
"File should not be modified with --dry-run"
);
}
/// Test dry-run with fish in legacy conf.d location (shows deprecated message)
#[rstest]
fn test_uninstall_shell_dry_run_fish(repo: TestRepo, temp_home: TempDir) {
// Create fish conf.d directory with wt.fish and completions
let conf_d = temp_home.path().join(".config/fish/conf.d");
fs::create_dir_all(&conf_d).unwrap();
let fish_config = conf_d.join("wt.fish");
fs::write(
&fish_config,
"if type -q wt; command wt config shell init fish | source; end\n",
)
.unwrap();
// Create completions file
let completions_d = temp_home.path().join(".config/fish/completions");
fs::create_dir_all(&completions_d).unwrap();
let completions_file = completions_d.join("wt.fish");
fs::write(
&completions_file,
"# worktrunk completions for fish\ncomplete --command wt\n",
)
.unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.arg("config")
.arg("shell")
.arg("uninstall")
.arg("fish")
.arg("--dry-run")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd);
});
// Verify files were NOT modified
assert!(fish_config.exists(), "Fish config should still exist");
assert!(
completions_file.exists(),
"Fish completions should still exist"
);
}
/// Test dry-run with fish in canonical functions/ location (shows normal message)
#[rstest]
fn test_uninstall_shell_dry_run_fish_canonical(repo: TestRepo, temp_home: TempDir) {
// Create fish functions directory with wt.fish (canonical location)
let functions = temp_home.path().join(".config/fish/functions");
fs::create_dir_all(&functions).unwrap();
let fish_config = functions.join("wt.fish");
// Write the exact wrapper content that install would create
let init =
worktrunk::shell::ShellInit::with_prefix(worktrunk::shell::Shell::Fish, "wt".to_string());
let wrapper_content = init.generate_fish_wrapper().unwrap();
fs::write(&fish_config, format!("{}\n", wrapper_content)).unwrap();
// Create completions file
let completions_d = temp_home.path().join(".config/fish/completions");
fs::create_dir_all(&completions_d).unwrap();
let completions_file = completions_d.join("wt.fish");
fs::write(
&completions_file,
"# worktrunk completions for fish\ncomplete --command wt\n",
)
.unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/fish");
cmd.arg("config")
.arg("shell")
.arg("uninstall")
.arg("fish")
.arg("--dry-run")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd);
});
// Verify files were NOT modified
assert!(fish_config.exists(), "Fish config should still exist");
assert!(
completions_file.exists(),
"Fish completions should still exist"
);
}
#[rstest]
fn test_uninstall_shell_dry_run_multiple(repo: TestRepo, temp_home: TempDir) {
// Create multiple shell configs with wt integration
let bash_config_path = temp_home.path().join(".bashrc");
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(
&bash_config_path,
"# Bash config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init bash)\"; fi\n",
)
.unwrap();
fs::write(
&zshrc_path,
"# Zsh config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init zsh)\"; fi\n",
)
.unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/zsh");
cmd.arg("config")
.arg("shell")
.arg("uninstall")
.arg("--dry-run")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd);
});
// Verify no files were modified
let bash_content = fs::read_to_string(&bash_config_path).unwrap();
assert!(
bash_content.contains("wt config shell init"),
"Bash config should not be modified with --dry-run"
);
let zsh_content = fs::read_to_string(&zshrc_path).unwrap();
assert!(
zsh_content.contains("wt config shell init"),
"Zsh config should not be modified with --dry-run"
);
}
/// Regression: nushell's wrapper wires completions inline via the
/// `@complete` attribute on the wrapper's rest parameter, so its
/// extension does ship completions — only Fish keeps completions in a
/// separate file. The `--dry-run uninstall` path (and the other four
/// label sites) must reflect that and label nushell as
/// `shell extension & completions`.
#[rstest]
fn test_uninstall_shell_dry_run_nushell(repo: TestRepo, temp_home: TempDir) {
let autoload = temp_home
.path()
.join(".local/share/nushell/vendor/autoload");
// Install nushell integration first so dry-run uninstall finds something.
let mut install_cmd = wt_command();
repo.configure_wt_cmd(&mut install_cmd);
set_temp_home_env(&mut install_cmd, temp_home.path());
install_cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload);
install_cmd.env("SHELL", "/bin/nu");
install_cmd
.args(["config", "shell", "install", "nu", "--yes"])
.current_dir(repo.root_path());
let install_output = install_cmd.output().expect("Failed to execute install");
assert!(
install_output.status.success(),
"Install precondition failed:\nstderr: {}",
String::from_utf8_lossy(&install_output.stderr)
);
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload);
cmd.env("SHELL", "/bin/nu");
cmd.args(["config", "shell", "uninstall", "nu", "--dry-run"])
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute uninstall");
assert!(
output.status.success(),
"Dry-run uninstall should succeed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
// The dry-run preview is the command's answer, so it lands on stdout.
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("shell extension & completions for") && stdout.contains("nu"),
"Dry-run output should label nushell as 'shell extension & completions':\n{stdout}"
);
}
// PTY-based tests for interactive install preview
#[cfg(all(unix, feature = "shell-integration-tests"))]
mod pty_tests {
use crate::common::pty::exec_cmd_in_pty_prompted;
use crate::common::{
TestRepo, add_pty_filters, configure_pty_command, repo, temp_home, wt_bin,
};
use insta::assert_snapshot;
use portable_pty::CommandBuilder;
use rstest::rstest;
use std::fs;
use tempfile::TempDir;
/// Execute shell install command in a PTY, waiting for prompt before input
fn exec_install_in_pty(temp_home: &TempDir, repo: &TestRepo, input: &str) -> (String, i32) {
let mut cmd = CommandBuilder::new(wt_bin());
cmd.arg("-C");
cmd.arg(repo.root_path());
cmd.arg("config");
cmd.arg("shell");
cmd.arg("install");
cmd.cwd(repo.root_path());
configure_pty_command(&mut cmd);
cmd.env("HOME", temp_home.path());
cmd.env("XDG_CONFIG_HOME", temp_home.path().join(".config"));
// Detection keys on SHELL alone, which puts the test on the single-zsh
// path: the baseline's "not installed" and empty-parent-shell defaults
// rule out the host's process ancestry — the test harness and whatever
// shell ran it, zsh on a dev box and bash on CI, flipping the zsh-only
// compinit/restart output between them.
cmd.env("SHELL", "/bin/zsh");
// Skip the compinit probe and force the advisory to appear. The probe spawns
// `zsh -ic` which triggers global zshrc configs that can produce "insecure
// directories" warnings on some CI environments. These warnings go to /dev/tty
// and leak into PTY output despite our ZSH_DISABLE_COMPFIX suppression.
// Using MISSING=1 skips the probe while still showing the compinit advisory.
cmd.env("WORKTRUNK_TEST_COMPINIT_MISSING", "1");
exec_cmd_in_pty_prompted(cmd, &[input], "[y/N")
}
/// Create insta settings for install PTY tests.
fn install_pty_settings(temp_home: &TempDir) -> insta::Settings {
let mut settings = insta::Settings::clone_current();
// Add PTY filters (CRLF, ^D, leading ANSI resets)
add_pty_filters(&mut settings);
// Remove echoed user input at end of prompt line (PTY echo timing varies).
// The prompt ends with [y/N/?] (possibly with ANSI codes) and then the echoed input appears.
settings.add_filter(r"(\[y/N/\?\](?:\x1b\[22m)?) [yn]", "$1 ");
// Remove standalone echoed input lines (just y or n on their own line)
settings.add_filter(r"^[yn]\n", "");
// Replace temp home path with ~/
settings.add_filter(&regex::escape(&temp_home.path().to_string_lossy()), "~");
settings
}
/// Test that `wt config shell install` shows preview with gutter-formatted config lines
#[rstest]
fn test_install_preview_with_gutter(repo: TestRepo, temp_home: TempDir) {
// Create zsh config file
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(&zshrc_path, "# Existing config\n").unwrap();
let (output, exit_code) = exec_install_in_pty(&temp_home, &repo, "y\n");
assert_eq!(exit_code, 0);
install_pty_settings(&temp_home).bind(|| {
assert_snapshot!(output.trim_start_matches('\n'));
});
}
/// Test that declining install shows preview but doesn't modify files
#[rstest]
fn test_install_preview_declined(repo: TestRepo, temp_home: TempDir) {
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(&zshrc_path, "# Existing config\n").unwrap();
let (output, exit_code) = exec_install_in_pty(&temp_home, &repo, "n\n");
// User declined, so exit code is 1
assert_eq!(exit_code, 1);
install_pty_settings(&temp_home).bind(|| {
assert_snapshot!(output.trim_start_matches('\n'));
});
// Verify file was not modified
let content = fs::read_to_string(&zshrc_path).unwrap();
assert!(
!content.contains("wt config shell init"),
"File should not be modified when user declines"
);
}
/// Typing `?` at the install prompt re-shows the preview (the interactive
/// re-preview path), then declining with `n` leaves the rcfile untouched.
#[rstest]
fn test_install_preview_question_mark(repo: TestRepo, temp_home: TempDir) {
let zshrc_path = temp_home.path().join(".zshrc");
fs::write(&zshrc_path, "# Existing config\n").unwrap();
// `?` re-shows the preview, then `n` declines (both lines are buffered
// and consumed across the two prompt cycles).
let (output, exit_code) = exec_install_in_pty(&temp_home, &repo, "?\nn\n");
assert_eq!(exit_code, 1, "declining returns exit 1:\n{output}");
assert!(
output.contains("wt config shell init"),
"Typing ? should re-show the install preview:\n{output}"
);
// Declined, so the file is untouched.
let content = fs::read_to_string(&zshrc_path).unwrap();
assert!(
!content.contains("wt config shell init"),
"File should not be modified when user declines"
);
}
/// Declining the interactive `wt config shell uninstall` prompt
/// exercises `prompt_for_uninstall_confirmation` — the one render
/// path that neither `--yes` nor `--dry-run` reaches. Verifies the
/// preview labels zsh as `shell extension & completions` (matching
/// the dedicated `shell_extension_label` helper) and that declining
/// leaves the rcfile untouched.
#[rstest]
fn test_uninstall_preview_declined(repo: TestRepo, temp_home: TempDir) {
let zshrc_path = temp_home.path().join(".zshrc");
let initial = "# Existing config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init zsh)\"; fi\n";
fs::write(&zshrc_path, initial).unwrap();
let mut cmd = CommandBuilder::new(wt_bin());
cmd.arg("-C");
cmd.arg(repo.root_path());
cmd.arg("config");
cmd.arg("shell");
cmd.arg("uninstall");
cmd.cwd(repo.root_path());
configure_pty_command(&mut cmd);
cmd.env("HOME", temp_home.path());
cmd.env("XDG_CONFIG_HOME", temp_home.path().join(".config"));
cmd.env("SHELL", "/bin/zsh");
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["n\n"], "[y/N");
// Decline → exit code 1, file unchanged.
assert_eq!(exit_code, 1);
assert!(
output.contains("shell extension & completions") && output.contains("zsh"),
"Preview should label zsh as 'shell extension & completions':\n{output}"
);
let content = fs::read_to_string(&zshrc_path).unwrap();
assert_eq!(
content, initial,
"Declining uninstall should leave the rcfile untouched",
);
}
}
/// Test installing nushell shell integration
///
/// Runs `install nu --yes` and verifies the wrapper file was created in the
/// vendor-autoload directory (issue #2878). Pins that directory via
/// `WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR` so the target is deterministic on
/// every platform (and doesn't depend on `nu` being installed).
#[rstest]
fn test_configure_shell_nushell(repo: TestRepo, temp_home: TempDir) {
let home = canonical_temp_home(&temp_home);
let autoload = home.join(".local/share/nushell/vendor/autoload");
let nu_config = autoload.join("wt.nu");
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload);
cmd.env("SHELL", "/bin/nu");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("nu")
.arg("--yes")
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute command");
assert!(
output.status.success(),
"Install should succeed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Created shell extension & completions for") && stderr.contains("nu"),
"Output should show nushell was created:\n{}",
stderr
);
assert!(
nu_config.exists(),
"wt.nu should be created in the vendor-autoload dir at {:?}",
nu_config
);
let content = fs::read_to_string(&nu_config).unwrap();
assert!(
content.contains("def --env --wrapped wt"),
"Should contain nushell function definition: {}",
content
);
}
/// Dry-run install for nushell previews what would be added, on stdout (the
/// command's answer), including the experimental note, without writing the file.
#[rstest]
fn test_configure_shell_nushell_dry_run(repo: TestRepo, temp_home: TempDir) {
let home = canonical_temp_home(&temp_home);
let autoload = home.join(".local/share/nushell/vendor/autoload");
let nu_config = autoload.join("wt.nu");
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload);
cmd.env("SHELL", "/bin/nu");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("nu")
.arg("--dry-run")
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute command");
assert!(
output.status.success(),
"Dry-run install should succeed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
// The dry-run preview is the command's answer, so it lands on stdout, and
// for nushell it carries the experimental note.
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("shell extension & completions for") && stdout.contains("nu"),
"Dry-run preview should show nushell would be added:\n{stdout}"
);
assert!(
stdout.contains("Nushell support is experimental"),
"Dry-run preview should include the nushell experimental note:\n{stdout}"
);
// Nothing should be written in dry-run mode.
assert!(
!nu_config.exists(),
"wt.nu should NOT be created with --dry-run: {nu_config:?}"
);
}
/// Test uninstalling nushell shell integration
///
/// Installs nushell integration first, then uninstalls it.
/// This covers the nushell-specific uninstall block in configure_shell.
#[rstest]
fn test_uninstall_shell_nushell(repo: TestRepo, temp_home: TempDir) {
let home = canonical_temp_home(&temp_home);
let autoload = home.join(".local/share/nushell/vendor/autoload");
let nu_config = autoload.join("wt.nu");
// First install to create the wrapper file
let mut install_cmd = wt_command();
repo.configure_wt_cmd(&mut install_cmd);
set_temp_home_env(&mut install_cmd, temp_home.path());
install_cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload);
install_cmd.env("SHELL", "/bin/nu");
install_cmd
.args(["config", "shell", "install", "nu", "--yes"])
.current_dir(repo.root_path());
let install_output = install_cmd.output().expect("Failed to execute install");
assert!(
install_output.status.success(),
"Install should succeed:\nstderr: {}",
String::from_utf8_lossy(&install_output.stderr)
);
assert!(
nu_config.exists(),
"wt.nu should exist after install at {:?}",
nu_config
);
// Now uninstall
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload);
cmd.env("SHELL", "/bin/nu");
cmd.args(["config", "shell", "uninstall", "nu", "--yes"])
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute uninstall");
assert!(
output.status.success(),
"Uninstall should succeed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Removed shell extension & completions for") && stderr.contains("nu"),
"Output should show nushell was removed:\n{}",
stderr
);
// Verify the nushell config file was deleted
assert!(
!nu_config.exists(),
"wt.nu should be deleted after uninstall: {:?}",
nu_config
);
}
/// Test that nushell uninstall cleans up the wrapper at every candidate
/// location — the canonical vendor-autoload dir and the legacy
/// `<config-dir>/vendor/autoload` paths older worktrunk stranded files at
/// (issue #2878). `config_paths(Nushell)` returns all of them and uninstall
/// iterates the full list.
#[rstest]
fn test_uninstall_nushell_cleans_all_candidate_locations(repo: TestRepo, temp_home: TempDir) {
let home = canonical_temp_home(&temp_home);
let autoload = home.join(".local/share/nushell/vendor/autoload");
let canonical = autoload.join("wt.nu");
// Install nushell integration to the canonical vendor-autoload dir.
let mut install_cmd = wt_command();
repo.configure_wt_cmd(&mut install_cmd);
set_temp_home_env(&mut install_cmd, temp_home.path());
install_cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload);
install_cmd.env("SHELL", "/bin/nu");
install_cmd
.args(["config", "shell", "install", "nu", "--yes"])
.current_dir(repo.root_path());
let install_output = install_cmd.output().expect("Failed to execute install");
assert!(
install_output.status.success(),
"Install should succeed:\nstderr: {}",
String::from_utf8_lossy(&install_output.stderr)
);
assert!(canonical.exists(), "Canonical config should exist");
// Simulate a wrapper stranded by an older worktrunk at the legacy
// config-dir location (set_temp_home_env points XDG_CONFIG_HOME at
// ~/.config, so that's a legacy candidate).
let legacy_dir = home.join(".config/nushell/vendor/autoload");
fs::create_dir_all(&legacy_dir).unwrap();
let legacy_config = legacy_dir.join("wt.nu");
fs::copy(&canonical, &legacy_config).unwrap();
// Uninstall should remove the wrapper at both the canonical and legacy paths.
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload);
cmd.env("SHELL", "/bin/nu");
cmd.args(["config", "shell", "uninstall", "nu", "--yes"])
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute uninstall");
assert!(
output.status.success(),
"Uninstall should succeed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
// Both locations should be cleaned up
assert!(
!canonical.exists(),
"Canonical wrapper should be deleted: {canonical:?}"
);
assert!(
!legacy_config.exists(),
"Stranded legacy wrapper should be deleted: {legacy_config:?}"
);
}
/// Test that WORKTRUNK_TEST_POWERSHELL_ENV=1 triggers PowerShell auto-detection.
/// This simulates the Windows behavior where we detect PowerShell when SHELL is not set.
#[rstest]
#[cfg_attr(
windows,
ignore = "Windows uses Documents folder which can't be easily overridden"
)]
fn test_powershell_env_detection(repo: TestRepo, temp_home: TempDir) {
// Create the PowerShell config directory (Unix: ~/.config/powershell)
// Note: On Windows, PowerShell uses Documents/ which dirs::document_dir() returns.
// This test only runs on Unix where we can control the path via HOME.
let powershell_dir = temp_home.path().join(".config/powershell");
fs::create_dir_all(&powershell_dir).unwrap();
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
// Force PowerShell detection via test env var
cmd.env("WORKTRUNK_TEST_POWERSHELL_ENV", "1");
// Set SHELL to something non-PowerShell to ensure we're testing the override
cmd.env("SHELL", "/bin/bash");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("--yes")
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute command");
assert!(output.status.success(), "Command should succeed");
let stderr = String::from_utf8_lossy(&output.stderr);
// Check that PowerShell was configured (not skipped)
assert!(
stderr.contains("Created shell extension & completions for")
&& stderr.contains("powershell"),
"Output should show PowerShell was created:\n{}",
stderr
);
// Verify the PowerShell profile was created
let profile_path = powershell_dir.join("Microsoft.PowerShell_profile.ps1");
assert!(
profile_path.exists(),
"PowerShell profile should be created at {:?}",
profile_path
);
let content = fs::read_to_string(&profile_path).unwrap();
assert!(
content.contains("wt config shell init powershell"),
"Profile should contain shell init: {}",
content
);
}
/// Test that nushell gets auto-configured when detected, even without vendor/autoload dir.
///
/// Parallels test_powershell_env_detection: when nushell is detected on the system,
/// `wt config shell install` should create vendor/autoload/ and install the wrapper,
/// rather than skipping with "vendor/autoload not found".
#[rstest]
fn test_nushell_auto_detection_creates_vendor_autoload(repo: TestRepo, temp_home: TempDir) {
// Don't create vendor/autoload - the whole point is that it doesn't exist yet
// but nushell IS detected on the system
let home = canonical_temp_home(&temp_home);
let autoload = home.join(".local/share/nushell/vendor/autoload");
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
// Force nushell detection via test env var (parallels WORKTRUNK_TEST_POWERSHELL_ENV)
cmd.env("WORKTRUNK_TEST_NUSHELL_ENV", "1");
cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload);
cmd.env("SHELL", "/bin/zsh");
cmd.arg("config")
.arg("shell")
.arg("install")
.arg("--yes")
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute command");
assert!(
output.status.success(),
"Command should succeed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
// Nushell should be configured, not skipped
assert!(
stderr.contains("Created shell extension & completions for") && stderr.contains("nu"),
"Nushell should be auto-configured when detected:\n{}",
stderr
);
assert!(
!stderr.contains("Skipped nu"),
"Nushell should not be skipped when detected:\n{}",
stderr
);
// Verify the nushell wrapper was created in the vendor-autoload directory
let nu_config = autoload.join("wt.nu");
assert!(
nu_config.exists(),
"wt.nu should be created at {:?}",
nu_config
);
let content = fs::read_to_string(&nu_config).unwrap();
assert!(
content.contains("def --env --wrapped wt"),
"Should contain nushell function definition: {}",
content
);
}
/// Test that `wt config show` detects nushell integration after install.
///
/// Verifies that `scan_for_detection_details` includes nushell vendor autoload
/// paths in its scan, so nushell appears in diagnostic output.
#[rstest]
fn test_config_show_detects_nushell_integration(mut repo: TestRepo, temp_home: TempDir) {
repo.setup_mock_ci_tools_unauthenticated();
let autoload = temp_home
.path()
.join(".local/share/nushell/vendor/autoload");
// Install nushell integration
let mut install_cmd = wt_command();
repo.configure_wt_cmd(&mut install_cmd);
set_temp_home_env(&mut install_cmd, temp_home.path());
install_cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload);
install_cmd.env("SHELL", "/bin/nu");
install_cmd
.args(["config", "shell", "install", "nu", "--yes"])
.current_dir(repo.root_path());
let install_output = install_cmd.output().expect("Failed to execute install");
assert!(
install_output.status.success(),
"Install should succeed:\nstderr: {}",
String::from_utf8_lossy(&install_output.stderr)
);
// Run `wt config show` and verify nushell integration is detected
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
repo.configure_mock_commands(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload);
cmd.env("SHELL", "/bin/nu");
cmd.args(["config", "show"]).current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute config show");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("wt.nu") || stdout.contains("nushell"),
"config show should detect nushell integration:\n{stdout}"
);
}
/// Installing nushell removes a wrapper stranded by older worktrunk at the
/// legacy `<config-dir>/vendor/autoload` location (issue #2878), and writes the
/// wrapper to the canonical vendor-autoload dir instead. Cross-platform: the
/// target dir is pinned via the test override so it doesn't depend on `nu`.
#[rstest]
fn test_nushell_install_cleans_stranded_legacy(repo: TestRepo, temp_home: TempDir) {
let home = canonical_temp_home(&temp_home);
let autoload = home.join(".local/share/nushell/vendor/autoload");
let canonical = autoload.join("wt.nu");
// A wrapper stranded by an older worktrunk at the legacy config-dir location
// (set_temp_home_env points XDG_CONFIG_HOME at ~/.config). The worktrunk
// header marks it as ours to remove.
let legacy_dir = home.join(".config/nushell/vendor/autoload");
fs::create_dir_all(&legacy_dir).unwrap();
let legacy = legacy_dir.join("wt.nu");
fs::write(
&legacy,
"# worktrunk shell integration for nushell\ndef --wrapped wt [...args] { command wt-old ...$args }\n",
)
.unwrap();
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload);
cmd.env("SHELL", "/bin/nu");
cmd.args(["config", "shell", "install", "nu", "--yes"])
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute install");
assert!(
output.status.success(),
"Install should succeed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
// Wrapper written to the canonical vendor-autoload dir...
assert!(
canonical.exists(),
"wrapper should be written to the vendor-autoload dir: {canonical:?}"
);
// ...and the stranded legacy copy removed.
assert!(
!legacy.exists(),
"stranded legacy wrapper should be removed: {legacy:?}"
);
// The cleanup is surfaced to the user.
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("deprecated"),
"install should report the stranded-file cleanup:\n{stderr}"
);
}
/// The stranded-file cleanup is scoped by path, so it reclaims `{cmd}.nu` at a
/// legacy autoload dir whatever the file holds — and touches nothing else in
/// that directory (issue #2878).
#[rstest]
fn test_nushell_install_reclaims_only_the_command_name(repo: TestRepo, temp_home: TempDir) {
let home = canonical_temp_home(&temp_home);
let autoload = home.join(".local/share/nushell/vendor/autoload");
let legacy_dir = home.join(".config/nushell/vendor/autoload");
fs::create_dir_all(&legacy_dir).unwrap();
// No worktrunk header: the path is what makes it worktrunk's.
let legacy = legacy_dir.join("wt.nu");
fs::write(&legacy, "def wt [] { echo hi }\n").unwrap();
// A neighbour under another name stays put.
let neighbour = legacy_dir.join("helpers.nu");
let neighbour_content = "def hi [] { echo hi }\n";
fs::write(&neighbour, neighbour_content).unwrap();
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &autoload);
cmd.env("SHELL", "/bin/nu");
cmd.args(["config", "shell", "install", "nu", "--yes"])
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute install");
assert!(
output.status.success(),
"Install should succeed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(autoload.join("wt.nu").exists(), "canonical wrapper missing");
assert!(
!legacy.exists(),
"install should reclaim the legacy wt.nu: {legacy:?}"
);
assert_eq!(
fs::read_to_string(&neighbour).unwrap(),
neighbour_content,
"a legacy-dir file under another name is not worktrunk's"
);
}
/// End-to-end guard for issue #2878 using the real `nu` binary (no override):
/// after `install nu`, the wrapper worktrunk wrote must live inside one of the
/// directories `nu` actually autoloads (`$nu.vendor-autoload-dirs`). This fails
/// against the old `<config-dir>/vendor/autoload` behavior — that path is in
/// neither autoload list — and passes once the write path follows
/// `$nu.vendor-autoload-dirs`.
///
/// Gated on `shell-integration-tests` (CI installs `nu` on non-Windows runners).
#[cfg(all(unix, feature = "shell-integration-tests"))]
#[rstest]
fn test_nushell_install_target_is_a_vendor_autoload_dir(repo: TestRepo, temp_home: TempDir) {
let home = canonical_temp_home(&temp_home);
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
set_temp_home_env(&mut cmd, temp_home.path());
cmd.env("SHELL", "/bin/nu");
cmd.args(["config", "shell", "install", "nu", "--yes"])
.current_dir(repo.root_path());
let output = cmd.output().expect("Failed to execute install");
assert!(
output.status.success(),
"Install should succeed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
// Ask the same `nu` (HOME isolated to temp_home) where it autoloads vendor
// files, then confirm worktrunk's wrapper landed in one of those dirs.
let listing = std::process::Command::new("nu")
.args([
"--no-config-file",
"-c",
"$nu.vendor-autoload-dirs | str join (char newline)",
])
.env("HOME", &home)
.env("XDG_CONFIG_HOME", home.join(".config"))
.output()
.expect("nu must be on PATH (installed by .github/actions/test-setup)");
assert!(
listing.status.success(),
"querying nu vendor-autoload-dirs failed:\nstderr: {}",
String::from_utf8_lossy(&listing.stderr)
);
let dirs = String::from_utf8(listing.stdout).unwrap();
let installed_in_autoload = dirs
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.any(|d| std::path::Path::new(d).join("wt.nu").exists());
assert!(
installed_in_autoload,
"worktrunk must install wt.nu into one of nu's vendor-autoload dirs (issue #2878).\n\
vendor-autoload-dirs:\n{dirs}"
);
}