use std::collections::{BTreeSet, HashMap};
use std::io::Write;
use anyhow::Context;
use color_print::cformat;
use worktrunk::config::{
ALIAS_ARGS_KEY, CommandConfig, ProjectConfig, UserConfig, append_aliases,
referenced_vars_for_config, template_references_var, validate_template_syntax,
};
use worktrunk::git::{Repository, WorktrunkError};
use worktrunk::styling::{format_bash_with_gutter, info_message, println};
use crate::commands::alias::{AliasOptions, AliasSource, TOP_LEVEL_BUILTINS};
use crate::commands::build_invalid_subcommand_error;
use crate::commands::command_executor::{
CommandContext, build_hook_context, expand_shell_template,
};
use crate::commands::did_you_mean;
pub fn handle_alias_show(name: String) -> anyhow::Result<()> {
let repo = Repository::current()?;
let user_config = UserConfig::load()?;
let project_config = ProjectConfig::load(&repo, true)?;
let entries = entries_for_name(&repo, &user_config, project_config.as_ref(), &name);
if entries.is_empty() {
return Err(unknown_alias_error(
&repo,
&user_config,
project_config.as_ref(),
&name,
"show",
));
}
warn_if_shadowed(&name);
for (i, (cfg, source)) in entries.iter().enumerate() {
if i > 0 {
println!();
}
let bodies: Vec<String> = cfg.commands().map(|c| c.template.clone()).collect();
println!("{}", format_entry(&name, cfg, *source, &bodies, None));
}
Ok(())
}
fn warn_if_shadowed(name: &str) {
if TOP_LEVEL_BUILTINS.contains(&name) {
worktrunk::styling::eprintln!(
"{}",
worktrunk::styling::warning_message(cformat!(
"Alias <bold>{name}</> is shadowed by built-in <bold>wt {name}</>"
))
);
}
}
pub fn handle_alias_dry_run(name: String, args: Vec<String>) -> anyhow::Result<()> {
let repo = Repository::current()?;
let user_config = UserConfig::load()?;
let project_config = ProjectConfig::load(&repo, true)?;
let entries = entries_for_name(&repo, &user_config, project_config.as_ref(), &name);
if entries.is_empty() {
return Err(unknown_alias_error(
&repo,
&user_config,
project_config.as_ref(),
&name,
"dry-run",
));
}
let mut referenced: BTreeSet<String> = BTreeSet::new();
for (cfg, _) in &entries {
referenced.extend(referenced_vars_for_config(cfg)?);
}
let mut parse_args = Vec::with_capacity(1 + args.len());
parse_args.push(name.clone());
parse_args.extend(args);
let (opts, warnings) = AliasOptions::parse(parse_args, &referenced)?;
warn_if_shadowed(&name);
for warning in &warnings {
worktrunk::styling::eprintln!("{}", worktrunk::styling::warning_message(warning));
}
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 routing = format_routing_summary(&opts);
for (i, (cfg, source)) in entries.iter().enumerate() {
if i > 0 {
println!();
}
let bodies: Vec<String> = cfg
.commands()
.map(|c| render_preview(&c.template, &context_map, &repo, &name))
.collect::<anyhow::Result<_>>()?;
println!(
"{}",
format_entry_with_routing(
&name,
cfg,
*source,
&bodies,
Some("would run"),
routing.as_deref()
)
);
}
Ok(())
}
fn format_routing_summary(opts: &AliasOptions) -> Option<String> {
if opts.vars.is_empty() && opts.positional_args.is_empty() {
return None;
}
let mut lines = String::new();
if !opts.vars.is_empty() {
let bound = opts
.vars
.iter()
.map(|(k, v)| format!("{k}={}", shell_escape::unix::escape(v.into())))
.collect::<Vec<_>>()
.join(", ");
lines.push_str(&format!("# bound: {bound}\n"));
}
if !opts.positional_args.is_empty() {
let args = opts
.positional_args
.iter()
.map(|a| shell_escape::unix::escape(a.into()).into_owned())
.collect::<Vec<_>>()
.join(" ");
lines.push_str(&format!("# args: {args}\n"));
}
Some(lines)
}
fn render_preview(
template: &str,
context: &HashMap<String, String>,
repo: &Repository,
alias_name: &str,
) -> anyhow::Result<String> {
if template_references_var(template, "vars") {
validate_template_syntax(template, alias_name)
.map_err(|e| anyhow::anyhow!("syntax error in alias {alias_name}: {e}"))?;
Ok(template.to_string())
} else {
Ok(expand_shell_template(template, context, repo, alias_name)?)
}
}
fn entries_for_name(
repo: &Repository,
user_config: &UserConfig,
project_config: Option<&ProjectConfig>,
name: &str,
) -> Vec<(CommandConfig, AliasSource)> {
let project_id = repo.project_identifier().ok();
let mut entries = Vec::new();
if let Some(cfg) = user_config.aliases(project_id.as_deref()).get(name) {
entries.push((cfg.clone(), AliasSource::User));
}
if let Some(pc) = project_config
&& let Some(cfg) = pc.aliases.get(name)
{
entries.push((cfg.clone(), AliasSource::Project));
}
entries
}
fn unknown_alias_error(
repo: &Repository,
user_config: &UserConfig,
project_config: Option<&ProjectConfig>,
name: &str,
sub: &str,
) -> anyhow::Error {
let project_id = repo.project_identifier().ok();
let mut merged = user_config.aliases(project_id.as_deref());
if let Some(pc) = project_config {
append_aliases(&mut merged, &pc.aliases);
}
let suggestions = did_you_mean(name, merged.into_keys());
let mut top = crate::cli::build_command();
let sub_cmd = top
.find_subcommand_mut("config")
.expect("`config` subcommand is defined in the CLI")
.find_subcommand_mut("alias")
.expect("`config alias` subcommand is defined in the CLI")
.find_subcommand_mut(sub)
.unwrap_or_else(|| panic!("`config alias {sub}` subcommand is defined in the CLI"));
sub_cmd.set_bin_name(format!("wt config alias {sub}"));
let err = build_invalid_subcommand_error(sub_cmd, name, suggestions);
let rewritten = err
.render()
.ansi()
.to_string()
.replace("unrecognized subcommand", "unrecognized alias")
.replace("similar subcommands", "similar aliases")
.replace("similar subcommand", "similar alias");
let mut stream = anstream::AutoStream::auto(std::io::stderr());
let _ = write!(stream, "{rewritten}");
WorktrunkError::AlreadyDisplayed { exit_code: 2 }.into()
}
fn format_entry(
name: &str,
cfg: &CommandConfig,
source: AliasSource,
bodies: &[String],
verb: Option<&str>,
) -> String {
format_entry_with_routing(name, cfg, source, bodies, verb, None)
}
fn format_entry_with_routing(
name: &str,
cfg: &CommandConfig,
source: AliasSource,
bodies: &[String],
verb: Option<&str>,
routing: Option<&str>,
) -> String {
let label = source.label();
let suffix = match verb {
Some(v) => format!(" {v}:"),
None => ":".to_string(),
};
let mut body = String::new();
if let Some(routing) = routing {
body.push_str(routing);
}
for (cmd, rendered) in cfg.commands().zip(bodies) {
if !body.is_empty() && !body.ends_with('\n') {
body.push('\n');
}
if let Some(step_name) = &cmd.name {
body.push_str(&format!("# {step_name}\n"));
}
body.push_str(rendered);
}
info_message(cformat!(
"Alias <bold>{name}</> ({label}){suffix}\n{}",
format_bash_with_gutter(&body)
))
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use ansi_str::AnsiStr;
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_entry_show_single() {
let cfg = cfg_from_toml(r#"cmd = "echo {{ branch }}""#);
let bodies: Vec<String> = cfg.commands().map(|c| c.template.clone()).collect();
let out = format_entry("greet", &cfg, AliasSource::User, &bodies, None);
insta::assert_snapshot!(out.ansi_strip());
}
#[test]
fn test_format_entry_show_pipeline() {
let cfg = cfg_from_toml(
r#"
cmd = [
{ install = "npm install" },
{ build = "npm run build", lint = "npm run lint" },
]
"#,
);
let bodies: Vec<String> = cfg.commands().map(|c| c.template.clone()).collect();
let out = format_entry("deploy", &cfg, AliasSource::Project, &bodies, None);
insta::assert_snapshot!(out.ansi_strip());
}
#[test]
fn test_format_entry_dry_run_pipeline() {
let cfg = cfg_from_toml(
r#"
cmd = [
{ install = "npm install" },
{ build = "npm run build", lint = "npm run lint" },
]
"#,
);
let bodies: Vec<String> = cfg.commands().map(|c| c.template.clone()).collect();
let out = format_entry(
"deploy",
&cfg,
AliasSource::Project,
&bodies,
Some("would run"),
);
insta::assert_snapshot!(out.ansi_strip());
}
}