worktrunk 0.39.0

A CLI for Git worktree management, designed for parallel AI agent workflows
Documentation
//! Tests for git-style custom subcommand dispatch (`wt-<name>`).

use crate::common::{
    mock_commands::{MockConfig, MockResponse},
    wt_command,
};
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;

/// Prepend `dir` to PATH on the given command.
fn prepend_path(cmd: &mut Command, dir: &Path) {
    let (path_var, current) = std::env::vars_os()
        .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
        .map(|(k, v)| (k.to_string_lossy().into_owned(), Some(v)))
        .unwrap_or(("PATH".to_string(), None));

    let mut paths: Vec<PathBuf> = current
        .as_deref()
        .map(|p| std::env::split_paths(p).collect())
        .unwrap_or_default();
    paths.insert(0, dir.to_path_buf());
    let new_path = std::env::join_paths(&paths).unwrap();
    cmd.env(path_var, new_path);
}

/// Create a mock `wt-<name>` binary in a temp dir, and return the dir.
fn mock_bin_dir(name: &str, response: MockResponse) -> TempDir {
    let dir = TempDir::new().unwrap();
    MockConfig::new(name)
        .command("_default", response)
        .write(dir.path());
    dir
}

#[test]
fn custom_subcommand_runs_wt_prefixed_binary_on_path() {
    // `wt wt-test-extcmd-ok` should find `wt-wt-test-extcmd-ok` on PATH.
    // We use a deliberately unique name so host PATH pollution doesn't match.
    let dir = mock_bin_dir("wt-wt-test-extcmd-ok", MockResponse::output("custom ran\n"));

    let mut cmd = wt_command();
    prepend_path(&mut cmd, dir.path());
    cmd.env("MOCK_CONFIG_DIR", dir.path());
    cmd.args(["wt-test-extcmd-ok", "arg1", "arg2"]);

    let output = cmd.output().expect("failed to run wt");
    assert!(
        output.status.success(),
        "expected success, stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "custom ran");
}

#[test]
fn custom_subcommand_not_found_prints_clap_error() {
    let mut cmd = wt_command();
    // Clear PATH so no `wt-*` binaries can be discovered, then add a single
    // empty dir so `which` has somewhere to look.
    let empty = TempDir::new().unwrap();
    cmd.env("PATH", empty.path());
    cmd.arg("definitely-not-a-wt-subcommand");

    let output = cmd.output().expect("failed to run wt");
    assert!(!output.status.success(), "expected failure");
    // clap's standard InvalidSubcommand exit code.
    assert_eq!(output.status.code(), Some(2));
    let stderr = strip_ansi(&String::from_utf8_lossy(&output.stderr));
    assert!(
        stderr.contains("unrecognized subcommand 'definitely-not-a-wt-subcommand'"),
        "stderr should use clap's native error format: {stderr}"
    );
    assert!(
        stderr.contains("Usage:") && stderr.contains("try '--help'"),
        "stderr should include Usage block and --help suggestion: {stderr}"
    );
}

#[test]
fn custom_subcommand_typo_suggests_closest_builtin() {
    let mut cmd = wt_command();
    let empty = TempDir::new().unwrap();
    cmd.env("PATH", empty.path());
    cmd.arg("siwtch"); // typo of `switch`

    let output = cmd.output().expect("failed to run wt");
    assert!(!output.status.success());
    let stderr = strip_ansi(&String::from_utf8_lossy(&output.stderr));
    assert!(
        stderr.contains("tip:") && stderr.contains("similar subcommand"),
        "stderr missing clap's similar-subcommand tip: {stderr}"
    );
    assert!(
        stderr.contains("'switch'"),
        "stderr should suggest 'switch': {stderr}"
    );
}

#[test]
fn custom_subcommand_nested_suggestion_wins_over_path_lookup() {
    // `wt squash` should suggest `wt step squash` even though `wt-squash` is
    // not on PATH. The nested tip is layered on top of clap's standard
    // unrecognized-subcommand error.
    let mut cmd = wt_command();
    let empty = TempDir::new().unwrap();
    cmd.env("PATH", empty.path());
    cmd.arg("squash");

    let output = cmd.output().expect("failed to run wt");
    assert!(!output.status.success());
    assert_eq!(output.status.code(), Some(2));
    let stderr = strip_ansi(&String::from_utf8_lossy(&output.stderr));
    assert!(
        stderr.contains("wt step squash"),
        "stderr should suggest 'wt step squash': {stderr}"
    );
}

/// Strip ANSI escape sequences so test assertions can match rendered text
/// without worrying about colour codes clap inserts.
fn strip_ansi(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c == '\u{1b}' {
            // CSI: ESC [ ... letter. Drop everything up to (and including)
            // the terminator (any ASCII letter).
            for next in chars.by_ref() {
                if next.is_ascii_alphabetic() {
                    break;
                }
            }
        } else {
            out.push(c);
        }
    }
    out
}

