use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use clap::CommandFactory;
use color_print::cformat;
use strsim::levenshtein;
use worktrunk::git::WorktrunkError;
use worktrunk::styling::{eprintln, error_message, hint_message};
use crate::cli::{Cli, suggest_nested_subcommand};
pub(crate) fn handle_external_command(
args: Vec<OsString>,
working_dir: Option<PathBuf>,
) -> Result<()> {
let mut iter = args.into_iter();
let name_os = iter
.next()
.expect("clap guarantees at least one arg for external subcommands");
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 cli_cmd = Cli::command();
if let Some(suggestion) = suggest_nested_subcommand(&cli_cmd, &name) {
eprintln!(
"{}",
error_message(cformat!("Unrecognized subcommand '<cyan,bold>{name}</>'"))
);
eprintln!(
"{}",
hint_message(cformat!("Perhaps <cyan,bold>{suggestion}</>?"))
);
eprintln!("{}", hint_message(help_hint()));
return Err(WorktrunkError::AlreadyDisplayed { exit_code: 2 }.into());
}
let binary = format!("wt-{name}");
let Ok(path) = which::which(&binary) else {
print_not_found(&name, &cli_cmd);
return Err(WorktrunkError::AlreadyDisplayed { exit_code: 1 }.into());
};
run_external(&path, &rest, working_dir.as_deref())
}
fn run_external(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 print_not_found(name: &str, cli_cmd: &clap::Command) {
eprintln!(
"{}",
error_message(cformat!("'<cyan,bold>{name}</>' is not a wt command"))
);
if let Some(suggestion) = closest_subcommand(name, cli_cmd) {
eprintln!(
"{}",
hint_message(cformat!(
"The most similar command is <cyan,bold>{suggestion}</>"
))
);
}
eprintln!("{}", hint_message(help_hint()));
}
fn help_hint() -> String {
cformat!("For more information, try '<cyan,bold>wt --help</>'.")
}
fn closest_subcommand(name: &str, cli_cmd: &clap::Command) -> Option<String> {
let max_distance = (name.len() / 3).max(1);
cli_cmd
.get_subcommands()
.filter(|c| !c.is_hide_set())
.map(|c| c.get_name())
.filter(|&candidate| candidate != "help")
.map(|candidate| (candidate, levenshtein(name, candidate)))
.filter(|&(_, dist)| dist <= max_distance)
.min_by_key(|&(_, dist)| dist)
.map(|(candidate, _)| candidate.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn closest_subcommand_finds_typo() {
let cmd = Cli::command();
assert_eq!(
closest_subcommand("siwtch", &cmd).as_deref(),
Some("switch")
);
}
#[test]
fn closest_subcommand_ignores_unrelated() {
let cmd = Cli::command();
assert_eq!(closest_subcommand("zzzzzzzz", &cmd), None);
}
#[test]
fn closest_subcommand_skips_hidden() {
let cmd = Cli::command();
assert_eq!(closest_subcommand("select", &cmd), None);
}
#[cfg(unix)]
#[test]
fn handle_external_command_rejects_non_utf8_name() {
use std::os::unix::ffi::OsStringExt;
let bad_name = OsString::from_vec(vec![0xFF, 0xFE]);
let err = handle_external_command(vec![bad_name], None).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("not valid UTF-8"),
"unexpected error message: {msg}"
);
}
#[cfg(unix)]
#[test]
fn run_external_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_external(&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:?}"),
}
}
}