use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Context, bail};
use clap::error::{ContextKind, ContextValue, ErrorKind};
use color_print::cformat;
use worktrunk::config::{
ALIAS_ARGS_KEY, CommandConfig, HookStep, ProjectConfig, UserConfig, append_aliases,
referenced_vars_for_config,
};
use worktrunk::git::Repository;
use worktrunk::styling::{eprintln, println, progress_message, warning_message};
use crate::commands::command_approval::approve_alias_commands;
use crate::commands::command_executor::{
CommandContext, CommandOrigin, FailureStrategy, ForegroundStep, PreparedCommand, PreparedStep,
build_hook_context, execute_pipeline_foreground,
};
use crate::commands::did_you_mean;
use crate::commands::hooks::{format_pipeline_summary_from_names, step_names_from_config};
use crate::output::DirectivePassthrough;
const BUILTIN_STEP_COMMANDS: &[&str] = &[
"commit",
"copy-ignored",
"diff",
"eval",
"for-each",
"promote",
"prune",
"push",
"rebase",
"relocate",
"squash",
];
pub(crate) const TOP_LEVEL_BUILTINS: &[&str] = &[
"config", "hook", "list", "merge", "remove", "select", "step", "switch",
];
fn help_flag_requested(args: &[String]) -> bool {
for arg in args {
if arg == "--" {
return false;
}
if arg == "--help" || arg == "-h" {
return true;
}
}
false
}
fn emit_alias_help_hint(name: &str) {
println!(
"`{name}` is an alias. Inspect with:
wt config alias show {name}
wt config alias dry-run {name}
Forward `--help` to the alias body with `wt {name} -- --help`."
);
}
#[derive(Debug)]
pub struct AliasOptions {
pub name: String,
pub vars: Vec<(String, String)>,
pub positional_args: Vec<String>,
}
impl AliasOptions {
pub fn parse(
args: Vec<String>,
referenced_vars: &BTreeSet<String>,
) -> anyhow::Result<(Self, Vec<String>)> {
let Some(name) = args.first().cloned() else {
bail!("Missing alias name");
};
let mut vars = Vec::new();
let mut positional_args = Vec::new();
let mut warnings = Vec::new();
let mut literal_mode = false;
let mut i = 1;
while i < args.len() {
let arg = &args[i];
if literal_mode {
positional_args.push(arg.clone());
i += 1;
continue;
}
if arg == "--" {
literal_mode = true;
i += 1;
continue;
}
if arg == "--dry-run" {
bail!(
"--dry-run is no longer supported; use `wt config alias dry-run {name}` instead"
);
}
if let Some(rest) = arg.strip_prefix("--") {
if let Some((key, value)) = rest.split_once('=') {
if key.is_empty() {
bail!("invalid KEY=VALUE: key cannot be empty");
}
let canon = key.replace('-', "_");
if referenced_vars.contains(&canon) {
vars.push((canon, value.to_string()));
} else {
positional_args.push(arg.clone());
}
i += 1;
continue;
}
let canon = rest.replace('-', "_");
if let Some(next) = args.get(i + 1) {
if referenced_vars.contains(&canon) {
if next.starts_with("--") {
warnings.push(format!(
"`--{rest} {next}` bound `{rest}` to `{next}` — use `--{rest}={next}` if that was intended"
));
}
vars.push((canon, next.clone()));
} else {
positional_args.push(arg.clone());
positional_args.push(next.clone());
}
i += 2;
continue;
}
positional_args.push(arg.clone());
i += 1;
continue;
}
positional_args.push(arg.clone());
i += 1;
}
Ok((
Self {
name,
vars,
positional_args,
},
warnings,
))
}
}
fn alias_needs_approval(
alias_name: &str,
project_config: &Option<ProjectConfig>,
) -> Option<CommandConfig> {
project_config
.as_ref()
.and_then(|pc| pc.aliases.get(alias_name))
.cloned()
}
fn unknown_step_command_exit(name: &str, alias_names: &[&str]) -> ! {
let mut top = crate::cli::build_command();
let step_cmd = top
.find_subcommand_mut("step")
.expect("`step` subcommand is defined in the CLI");
step_cmd.set_bin_name("wt step");
let usage = step_cmd.render_usage();
let builtins = step_cmd
.get_subcommands()
.filter(|c| !c.is_hide_set())
.map(|c| c.get_name().to_string())
.filter(|n| n != "help");
let candidates = builtins.chain(alias_names.iter().map(|s| s.to_string()));
let suggestions = did_you_mean(name, candidates);
let mut err = clap::Error::new(ErrorKind::InvalidSubcommand).with_cmd(step_cmd);
err.insert(
ContextKind::InvalidSubcommand,
ContextValue::String(name.to_string()),
);
if !suggestions.is_empty() {
err.insert(
ContextKind::SuggestedSubcommand,
ContextValue::Strings(suggestions),
);
}
err.insert(ContextKind::Usage, ContextValue::StyledStr(usage));
crate::enhance_and_exit_error(err)
}
fn format_alias_announcement(name: &str, cmd_config: &CommandConfig) -> String {
let step_names = step_names_from_config(cmd_config);
let summary =
format_pipeline_summary_from_names(&step_names, |n| cformat!("<bold>{n}</>"), |_| None);
if summary.is_empty() {
cformat!("Running alias <bold>{name}</>")
} else {
cformat!("Running alias <bold>{name}</>: {summary}")
}
}
fn load_merged_aliases(
repo: &Repository,
user_config: &UserConfig,
project_config: Option<&ProjectConfig>,
) -> BTreeMap<String, CommandConfig> {
let project_id = repo.project_identifier().ok();
let mut aliases = user_config.aliases(project_id.as_deref());
if let Some(pc) = project_config {
append_aliases(&mut aliases, &pc.aliases);
}
aliases
}
pub fn try_alias(name: String, rest: Vec<String>, global_yes: bool) -> anyhow::Result<Option<()>> {
let Ok(repo) = Repository::current() else {
return Ok(None);
};
let user_config = UserConfig::load()?;
let project_config = ProjectConfig::load(&repo, true)?;
let aliases = load_merged_aliases(&repo, &user_config, project_config.as_ref());
let Some(cmd_config) = aliases.get(&name) else {
return Ok(None);
};
let referenced = referenced_vars_for_config(cmd_config)?;
if !referenced.contains("help") && help_flag_requested(&rest) {
emit_alias_help_hint(&name);
return Ok(Some(()));
}
let mut alias_args = Vec::with_capacity(1 + rest.len());
alias_args.push(name);
alias_args.extend(rest);
let (opts, warnings) = AliasOptions::parse(alias_args, &referenced)?;
run_alias(
opts,
warnings,
repo,
user_config,
project_config,
aliases,
global_yes,
)
.map(Some)
}
pub fn step_alias(args: Vec<String>, global_yes: bool) -> anyhow::Result<()> {
let repo = Repository::current()?;
let user_config = UserConfig::load()?;
let project_config = ProjectConfig::load(&repo, true)?;
let aliases = load_merged_aliases(&repo, &user_config, project_config.as_ref());
let Some(name) = args.first().cloned() else {
bail!("Missing alias name");
};
let Some(cmd_config) = aliases.get(&name) else {
let alias_names: Vec<&str> = aliases
.keys()
.filter(|k| !BUILTIN_STEP_COMMANDS.contains(&k.as_str()))
.map(|k| k.as_str())
.collect();
unknown_step_command_exit(&name, &alias_names);
};
let referenced = referenced_vars_for_config(cmd_config)?;
if !referenced.contains("help") && help_flag_requested(&args[1..]) {
emit_alias_help_hint(&name);
return Ok(());
}
let (opts, warnings) = AliasOptions::parse(args, &referenced)?;
run_alias(
opts,
warnings,
repo,
user_config,
project_config,
aliases,
global_yes,
)
}
pub fn alias_names_for_suggestions() -> Vec<String> {
worktrunk::config::suppress_warnings();
let Ok(repo) = Repository::current() else {
return UserConfig::load()
.map(|uc| uc.aliases(None).keys().cloned().collect())
.unwrap_or_default();
};
let Ok(user_config) = UserConfig::load() else {
return Vec::new();
};
let project_config = ProjectConfig::load(&repo, false).ok().flatten();
load_merged_aliases(&repo, &user_config, project_config.as_ref())
.keys()
.cloned()
.collect()
}
fn run_alias(
opts: AliasOptions,
warnings: Vec<String>,
repo: Repository,
user_config: UserConfig,
project_config: Option<ProjectConfig>,
aliases: BTreeMap<String, CommandConfig>,
global_yes: bool,
) -> anyhow::Result<()> {
let cmd_config = aliases
.get(&opts.name)
.expect("caller verified alias is configured");
for warning in &warnings {
eprintln!("{}", warning_message(warning));
}
if let Some(project_commands) = alias_needs_approval(&opts.name, &project_config) {
let project_id = repo
.project_identifier()
.context("Cannot determine project identifier for alias approval")?;
let approved =
approve_alias_commands(&project_commands, &opts.name, &project_id, global_yes)?;
if !approved {
return Ok(());
}
}
let wt = repo.current_worktree();
let wt_path = wt.root().context("Failed to get worktree root")?;
let branch = wt.branch().ok().flatten();
let ctx = CommandContext::new(&repo, &user_config, branch.as_deref(), &wt_path, false);
let extra_refs: Vec<(&str, &str)> = opts
.vars
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let mut context_map = build_hook_context(&ctx, &extra_refs)?;
context_map.insert(
ALIAS_ARGS_KEY.to_string(),
serde_json::to_string(&opts.positional_args)
.expect("Vec<String> serialization should never fail"),
);
let context_json = serde_json::to_string(&context_map)
.expect("HashMap<String, String> serialization should never fail");
eprintln!(
"{}",
progress_message(format_alias_announcement(&opts.name, cmd_config))
);
let directives = DirectivePassthrough::inherit_from_env();
let origin = CommandOrigin::Alias {
name: opts.name.clone(),
};
let foreground_steps: Vec<ForegroundStep> = cmd_config
.steps()
.iter()
.map(|step| {
let prepared = match step {
HookStep::Single(cmd) => {
PreparedStep::Single(alias_prepared_command(cmd, &context_json))
}
HookStep::Concurrent(cmds) => PreparedStep::Concurrent(
cmds.iter()
.map(|cmd| alias_prepared_command(cmd, &context_json))
.collect(),
),
};
ForegroundStep {
step: prepared,
origin: origin.clone(),
concurrent: true,
}
})
.collect();
execute_pipeline_foreground(
&foreground_steps,
&repo,
&wt_path,
&directives,
FailureStrategy::FailFast,
)
}
fn alias_prepared_command(cmd: &worktrunk::config::Command, context_json: &str) -> PreparedCommand {
PreparedCommand {
name: cmd.name.clone(),
expanded: cmd.template.clone(),
context_json: context_json.to_string(),
lazy_template: Some(cmd.template.clone()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum AliasSource {
User,
Project,
}
impl AliasSource {
pub(crate) fn label(self) -> &'static str {
match self {
AliasSource::User => "user",
AliasSource::Project => "project",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HelpContext {
TopLevel,
Step,
}
pub(crate) fn augment_help(help: &str, context: HelpContext) -> String {
worktrunk::config::suppress_warnings();
let aliases = load_aliases_for_listing();
if aliases.is_empty() {
return help.to_string();
}
let aliases_section = render_aliases_section(&aliases, context);
let options_heading = format!(
"{}Options:",
crate::cli::help_styles().get_header().render()
);
match help.find(&options_heading) {
Some(pos) => format!("{}{aliases_section}\n\n{}", &help[..pos], &help[pos..]),
None => {
format!("{help}\n{aliases_section}")
}
}
}
fn render_aliases_section(
entries: &[(String, CommandConfig, AliasSource)],
context: HelpContext,
) -> String {
use std::fmt::Write as _;
let shadowed_names: &[&str] = match context {
HelpContext::TopLevel => TOP_LEVEL_BUILTINS,
HelpContext::Step => BUILTIN_STEP_COMMANDS,
};
let is_shadowed = |name: &str| shadowed_names.contains(&name);
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for (name, _, _) in entries {
*counts.entry(name.as_str()).or_insert(0) += 1;
}
let mut out = String::new();
let _ = writeln!(out, "{}", cformat!("<bold><green>Aliases:</></>"));
let name_width = entries.iter().map(|(n, _, _)| n.len()).max().unwrap_or(0);
let mut first = true;
for (name, cfg, source) in entries {
if !first {
out.push('\n');
}
first = false;
let padding = " ".repeat(name_width - name.len());
let summary = format_alias_summary(cfg);
let suffix = if is_shadowed(name) {
cformat!(" <yellow>(shadowed by built-in)</>")
} else if counts.get(name.as_str()).copied().unwrap_or(0) > 1 {
match source {
AliasSource::User => cformat!(" <dim>(user)</>"),
AliasSource::Project => cformat!(" <dim>(project)</>"),
}
} else {
String::new()
};
let _ = write!(
out,
" {name_styled}{padding} {summary}{suffix}",
name_styled = cformat!("<bold><cyan>{name}</></>"),
);
}
out
}
fn load_aliases_for_listing() -> Vec<(String, CommandConfig, AliasSource)> {
let repo = Repository::current().ok();
let project_id = repo.as_ref().and_then(|r| r.project_identifier().ok());
let user_aliases = UserConfig::load()
.ok()
.map(|uc| uc.aliases(project_id.as_deref()))
.unwrap_or_default();
let project_aliases = repo
.as_ref()
.and_then(load_project_aliases_silent)
.unwrap_or_default();
let mut entries: Vec<(String, CommandConfig, AliasSource)> = user_aliases
.into_iter()
.map(|(n, c)| (n, c, AliasSource::User))
.chain(
project_aliases
.into_iter()
.map(|(n, c)| (n, c, AliasSource::Project)),
)
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0).then(a.2.cmp(&b.2)));
entries
}
fn load_project_aliases_silent(repo: &Repository) -> Option<BTreeMap<String, CommandConfig>> {
let path = repo.project_config_path().ok().flatten()?;
if !path.exists() {
return None;
}
let contents = std::fs::read_to_string(&path).ok()?;
let config: ProjectConfig = toml::from_str(&contents).ok()?;
Some(config.aliases)
}
fn format_alias_summary(cfg: &CommandConfig) -> String {
if cfg.commands().count() > 1 {
let step_names = step_names_from_config(cfg);
let summary = format_pipeline_summary_from_names(&step_names, |n| n.to_string(), |_| None);
if summary.is_empty() {
format!("<{} steps>", cfg.commands().count())
} else {
summary
}
} else {
let cmd = cfg
.commands()
.next()
.expect("CommandConfig always contains at least one command");
let first = cmd.template.lines().next().unwrap_or("").trim_end();
if cmd.template.lines().count() > 1 {
format!("{first}…")
} else {
first.to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ansi_str::AnsiStr;
fn parse_with(args: &[&str], referenced: &[&str]) -> anyhow::Result<AliasOptions> {
parse_with_warnings(args, referenced).map(|(opts, _)| opts)
}
fn parse_with_warnings(
args: &[&str],
referenced: &[&str],
) -> anyhow::Result<(AliasOptions, Vec<String>)> {
let refs: BTreeSet<String> = referenced.iter().map(|s| s.to_string()).collect();
AliasOptions::parse(args.iter().map(|s| s.to_string()).collect(), &refs)
}
fn parse(args: &[&str]) -> anyhow::Result<AliasOptions> {
parse_with(args, &[])
}
fn cfg_from_toml(toml_str: &str) -> CommandConfig {
#[derive(serde::Deserialize)]
struct Wrap {
cmd: CommandConfig,
}
toml::from_str::<Wrap>(toml_str).unwrap().cmd
}
#[test]
fn test_format_alias_announcement_single_unnamed() {
let cfg = cfg_from_toml(r#"cmd = "echo hi""#);
let msg = format_alias_announcement("deploy", &cfg);
insta::assert_snapshot!(msg.ansi_strip(), @"Running alias deploy");
}
#[test]
fn test_format_alias_announcement_pipeline_all_unnamed() {
let cfg = cfg_from_toml(r#"cmd = ["echo a", "echo b"]"#);
let msg = format_alias_announcement("deploy", &cfg);
insta::assert_snapshot!(msg.ansi_strip(), @"Running alias deploy");
}
#[test]
fn test_format_alias_announcement_concurrent_named() {
let cfg = cfg_from_toml(
r#"
[cmd]
build = "cargo build"
test = "cargo test"
"#,
);
let msg = format_alias_announcement("check", &cfg);
insta::assert_snapshot!(msg.ansi_strip(), @"Running alias check: build, test");
}
#[test]
fn test_format_alias_announcement_pipeline_named() {
let cfg = cfg_from_toml(
r#"
cmd = [
{ install = "npm install" },
{ build = "npm run build", lint = "npm run lint" },
]
"#,
);
let msg = format_alias_announcement("deploy", &cfg);
insta::assert_snapshot!(msg.ansi_strip(), @"Running alias deploy: install; build, lint");
}
#[test]
fn test_format_alias_announcement_mixed_named_unnamed() {
let cfg = cfg_from_toml(
r#"
cmd = [
"echo first",
{ build = "cargo build", test = "cargo test" },
]
"#,
);
let msg = format_alias_announcement("ci", &cfg);
insta::assert_snapshot!(msg.ansi_strip(), @"Running alias ci: build, test");
}
#[test]
fn test_parse_built_in_flags() {
use insta::assert_debug_snapshot;
assert_debug_snapshot!(parse(&["deploy"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [],
positional_args: [],
}
"#);
}
#[test]
fn test_parse_key_value_routing() {
use insta::assert_debug_snapshot;
assert_debug_snapshot!(parse_with(&["deploy", "--env=staging"], &["env"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [
(
"env",
"staging",
),
],
positional_args: [],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--env=staging"], &[]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [],
positional_args: [
"--env=staging",
],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--url=http://host?a=1"], &["url"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [
(
"url",
"http://host?a=1",
),
],
positional_args: [],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--foo=a=b=c"], &["foo"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [
(
"foo",
"a=b=c",
),
],
positional_args: [],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--env="], &["env"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [
(
"env",
"",
),
],
positional_args: [],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--env="], &[]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [],
positional_args: [
"--env=",
],
}
"#);
}
#[test]
fn test_parse_space_separated_routing() {
use insta::assert_debug_snapshot;
assert_debug_snapshot!(parse_with(&["deploy", "--env", "staging"], &["env"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [
(
"env",
"staging",
),
],
positional_args: [],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--env", "staging"], &[]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [],
positional_args: [
"--env",
"staging",
],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--env", "--other"], &["env"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [
(
"env",
"--other",
),
],
positional_args: [],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--env", "--other"], &[]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [],
positional_args: [
"--env",
"--other",
],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--env"], &["env"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [],
positional_args: [
"--env",
],
}
"#);
}
#[test]
fn test_parse_warns_on_footgun_space_form() {
let (_, warnings) = parse_with_warnings(&["deploy", "--env", "--other"], &["env"]).unwrap();
insta::assert_debug_snapshot!(warnings, @r#"
[
"`--env --other` bound `env` to `--other` — use `--env=--other` if that was intended",
]
"#);
let (_, warnings) = parse_with_warnings(&["deploy", "--env", "--other"], &[]).unwrap();
assert!(warnings.is_empty());
let (_, warnings) = parse_with_warnings(&["deploy", "--env", "prod"], &["env"]).unwrap();
assert!(warnings.is_empty());
let (_, warnings) =
parse_with_warnings(&["deploy", "--my-env", "--other"], &["my_env"]).unwrap();
insta::assert_debug_snapshot!(warnings, @r#"
[
"`--my-env --other` bound `my-env` to `--other` — use `--my-env=--other` if that was intended",
]
"#);
}
#[test]
fn test_parse_duplicate_key_last_write_wins() {
use insta::assert_debug_snapshot;
assert_debug_snapshot!(parse_with(&["deploy", "--env=a", "--env=b"], &["env"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [
(
"env",
"a",
),
(
"env",
"b",
),
],
positional_args: [],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--env", "a", "--env=b"], &["env"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [
(
"env",
"a",
),
(
"env",
"b",
),
],
positional_args: [],
}
"#);
}
#[test]
fn test_parse_hyphen_canonicalization() {
use insta::assert_debug_snapshot;
assert_debug_snapshot!(parse_with(&["deploy", "--my-var=value"], &["my_var"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [
(
"my_var",
"value",
),
],
positional_args: [],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--my_var=value"], &["my_var"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [
(
"my_var",
"value",
),
],
positional_args: [],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--region=us-east-1"], &["region"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [
(
"region",
"us-east-1",
),
],
positional_args: [],
}
"#);
}
#[test]
fn test_parse_literal_forward_escape() {
use insta::assert_debug_snapshot;
assert_debug_snapshot!(parse_with(&["deploy", "--env=staging", "--", "--env=other", "x"], &["env"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [
(
"env",
"staging",
),
],
positional_args: [
"--env=other",
"x",
],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--", "--yes", "-y", "--dry-run"], &[]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [],
positional_args: [
"--yes",
"-y",
"--dry-run",
],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--"], &[]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [],
positional_args: [],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "--", "a", "--", "b"], &[]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [],
positional_args: [
"a",
"--",
"b",
],
}
"#);
}
#[test]
fn test_parse_mixed_pipeline() {
use insta::assert_debug_snapshot;
assert_debug_snapshot!(
parse_with(
&["deploy", "--env", "prod", "--region=us-east", "thing"],
&["env", "region"],
).unwrap(),
@r#"
AliasOptions {
name: "deploy",
vars: [
(
"env",
"prod",
),
(
"region",
"us-east",
),
],
positional_args: [
"thing",
],
}
"#
);
}
#[test]
fn test_parse_positionals() {
use insta::assert_debug_snapshot;
assert_debug_snapshot!(parse(&["s", "some-branch"]).unwrap(), @r#"
AliasOptions {
name: "s",
vars: [],
positional_args: [
"some-branch",
],
}
"#);
assert_debug_snapshot!(parse(&["deploy", "one", "two", "three"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [],
positional_args: [
"one",
"two",
"three",
],
}
"#);
assert_debug_snapshot!(parse_with(&["deploy", "foo", "--env=prod", "bar"], &["env"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [
(
"env",
"prod",
),
],
positional_args: [
"foo",
"bar",
],
}
"#);
assert_debug_snapshot!(parse(&["deploy", "foo bar", "x;rm -rf /"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [],
positional_args: [
"foo bar",
"x;rm -rf /",
],
}
"#);
assert_debug_snapshot!(parse(&["deploy", "--yes"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [],
positional_args: [
"--yes",
],
}
"#);
assert_debug_snapshot!(parse(&["deploy", "-y"]).unwrap(), @r#"
AliasOptions {
name: "deploy",
vars: [],
positional_args: [
"-y",
],
}
"#);
}
#[test]
fn test_parse_errors() {
use insta::assert_snapshot;
assert_snapshot!(parse(&[]).unwrap_err(), @"Missing alias name");
assert_snapshot!(parse(&["deploy", "--=value"]).unwrap_err(), @"invalid KEY=VALUE: key cannot be empty");
assert_snapshot!(parse(&["deploy", "--dry-run"]).unwrap_err(), @"--dry-run is no longer supported; use `wt config alias dry-run deploy` instead");
}
#[test]
fn test_referenced_vars_for_config_unions_steps() {
let cfg = cfg_from_toml(
r#"
cmd = [
"echo {{ env }}",
{ build = "make {{ target }}", lint = "lint {{ args }}" },
]
"#,
);
let refs = worktrunk::config::referenced_vars_for_config(&cfg).unwrap();
let names: Vec<&str> = refs.iter().map(String::as_str).collect();
assert_eq!(names, vec!["args", "env", "target"]);
}
#[test]
fn test_builtin_step_commands_matches_clap() {
use crate::cli::Cli;
use clap::CommandFactory;
let app = Cli::command();
let step_cmd = app
.get_subcommands()
.find(|c| c.get_name() == "step")
.expect("step subcommand exists");
let clap_names: Vec<&str> = step_cmd.get_subcommands().map(|s| s.get_name()).collect();
for name in &clap_names {
assert!(
BUILTIN_STEP_COMMANDS.contains(name),
"Step subcommand '{name}' is missing from BUILTIN_STEP_COMMANDS. \
Add it to prevent aliases from silently conflicting with the built-in."
);
}
for name in BUILTIN_STEP_COMMANDS {
assert!(
clap_names.contains(name),
"BUILTIN_STEP_COMMANDS contains '{name}' but no such step subcommand exists. \
Remove it from the list."
);
}
}
#[test]
fn test_top_level_builtins_match_clap() {
use crate::cli::Cli;
use clap::CommandFactory;
let app = Cli::command();
let clap_names: Vec<&str> = app
.get_subcommands()
.map(|s| s.get_name())
.filter(|n| *n != "help")
.collect();
for name in &clap_names {
assert!(
TOP_LEVEL_BUILTINS.contains(name),
"Top-level subcommand '{name}' is missing from TOP_LEVEL_BUILTINS. \
Add it so the help splice annotates aliases unreachable at the top level."
);
}
for name in TOP_LEVEL_BUILTINS {
assert!(
clap_names.contains(name),
"TOP_LEVEL_BUILTINS contains '{name}' but no such top-level subcommand exists. \
Remove it from the list."
);
}
}
#[test]
fn test_format_alias_summary_single_command() {
let cfg = cfg_from_toml(r#"cmd = "echo hello""#);
assert_eq!(format_alias_summary(&cfg), "echo hello");
}
#[test]
fn test_format_alias_summary_multiline_gets_ellipsis() {
let cfg = cfg_from_toml(
r#"cmd = """
git fetch --all --prune
git rebase @{u}
""""#,
);
assert_eq!(format_alias_summary(&cfg), "git fetch --all --prune…");
}
#[test]
fn test_format_alias_summary_pipeline_named() {
let cfg = cfg_from_toml(
r#"
cmd = [
{ install = "npm install" },
{ build = "npm run build", lint = "npm run lint" },
]
"#,
);
assert_eq!(format_alias_summary(&cfg), "install; build, lint");
}
#[test]
fn test_format_alias_summary_concurrent_named() {
let cfg = cfg_from_toml(
r#"
[cmd]
build = "cargo build"
test = "cargo test"
"#,
);
assert_eq!(format_alias_summary(&cfg), "build, test");
}
#[test]
fn test_format_alias_summary_pipeline_all_unnamed() {
let cfg = cfg_from_toml(r#"cmd = ["echo a", "echo b"]"#);
assert_eq!(format_alias_summary(&cfg), "<2 steps>");
}
#[test]
fn test_render_aliases_section_source_annotations() {
let entries = vec![
(
"only-user".to_string(),
cfg_from_toml(r#"cmd = "echo u""#),
AliasSource::User,
),
(
"only-project".to_string(),
cfg_from_toml(r#"cmd = "echo p""#),
AliasSource::Project,
),
(
"shared".to_string(),
cfg_from_toml(r#"cmd = "echo from-user""#),
AliasSource::User,
),
(
"shared".to_string(),
cfg_from_toml(r#"cmd = "echo from-project""#),
AliasSource::Project,
),
];
let mut sorted = entries;
sorted.sort_by(|a, b| a.0.cmp(&b.0).then(a.2.cmp(&b.2)));
let rendered = render_aliases_section(&sorted, HelpContext::Step);
let rendered = rendered.ansi_strip();
insta::assert_snapshot!(rendered, @r"
Aliases:
only-project echo p
only-user echo u
shared echo from-user (user)
shared echo from-project (project)
");
}
#[test]
fn test_render_aliases_section_top_level_shadowing() {
let entries = vec![
(
"list".to_string(),
cfg_from_toml(r#"cmd = "ls""#),
AliasSource::User,
),
(
"commit".to_string(),
cfg_from_toml(r#"cmd = "git commit""#),
AliasSource::User,
),
(
"deploy".to_string(),
cfg_from_toml(r#"cmd = "make deploy""#),
AliasSource::User,
),
];
let mut sorted = entries;
sorted.sort_by(|a, b| a.0.cmp(&b.0).then(a.2.cmp(&b.2)));
let rendered = render_aliases_section(&sorted, HelpContext::TopLevel);
let rendered = rendered.ansi_strip();
insta::assert_snapshot!(rendered, @r"
Aliases:
commit git commit
deploy make deploy
list ls (shadowed by built-in)
");
}
}