#[test]
fn custom_subcommand_propagates_exit_code() {
    let dir = mock_bin_dir(
        "wt-wt-test-extcmd-fail",
        MockResponse::exit(0).with_exit_code(42),
    );

    let mut cmd = wt_command();
    prepend_path(&mut cmd, dir.path());
    cmd.env("MOCK_CONFIG_DIR", dir.path());
    cmd.arg("wt-test-extcmd-fail");

    let output = cmd.output().expect("failed to run wt");
    assert_eq!(
        output.status.code(),
        Some(42),
        "expected exit code 42, stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    // When the custom command fails, wt should NOT add its own error line —
    // the child already reported whatever it needed to.
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        !stderr.contains("exited with status"),
        "wt should not decorate child failures: {stderr}"
    );
}

#[test]
fn custom_subcommand_respects_global_dash_c_flag() {
    // `wt -C <dir> foo` should run `wt-foo` with `<dir>` as its cwd. We verify
    // by reading argv, not cwd, because MockResponse doesn't reflect cwd —
    // instead we run `pwd` via a shell wrapper using the `file` response. Too
    // fiddly; simpler: use a unique temp dir as cwd and have the mock emit
    // `$PWD` via its stderr field. Alas, MockResponse just emits literals.
    //
    // Instead: point `-C` at a sentinel dir, and have the mock exit 0. The
    // assertion is indirect — if wt fails to chdir to the dir (because it
    // doesn't exist from the parent's cwd), the child will still run because
    // we pass an absolute path. So we verify by confirming the child ran and
    // exited cleanly even though the parent's cwd is unrelated.
    let target_dir = TempDir::new().unwrap();
    let dir = mock_bin_dir("wt-wt-test-extcmd-cwd", MockResponse::output("ok\n"));

    let mut cmd = wt_command();
    prepend_path(&mut cmd, dir.path());
    cmd.env("MOCK_CONFIG_DIR", dir.path());
    cmd.current_dir(std::env::temp_dir());
    cmd.args([
        "-C",
        target_dir.path().to_str().unwrap(),
        "wt-test-extcmd-cwd",
    ]);

    let output = cmd.output().expect("failed to run wt");
    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn custom_subcommand_passes_help_flag_through() {
    // `wt foo --help` should hand `--help` to `wt-foo`, not to wt itself.
    // The mock-stub has no built-in `--help` handler, so if `--help` reaches
    // it the mock falls through to `_default` (which we set to exit 0).
    let dir = mock_bin_dir(
        "wt-wt-test-extcmd-help",
        MockResponse::output("child got help\n"),
    );

    let mut cmd = wt_command();
    prepend_path(&mut cmd, dir.path());
    cmd.env("MOCK_CONFIG_DIR", dir.path());
    cmd.args(["wt-test-extcmd-help", "--help"]);

    let output = cmd.output().expect("failed to run wt");
    assert!(output.status.success());
    assert_eq!(
        String::from_utf8_lossy(&output.stdout).trim(),
        "child got help",
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}