use std::ffi::OsString;
use anyhow::{Context, bail};
use clap::Subcommand;
use worktrunk::HookType;
use super::config::ApprovalsCommand;
pub const HOOK_TYPE_NAMES: &[&str] = &[
"pre-switch",
"post-switch",
"pre-start",
"post-start",
"pre-commit",
"post-commit",
"pre-merge",
"post-merge",
"pre-remove",
"post-remove",
];
#[derive(Subcommand)]
pub enum HookCommand {
Show {
#[arg(value_parser = ["pre-switch", "post-switch", "pre-start", "post-start", "pre-commit", "post-commit", "pre-merge", "post-merge", "pre-remove", "post-remove"])]
hook_type: Option<String>,
#[arg(long)]
expanded: bool,
},
#[command(hide = true, name = "run-pipeline")]
RunPipeline,
#[command(hide = true)]
Approvals {
#[command(subcommand)]
action: ApprovalsCommand,
},
#[command(external_subcommand)]
Run(Vec<OsString>),
}
#[derive(Debug)]
pub struct HookOptions {
pub hook_type: HookType,
pub yes: bool,
pub dry_run: bool,
pub foreground: Option<bool>,
pub name_filters: Vec<String>,
pub explicit_vars: Vec<(String, String)>,
pub shorthand_vars: Vec<String>,
pub forwarded_args: Vec<String>,
}
pub fn parse_hook_type(name: &str) -> anyhow::Result<HookType> {
match name {
"pre-switch" => Ok(HookType::PreSwitch),
"post-switch" => Ok(HookType::PostSwitch),
"pre-start" | "post-create" => Ok(HookType::PreStart),
"post-start" => Ok(HookType::PostStart),
"pre-commit" => Ok(HookType::PreCommit),
"post-commit" => Ok(HookType::PostCommit),
"pre-merge" => Ok(HookType::PreMerge),
"post-merge" => Ok(HookType::PostMerge),
"pre-remove" => Ok(HookType::PreRemove),
"post-remove" => Ok(HookType::PostRemove),
other => {
let candidates = HOOK_TYPE_NAMES.iter().map(|s| s.to_string());
let suggestions = crate::commands::did_you_mean(other, candidates);
if let Some(suggestion) = suggestions.first() {
bail!("unknown hook type: `{other}` (did you mean `{suggestion}`?)");
}
bail!(
"unknown hook type: `{other}` (expected one of: {})",
HOOK_TYPE_NAMES.join(", ")
);
}
}
}
impl HookOptions {
pub fn parse(args: &[OsString]) -> anyhow::Result<Self> {
let first = args
.first()
.and_then(|s| s.to_str())
.context("missing hook type after `wt hook`")?;
let hook_type = parse_hook_type(first)?;
let mut yes = false;
let mut dry_run = false;
let mut foreground: Option<bool> = None;
let mut name_filters: Vec<String> = Vec::new();
let mut explicit_vars: Vec<(String, String)> = Vec::new();
let mut shorthand_vars: Vec<String> = Vec::new();
let mut forwarded_args: Vec<String> = Vec::new();
let mut literal_mode = false;
let mut i = 1;
while i < args.len() {
let arg = args[i]
.to_str()
.with_context(|| format!("non-UTF-8 argument at position {i}"))?;
if literal_mode {
forwarded_args.push(arg.to_string());
i += 1;
continue;
}
match arg {
"--" => {
literal_mode = true;
i += 1;
continue;
}
"--yes" | "-y" => {
yes = true;
i += 1;
continue;
}
"--dry-run" => {
dry_run = true;
i += 1;
continue;
}
"--foreground" => {
foreground = Some(true);
i += 1;
continue;
}
"--var" => {
let value = args
.get(i + 1)
.and_then(|s| s.to_str())
.context("--var requires KEY=VALUE")?;
push_var(&mut explicit_vars, value)?;
i += 2;
continue;
}
_ => {}
}
if let Some(rest) = arg.strip_prefix("--var=") {
push_var(&mut explicit_vars, rest)?;
i += 1;
continue;
}
if let Some(rest) = arg.strip_prefix("--")
&& let Some((key, value)) = rest.split_once('=')
&& !key.is_empty()
{
shorthand_vars.push(format!("{key}={value}"));
i += 1;
continue;
}
if arg.starts_with("--") {
bail!(
"unknown flag `{arg}` for `wt hook {first}` (use `--KEY=VALUE` for template \
variables, `--` to forward tokens to {{{{ args }}}})"
);
}
name_filters.push(arg.to_string());
i += 1;
}
Ok(Self {
hook_type,
yes,
dry_run,
foreground,
name_filters,
explicit_vars,
shorthand_vars,
forwarded_args,
})
}
}
fn push_var(out: &mut Vec<(String, String)>, raw: &str) -> anyhow::Result<()> {
let (key, val) = raw
.split_once('=')
.with_context(|| format!("--var expected KEY=VALUE, got `{raw}`"))?;
if key.is_empty() {
bail!("--var key cannot be empty");
}
out.push((key.replace('-', "_"), val.to_string()));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> anyhow::Result<HookOptions> {
let os: Vec<OsString> = args.iter().map(OsString::from).collect();
HookOptions::parse(&os)
}
#[test]
fn test_parse_minimal() {
let opts = parse(&["pre-merge"]).unwrap();
assert_eq!(opts.hook_type, HookType::PreMerge);
assert!(!opts.yes);
assert!(!opts.dry_run);
assert_eq!(opts.foreground, None);
assert!(opts.name_filters.is_empty());
assert!(opts.explicit_vars.is_empty());
assert!(opts.shorthand_vars.is_empty());
assert!(opts.forwarded_args.is_empty());
}
#[test]
fn test_parse_flags() {
let opts = parse(&["post-start", "--yes", "--dry-run", "--foreground"]).unwrap();
assert_eq!(opts.hook_type, HookType::PostStart);
assert!(opts.yes);
assert!(opts.dry_run);
assert_eq!(opts.foreground, Some(true));
let opts = parse(&["pre-merge", "-y"]).unwrap();
assert!(opts.yes);
}
#[test]
fn test_parse_name_filters() {
let opts = parse(&["pre-merge", "test", "build"]).unwrap();
assert_eq!(opts.name_filters, vec!["test", "build"]);
assert!(opts.shorthand_vars.is_empty());
let opts = parse(&["pre-merge", "user:test", "project:"]).unwrap();
assert_eq!(opts.name_filters, vec!["user:test", "project:"]);
}
#[test]
fn test_parse_shorthand() {
let opts = parse(&["pre-merge", "--branch=feature/x"]).unwrap();
assert_eq!(opts.shorthand_vars, vec!["branch=feature/x"]);
let opts = parse(&["pre-start", "--url=http://host?a=1"]).unwrap();
assert_eq!(opts.shorthand_vars, vec!["url=http://host?a=1"]);
let opts = parse(&["pre-start", "--branch="]).unwrap();
assert_eq!(opts.shorthand_vars, vec!["branch="]);
}
#[test]
fn test_parse_explicit_var() {
let opts = parse(&["pre-merge", "--var", "branch=x"]).unwrap();
assert_eq!(
opts.explicit_vars,
vec![("branch".to_string(), "x".to_string())]
);
assert!(opts.shorthand_vars.is_empty());
let opts = parse(&["pre-merge", "--var=branch=x"]).unwrap();
assert_eq!(
opts.explicit_vars,
vec![("branch".to_string(), "x".to_string())]
);
let opts = parse(&["pre-merge", "--var", "my-key=val"]).unwrap();
assert_eq!(
opts.explicit_vars,
vec![("my_key".to_string(), "val".to_string())]
);
let opts = parse(&["pre-merge", "--var", "a=1", "--var", "b=2"]).unwrap();
assert_eq!(
opts.explicit_vars,
vec![
("a".to_string(), "1".to_string()),
("b".to_string(), "2".to_string())
]
);
}
#[test]
fn test_parse_literal_forward_escape() {
let opts = parse(&[
"pre-merge",
"--branch=x",
"--",
"--fast",
"--branch=y",
"extra",
])
.unwrap();
assert_eq!(opts.shorthand_vars, vec!["branch=x"]);
assert_eq!(opts.forwarded_args, vec!["--fast", "--branch=y", "extra"]);
let opts = parse(&["pre-merge", "--"]).unwrap();
assert!(opts.forwarded_args.is_empty());
let opts = parse(&["pre-merge", "test", "--", "extra"]).unwrap();
assert_eq!(opts.name_filters, vec!["test"]);
assert_eq!(opts.forwarded_args, vec!["extra"]);
}
#[test]
fn test_parse_mixed() {
let opts = parse(&[
"post-merge",
"--yes",
"test",
"--branch=x",
"--var",
"override=1",
"--dry-run",
"--",
"--fast",
])
.unwrap();
assert_eq!(opts.hook_type, HookType::PostMerge);
assert!(opts.yes);
assert!(opts.dry_run);
assert_eq!(opts.name_filters, vec!["test"]);
assert_eq!(opts.shorthand_vars, vec!["branch=x"]);
assert_eq!(
opts.explicit_vars,
vec![("override".to_string(), "1".to_string())]
);
assert_eq!(opts.forwarded_args, vec!["--fast"]);
}
#[test]
fn test_parse_hook_type_aliases() {
let opts = parse(&["post-create"]).unwrap();
assert_eq!(opts.hook_type, HookType::PreStart);
}
#[test]
fn test_parse_errors() {
assert!(HookOptions::parse(&[]).is_err());
let err = parse(&["pre-mrege"]).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("pre-merge"),
"expected did-you-mean suggestion, got: {msg}"
);
let err = parse(&["zzz"]).unwrap_err();
assert!(err.to_string().contains("expected one of"));
let err = parse(&["pre-merge", "--var"]).unwrap_err();
assert!(err.to_string().contains("--var"));
let err = parse(&["pre-merge", "--var", "=value"]).unwrap_err();
assert!(err.to_string().contains("empty"));
let err = parse(&["pre-merge", "--dryrun"]).unwrap_err();
assert!(err.to_string().contains("unknown flag"));
}
}