use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use clap::error::{ContextKind, ContextValue, ErrorKind};
use worktrunk::git::WorktrunkError;
use crate::cli::build_command;
use crate::commands::{alias_names_for_suggestions, did_you_mean, try_alias};
use crate::enhance_and_exit_error;
pub(crate) fn handle_custom_command(
args: Vec<OsString>,
working_dir: Option<PathBuf>,
yes: bool,
) -> Result<()> {
let mut iter = args.into_iter();
let name_os = iter
.next()
.expect("clap guarantees at least one arg for external_subcommand variants");
let rest: Vec<OsString> = iter.collect();
let name = name_os
.to_str()
.ok_or_else(|| {
anyhow::anyhow!(
"subcommand name is not valid UTF-8: {}",
name_os.to_string_lossy()
)
})?
.to_owned();
let alias_args: Option<Vec<String>> = rest
.iter()
.map(|a| a.to_str().map(|s| s.to_owned()))
.collect();
if let Some(alias_args) = alias_args
&& let Some(()) = try_alias(name.clone(), alias_args, yes)?
{
return Ok(());
}
let binary = format!("wt-{name}");
if let Ok(path) = which::which(&binary) {
return run_custom(&path, &rest, working_dir.as_deref());
}
enhance_and_exit_error(unrecognized_subcommand_error(&name));
}
fn unrecognized_subcommand_error(name: &str) -> clap::Error {
let mut cmd = build_command();
let mut err = clap::Error::new(ErrorKind::InvalidSubcommand).with_cmd(&cmd);
err.insert(
ContextKind::InvalidSubcommand,
ContextValue::String(name.to_string()),
);
let alias_names = alias_names_for_suggestions();
let suggestions = similar_subcommands(name, &cmd, &alias_names);
if !suggestions.is_empty() {
err.insert(
ContextKind::SuggestedSubcommand,
ContextValue::Strings(suggestions),
);
}
err.insert(
ContextKind::Usage,
ContextValue::StyledStr(cmd.render_usage()),
);
err
}
fn run_custom(path: &Path, args: &[OsString], working_dir: Option<&Path>) -> Result<()> {
let mut cmd = Command::new(path);
cmd.args(args);
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
let status = cmd
.status()
.with_context(|| format!("failed to execute {}", path.display()))?;
if status.success() {
return Ok(());
}
#[cfg(unix)]
if let Some(sig) = std::os::unix::process::ExitStatusExt::signal(&status) {
return Err(WorktrunkError::AlreadyDisplayed {
exit_code: 128 + sig,
}
.into());
}
let code = status.code().unwrap_or(1);
Err(WorktrunkError::AlreadyDisplayed { exit_code: code }.into())
}
fn similar_subcommands(name: &str, cli_cmd: &clap::Command, alias_names: &[String]) -> Vec<String> {
let builtins = cli_cmd
.get_subcommands()
.filter(|c| !c.is_hide_set())
.map(|c| c.get_name().to_string())
.filter(|candidate| candidate != "help");
did_you_mean(name, builtins.chain(alias_names.iter().cloned()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn similar_subcommands_finds_typo() {
let cmd = build_command();
let suggestions = similar_subcommands("siwtch", &cmd, &[]);
assert_eq!(
suggestions.first().map(String::as_str),
Some("switch"),
"got: {suggestions:?}"
);
}
#[test]
fn similar_subcommands_ignores_unrelated() {
let cmd = build_command();
assert!(similar_subcommands("zzzzzzzz", &cmd, &[]).is_empty());
}
#[test]
fn similar_subcommands_skips_hidden() {
let cmd = build_command();
assert!(!similar_subcommands("select", &cmd, &[]).contains(&"select".to_string()));
}
#[test]
fn similar_subcommands_includes_aliases() {
let cmd = build_command();
let aliases = vec!["deploy".to_string(), "release".to_string()];
let suggestions = similar_subcommands("deplyo", &cmd, &aliases);
assert_eq!(
suggestions.first().map(String::as_str),
Some("deploy"),
"got: {suggestions:?}"
);
}
#[test]
fn similar_subcommands_dedupes_alias_matching_builtin() {
let cmd = build_command();
let aliases = vec!["list".to_string()];
let suggestions = similar_subcommands("list", &cmd, &aliases);
let count = suggestions.iter().filter(|n| *n == "list").count();
assert_eq!(count, 1, "got: {suggestions:?}");
}
#[cfg(unix)]
#[test]
fn handle_custom_command_rejects_non_utf8_name() {
use std::os::unix::ffi::OsStringExt;
let bad_name = OsString::from_vec(vec![0xFF, 0xFE]);
let err = handle_custom_command(vec![bad_name], None, false).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("not valid UTF-8"),
"unexpected error message: {msg}"
);
}
#[cfg(unix)]
#[test]
fn run_custom_propagates_signal_exit_code() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("create tempdir");
let script = dir.path().join("wt-signal-test");
std::fs::write(&script, "#!/bin/sh\nkill -TERM $$\n").expect("write script");
let mut perms = std::fs::metadata(&script)
.expect("stat script")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).expect("chmod script");
let err = run_custom(&script, &[], None).expect_err("child killed by SIGTERM");
let wt_err = err
.downcast_ref::<WorktrunkError>()
.expect("signal should surface as WorktrunkError::AlreadyDisplayed");
match wt_err {
WorktrunkError::AlreadyDisplayed { exit_code } => {
assert_eq!(*exit_code, 128 + 15);
}
other => panic!("unexpected WorktrunkError variant: {other:?}"),
}
}
}