use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use clap::error::{ContextKind, ContextValue, ErrorKind};
use strsim::jaro_winkler;
use worktrunk::git::WorktrunkError;
use crate::cli::build_command;
use crate::enhance_and_exit_error;
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 binary = format!("wt-{name}");
if let Ok(path) = which::which(&binary) {
return run_external(&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 suggestions = similar_subcommands(name, &cmd);
if !suggestions.is_empty() {
err.insert(
ContextKind::SuggestedSubcommand,
ContextValue::Strings(suggestions),
);
}
err.insert(
ContextKind::Usage,
ContextValue::StyledStr(cmd.render_usage()),
);
err
}
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 similar_subcommands(name: &str, cli_cmd: &clap::Command) -> Vec<String> {
let mut scored: Vec<(f64, String)> = cli_cmd
.get_subcommands()
.filter(|c| !c.is_hide_set())
.map(|c| c.get_name())
.filter(|&candidate| candidate != "help")
.map(|candidate| (jaro_winkler(name, candidate), candidate.to_string()))
.filter(|(score, _)| *score > 0.7)
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
scored.into_iter().map(|(_, name)| name).collect()
}
#[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()));
}
#[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:?}"),
}
}
}