use std::borrow::Cow;
use std::collections::{BTreeSet, HashMap};
use std::fmt;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use anyhow::Context;
use color_print::cformat;
use minijinja::value::{Enumerator, Object, ObjectRepr};
use minijinja::{Environment, ErrorKind, UndefinedBehavior, Value};
use regex::Regex;
use shell_escape::escape;
use crate::git::{HookType, Repository};
use crate::path::to_posix_path;
use crate::styling::{
eprintln, error_message, format_bash_with_gutter, format_with_gutter, hint_message,
info_message, verbosity,
};
pub const ACTIVE_VARS: &[&str] = &[
"branch",
"worktree_path",
"worktree_name",
"commit",
"short_commit",
"upstream",
];
pub const REPO_VARS: &[&str] = &[
"repo",
"repo_path",
"owner",
"primary_worktree_path",
"default_branch",
"remote",
"remote_url",
];
pub const EXEC_BASE_VARS: &[&str] = &["cwd"];
pub fn base_vars() -> Vec<&'static str> {
let mut v = Vec::with_capacity(ACTIVE_VARS.len() + REPO_VARS.len() + EXEC_BASE_VARS.len());
v.extend_from_slice(ACTIVE_VARS);
v.extend_from_slice(REPO_VARS);
v.extend_from_slice(EXEC_BASE_VARS);
v
}
pub const ALIAS_ARGS_KEY: &str = "args";
pub const DEPRECATED_TEMPLATE_VARS: &[&str] = &[
"main_worktree",
"repo_root",
"worktree",
"main_worktree_path",
];
#[derive(Debug, Clone, Copy)]
pub enum ValidationScope {
Hook(HookType),
SwitchExecute,
Alias,
}
fn hook_extras(hook_type: HookType) -> &'static [&'static str] {
use HookType::*;
match hook_type {
PreSwitch | PostSwitch => &[
"base",
"base_worktree_path",
"target",
"target_worktree_path",
"pr_number",
"pr_url",
],
PreStart | PostStart => &[
"base",
"base_worktree_path",
"target",
"target_worktree_path",
"pr_number",
"pr_url",
],
PreCommit | PostCommit => &["target"],
PreMerge | PostMerge => &["target", "target_worktree_path"],
PreRemove | PostRemove => &["target", "target_worktree_path"],
}
}
const HOOK_INFRASTRUCTURE_VARS: &[&str] = &["hook_type", "hook_name"];
pub fn vars_available_in(scope: ValidationScope) -> Vec<&'static str> {
let mut vars: Vec<&'static str> = base_vars();
match scope {
ValidationScope::Hook(hook_type) => {
vars.extend(HOOK_INFRASTRUCTURE_VARS);
vars.extend(hook_extras(hook_type));
vars.push(ALIAS_ARGS_KEY);
}
ValidationScope::SwitchExecute => {
vars.extend(["base", "base_worktree_path"]);
}
ValidationScope::Alias => {
vars.push(ALIAS_ARGS_KEY);
}
}
vars.extend(DEPRECATED_TEMPLATE_VARS);
vars
}
fn format_variables_table(vars: &[&'static str], ctx: &HashMap<String, String>) -> String {
let max_name = vars.iter().map(|v| v.len()).max().unwrap_or(0);
vars.iter()
.map(|var| {
let value = match ctx.get(*var) {
Some(v) => v.as_str(),
None => "(unset)",
};
format!("{var:<max_name$} = {value}")
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn format_hook_variables(hook_type: HookType, ctx: &HashMap<String, String>) -> String {
let vars: Vec<&'static str> = ACTIVE_VARS
.iter()
.chain(hook_extras(hook_type))
.chain(REPO_VARS)
.chain(EXEC_BASE_VARS)
.chain(HOOK_INFRASTRUCTURE_VARS)
.copied()
.collect();
format_variables_table(&vars, ctx)
}
pub fn format_alias_variables(ctx: &HashMap<String, String>) -> String {
let vars: Vec<&'static str> = ACTIVE_VARS
.iter()
.copied()
.chain(REPO_VARS.iter().copied())
.chain(EXEC_BASE_VARS.iter().copied())
.chain(std::iter::once(ALIAS_ARGS_KEY))
.collect();
let mut display_ctx = ctx.clone();
if let Some(json) = ctx.get(ALIAS_ARGS_KEY) {
let args: Vec<String> = serde_json::from_str(json)
.expect("ALIAS_ARGS_KEY is always serialized from a Vec<String>");
display_ctx.insert(ALIAS_ARGS_KEY.into(), shell_join(&args));
}
format_variables_table(&vars, &display_ctx)
}
#[derive(Debug)]
struct ShellArgs(Vec<String>);
impl ShellArgs {
fn new(args: Vec<String>) -> Self {
Self(args)
}
}
impl Object for ShellArgs {
fn repr(self: &Arc<Self>) -> ObjectRepr {
ObjectRepr::Seq
}
fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
let idx = key.as_usize()?;
self.0.get(idx).cloned().map(Value::from)
}
fn enumerate(self: &Arc<Self>) -> Enumerator {
Enumerator::Seq(self.0.len())
}
fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&shell_join(&self.0))
}
}
fn shell_join(args: &[String]) -> String {
args.iter()
.map(|a| escape(Cow::Borrowed(a)).into_owned())
.collect::<Vec<_>>()
.join(" ")
}
fn string_to_port(s: &str) -> u16 {
let mut h = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut h);
10000 + (h.finish() % 10000) as u16
}
pub fn sanitize_branch_name(branch: &str) -> String {
branch.replace(['/', '\\'], "-")
}
pub fn sanitize_db(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut result = String::with_capacity(s.len() + 4); let mut prev_underscore = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
result.push(c.to_ascii_lowercase());
prev_underscore = false;
} else if !prev_underscore {
result.push('_');
prev_underscore = true;
}
}
if result.starts_with(|c: char| c.is_ascii_digit()) {
result.insert(0, '_');
}
if result.len() > 59 {
result.truncate(59);
}
if !result.ends_with('_') {
result.push('_');
}
result.push_str(&short_hash(s));
result
}
pub fn short_hash(s: &str) -> String {
let mut h = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut h);
let hash = h.finish();
const CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
let c0 = CHARS[(hash % 36) as usize];
let c1 = CHARS[((hash / 36) % 36) as usize];
let c2 = CHARS[((hash / 1296) % 36) as usize];
String::from_utf8(vec![c0, c1, c2]).unwrap()
}
pub fn redact_credentials(s: &str) -> String {
thread_local! {
static CREDENTIAL_URL: Regex = Regex::new(r"^([a-z][a-z0-9+.-]*://)([^@/]+)@").unwrap();
}
CREDENTIAL_URL.with(|re| re.replace(s, "${1}[REDACTED]@").into_owned())
}
#[derive(Debug)]
pub struct TemplateExpandError {
pub message: String,
pub source_line: Option<String>,
pub available_vars: Vec<String>,
}
impl std::fmt::Display for TemplateExpandError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut parts = vec![error_message(&self.message).to_string()];
if let Some(ref line) = self.source_line {
parts.push(format_with_gutter(line, None));
}
if !self.available_vars.is_empty() {
let underlined_vars: Vec<String> = self
.available_vars
.iter()
.map(|v| cformat!("<underline>{}</>", v))
.collect();
parts.push(
hint_message(cformat!(
"Available variables: {}",
underlined_vars.join(", ")
))
.to_string(),
);
}
write!(f, "{}", parts.join("\n"))
}
}
impl std::error::Error for TemplateExpandError {}
fn build_template_error(
e: &minijinja::Error,
template: &str,
name: &str,
available_vars: Vec<String>,
) -> TemplateExpandError {
let lines: Vec<&str> = template.lines().collect();
let line_num = e.line();
let source_line =
line_num.and_then(|n| lines.get(n.saturating_sub(1)).copied().map(String::from));
let detail = match e.detail() {
Some(detail) => format!("{}: {detail}", e.kind()),
None => e.kind().to_string(),
};
let is_undefined = e.kind() == ErrorKind::UndefinedError;
let message = match line_num {
Some(n) => format!("Failed to expand {name}: {detail} @ line {n}"),
None => format!("Failed to expand {name}: {detail}"),
};
TemplateExpandError {
message,
source_line,
available_vars: if is_undefined {
available_vars
} else {
Vec::new()
},
}
}
fn setup_template_env(repo: &Repository) -> Environment<'static> {
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
env.add_filter("sanitize", |value: Value| -> String {
sanitize_branch_name(value.as_str().unwrap_or_default())
});
env.add_filter("sanitize_db", |value: Value| -> String {
sanitize_db(value.as_str().unwrap_or_default())
});
env.add_filter("sanitize_hash", |value: Value| -> String {
crate::path::sanitize_for_filename(value.as_str().unwrap_or_default())
});
env.add_filter("hash_port", |value: String| string_to_port(&value));
let repo_clone = repo.clone();
env.add_function("worktree_path_of_branch", move |branch: String| -> String {
repo_clone
.worktree_for_branch(&branch)
.ok()
.flatten()
.map(|p| to_posix_path(&p.to_string_lossy()))
.unwrap_or_default()
});
env
}
fn referenced_vars(template: &str) -> std::collections::HashSet<String> {
minijinja::Environment::new()
.template_from_str(template)
.map(|tmpl| tmpl.undeclared_variables(false))
.unwrap_or_default()
}
pub fn template_references_var(template: &str, var: &str) -> bool {
referenced_vars(template).contains(var)
}
pub fn referenced_vars_for_config(cfg: &super::CommandConfig) -> anyhow::Result<BTreeSet<String>> {
let env = minijinja::Environment::new();
let mut out = BTreeSet::new();
for cmd in cfg.commands() {
let tmpl = env
.template_from_str(&cmd.template)
.with_context(|| format!("Failed to parse template: {:?}", cmd.template))?;
out.extend(tmpl.undeclared_variables(false));
}
Ok(out)
}
pub fn validate_template_syntax(template: &str, name: &str) -> Result<(), minijinja::Error> {
minijinja::Environment::new()
.template_from_named_str(name, template)
.map(|_| ())
}
pub fn validate_template(
template: &str,
scope: ValidationScope,
repo: &Repository,
name: &str,
) -> Result<(), TemplateExpandError> {
let available = vars_available_in(scope);
let mut context: HashMap<String, minijinja::Value> = available
.iter()
.filter(|&&k| k != ALIAS_ARGS_KEY)
.map(|&k| (k.to_string(), minijinja::Value::from("PLACEHOLDER")))
.collect();
context.insert(
"vars".to_string(),
minijinja::Value::from_serialize(std::collections::BTreeMap::<String, String>::new()),
);
if matches!(scope, ValidationScope::Alias | ValidationScope::Hook(_)) {
context.insert(
ALIAS_ARGS_KEY.to_string(),
Value::from_object(ShellArgs::new(Vec::new())),
);
}
let env = setup_template_env(repo);
let tmpl = env
.template_from_named_str(name, template)
.map_err(|e| build_template_error(&e, template, name, Vec::new()))?;
tmpl.render(minijinja::Value::from_object(context))
.map_err(|e| {
let mut keys: Vec<String> = available.iter().map(|k| k.to_string()).collect();
keys.sort();
build_template_error(&e, template, name, keys)
})?;
Ok(())
}
pub fn expand_template(
template: &str,
vars: &HashMap<&str, &str>,
shell_escape: bool,
repo: &Repository,
name: &str,
) -> Result<String, TemplateExpandError> {
let mut context = HashMap::new();
for (key, value) in vars {
if *key == ALIAS_ARGS_KEY {
let parsed: Vec<String> = serde_json::from_str(value).unwrap_or_default();
context.insert(key.to_string(), Value::from_object(ShellArgs::new(parsed)));
} else {
context.insert(
key.to_string(),
minijinja::Value::from((*value).to_string()),
);
}
}
if template.contains("vars.")
&& let Some(branch) = vars.get("branch")
{
let entries = repo.vars_entries(branch);
let vars_map: std::collections::BTreeMap<String, Value> = entries
.into_iter()
.map(|(k, v)| {
let value = serde_json::from_str::<serde_json::Value>(&v)
.ok()
.filter(|j| j.is_object() || j.is_array())
.map(|j| Value::from_serialize(&j))
.unwrap_or_else(|| Value::from(v));
(k, value)
})
.collect();
context.insert("vars".to_string(), Value::from_serialize(&vars_map));
}
let mut env = setup_template_env(repo);
if shell_escape {
env.set_keep_trailing_newline(true);
env.set_formatter(|out, _state, value| {
if value.is_none() {
return Ok(());
}
if value.downcast_object_ref::<ShellArgs>().is_some() {
write!(out, "{value}")?;
return Ok(());
}
let s = value.to_string();
let escaped = escape(Cow::Borrowed(&s));
write!(out, "{escaped}")?;
Ok(())
});
}
let verbose = verbosity();
if verbose >= 2 {
log::debug!("[template:{name}] template={template:?}");
let mut sorted_vars: Vec<_> = vars.iter().collect();
sorted_vars.sort_by_key(|(k, _)| *k);
log::debug!(
"[template:{name}] vars={{{}}}",
sorted_vars
.iter()
.map(|(k, v)| format!("{k}={:?}", redact_credentials(v)))
.collect::<Vec<_>>()
.join(", ")
);
}
let tmpl = env
.template_from_named_str(name, template)
.map_err(|e| build_template_error(&e, template, name, Vec::new()))?;
let result = tmpl
.render(minijinja::Value::from_object(context))
.map_err(|e| {
let mut keys: Vec<String> = vars.keys().map(|k| k.to_string()).collect();
keys.sort();
build_template_error(&e, template, name, keys)
})?;
if verbose >= 2 {
log::debug!("[template:{name}] result={:?}", redact_credentials(&result));
}
if verbose == 1 {
let header = info_message(cformat!("Expanding <bold>{name}</>"));
let template_gutter = format_bash_with_gutter(template);
let arrow = format_with_gutter(&cformat!("<dim>→</>"), None);
let result_gutter = format_bash_with_gutter(&result);
eprintln!("{header}\n{template_gutter}\n{arrow}\n{result_gutter}");
}
Ok(result)
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use super::*;
use crate::shell_exec::Cmd;
use crate::testing::TestRepo;
fn test_repo() -> TestRepo {
TestRepo::new()
}
#[test]
fn test_sanitize_branch_name() {
let cases = [
("feature/foo", "feature-foo"),
(r"user\task", "user-task"),
("feature/user/task", "feature-user-task"),
(r"feature/user\task", "feature-user-task"),
("simple-branch", "simple-branch"),
("", ""),
("///", "---"),
("/feature", "-feature"),
("feature/", "feature-"),
];
for (input, expected) in cases {
assert_eq!(sanitize_branch_name(input), expected, "input: {input}");
}
}
#[test]
fn test_sanitize_db() {
let cases = [
("feature/auth-oauth2", "feature_auth_oauth2_"),
("123-bug-fix", "_123_bug_fix_"),
("UPPERCASE.Branch", "uppercase_branch_"),
("MyBranch", "mybranch_"),
("ALLCAPS", "allcaps_"),
("feature/foo", "feature_foo_"),
("feature-bar", "feature_bar_"),
("feature.baz", "feature_baz_"),
("feature@qux", "feature_qux_"),
("a--b", "a_b_"),
("a///b", "a_b_"),
("a...b", "a_b_"),
("a-/-b", "a_b_"),
("1branch", "_1branch_"),
("123", "_123_"),
("0test", "_0test_"),
("branch1", "branch1_"),
("_already", "_already_"),
("a", "a_"),
("Feature/Auth-OAuth2", "feature_auth_oauth2_"),
("user/TASK/123", "user_task_123_"),
("naïve-impl", "na_ve_impl_"),
("über-feature", "_ber_feature_"),
];
for (input, expected_prefix) in cases {
let result = sanitize_db(input);
assert!(
result.starts_with(expected_prefix),
"input: {input}, expected prefix: {expected_prefix}, got: {result}"
);
assert_eq!(
result.len(),
expected_prefix.len() + 3,
"input: {input}, result: {result}"
);
}
assert_eq!(sanitize_db(""), "");
for input in ["_", "-", "---", "日本語"] {
let result = sanitize_db(input);
assert!(result.starts_with('_'), "input: {input}, got: {result}");
assert_eq!(result.len(), 4, "input: {input}, got: {result}"); }
}
#[test]
fn test_sanitize_db_collision_avoidance() {
assert_ne!(sanitize_db("a-b"), sanitize_db("a_b"));
assert_ne!(sanitize_db("feature/auth"), sanitize_db("feature-auth"));
assert_ne!(sanitize_db("UPPERCASE"), sanitize_db("uppercase"));
assert_eq!(sanitize_db("test"), sanitize_db("test"));
assert_eq!(sanitize_db("feature/foo"), sanitize_db("feature/foo"));
}
#[test]
fn test_sanitize_db_reserved_words() {
let user = sanitize_db("user");
assert!(user.starts_with("user_"), "got: {user}");
assert_ne!(user, "user");
let select = sanitize_db("select");
assert!(select.starts_with("select_"), "got: {select}");
assert_ne!(select, "select");
}
#[test]
fn test_sanitize_db_truncation() {
let long_input = "a".repeat(100);
let result = sanitize_db(&long_input);
assert_eq!(result.len(), 63, "result: {result}");
assert!(result.starts_with(&"a".repeat(58)), "result: {result}");
assert!(!result.ends_with('_'), "should end with hash chars");
let short = "test";
let result = sanitize_db(short);
assert!(result.starts_with("test_"), "result: {result}");
assert_eq!(result.len(), 8, "result: {result}");
let digit_start = format!("1{}", "x".repeat(100));
let result = sanitize_db(&digit_start);
assert_eq!(result.len(), 63, "result: {result}");
assert!(result.starts_with("_1"), "result: {result}");
}
#[test]
fn test_expand_template_basic() {
let test = test_repo();
let mut vars = HashMap::new();
vars.insert("name", "world");
assert_eq!(
expand_template("Hello {{ name }}", &vars, false, &test.repo, "test").unwrap(),
"Hello world"
);
vars.insert("repo", "myrepo");
assert_eq!(
expand_template("{{ repo }}/{{ name }}", &vars, false, &test.repo, "test").unwrap(),
"myrepo/world"
);
let empty: HashMap<&str, &str> = HashMap::new();
assert_eq!(
expand_template("", &empty, false, &test.repo, "test").unwrap(),
""
);
assert_eq!(
expand_template("static text", &empty, false, &test.repo, "test").unwrap(),
"static text"
);
let err = expand_template("no {{ variables }} here", &empty, false, &test.repo, "test")
.unwrap_err();
assert!(
err.message.contains("undefined value"),
"got: {}",
err.message
);
}
#[test]
fn test_expand_template_shell_escape() {
let test = test_repo();
let mut vars = HashMap::new();
vars.insert("path", "my path");
let expanded = expand_template("cd {{ path }}", &vars, true, &test.repo, "test").unwrap();
assert!(expanded.contains("'my path'") || expanded.contains(r"my\ path"));
vars.insert("arg", "test;rm -rf");
let expanded = expand_template("echo {{ arg }}", &vars, true, &test.repo, "test").unwrap();
assert!(!expanded.contains(";rm") || expanded.contains("'"));
vars.insert("branch", "feature/foo");
assert_eq!(
expand_template("{{ branch }}", &vars, false, &test.repo, "test").unwrap(),
"feature/foo"
);
}
#[test]
fn test_expand_template_errors() {
let test = test_repo();
let vars = HashMap::new();
let err = expand_template("{{ unclosed", &vars, false, &test.repo, "test").unwrap_err();
assert!(err.message.contains("syntax error"), "got: {}", err.message);
assert!(expand_template("{{ 1 + }}", &vars, false, &test.repo, "test").is_err());
assert_snapshot!(err, @"
[31m✗[39m [31mFailed to expand test: syntax error: unexpected end of input, expected end of variable block @ line 1[39m
[107m [0m {{ unclosed
");
}
#[test]
fn test_expand_template_undefined_var_details() {
let test = test_repo();
let mut vars = HashMap::new();
vars.insert("branch", "main");
vars.insert("remote", "origin");
let err =
expand_template("echo {{ target }}", &vars, false, &test.repo, "test").unwrap_err();
assert!(
err.message.contains("undefined value"),
"should mention undefined value: {}",
err.message
);
assert!(err.available_vars.contains(&"branch".to_string()));
assert!(err.available_vars.contains(&"remote".to_string()));
assert_eq!(err.source_line.as_deref(), Some("echo {{ target }}"));
assert_snapshot!(err, @"
[31m✗[39m [31mFailed to expand test: undefined value @ line 1[39m
[107m [0m echo {{ target }}
[2m↳[22m [2mAvailable variables: [4mbranch[24m, [4mremote[24m[22m
");
}
#[test]
fn test_expand_template_jinja_features() {
let test = test_repo();
let mut vars = HashMap::new();
vars.insert("debug", "true");
assert_eq!(
expand_template(
"{% if debug %}DEBUG{% endif %}",
&vars,
false,
&test.repo,
"test"
)
.unwrap(),
"DEBUG"
);
vars.insert("debug", "");
assert_eq!(
expand_template(
"{% if debug %}DEBUG{% endif %}",
&vars,
false,
&test.repo,
"test"
)
.unwrap(),
""
);
let empty: HashMap<&str, &str> = HashMap::new();
assert_eq!(
expand_template(
"{{ missing | default('fallback') }}",
&empty,
false,
&test.repo,
"test",
)
.unwrap(),
"fallback"
);
vars.insert("name", "hello");
assert_eq!(
expand_template("{{ name | upper }}", &vars, false, &test.repo, "test").unwrap(),
"HELLO"
);
}
#[test]
fn test_expand_template_strip_prefix() {
let test = test_repo();
let mut vars = HashMap::new();
vars.insert("branch", "feature/foo");
assert_eq!(
expand_template(
"{{ branch | replace('feature/', '') }}",
&vars,
false,
&test.repo,
"test"
)
.unwrap(),
"foo"
);
assert_eq!(
expand_template(
"{{ branch | replace('feature/', '') | sanitize }}",
&vars,
false,
&test.repo,
"test"
)
.unwrap(),
"foo"
);
vars.insert("branch", "main");
assert_eq!(
expand_template(
"{{ branch | replace('feature/', '') }}",
&vars,
false,
&test.repo,
"test"
)
.unwrap(),
"main"
);
vars.insert("branch", "feature/nested/feature/deep");
assert_eq!(
expand_template("{{ branch[8:] }}", &vars, false, &test.repo, "test").unwrap(),
"nested/feature/deep"
);
assert_eq!(
expand_template(
"{% if branch[:8] == 'feature/' %}{{ branch[8:] }}{% else %}{{ branch }}{% endif %}",
&vars,
false,
&test.repo,
"test"
)
.unwrap(),
"nested/feature/deep"
);
vars.insert("branch", "bugfix/bar");
assert_eq!(
expand_template(
"{% if branch[:8] == 'feature/' %}{{ branch[8:] }}{% else %}{{ branch }}{% endif %}",
&vars,
false,
&test.repo,
"test"
)
.unwrap(),
"bugfix/bar"
);
}
#[test]
fn test_expand_template_sanitize_filter() {
let test = test_repo();
let mut vars = HashMap::new();
vars.insert("branch", "feature/foo");
assert_eq!(
expand_template("{{ branch | sanitize }}", &vars, false, &test.repo, "test").unwrap(),
"feature-foo"
);
vars.insert("branch", r"feature\bar");
assert_eq!(
expand_template("{{ branch | sanitize }}", &vars, false, &test.repo, "test").unwrap(),
"feature-bar"
);
vars.insert("branch", "user/feature/task");
assert_eq!(
expand_template("{{ branch | sanitize }}", &vars, false, &test.repo, "test").unwrap(),
"user-feature-task"
);
vars.insert("branch", "feature/foo");
assert_eq!(
expand_template("{{ branch }}", &vars, false, &test.repo, "test").unwrap(),
"feature/foo"
);
vars.insert("branch", "user's/feature");
let result =
expand_template("{{ branch | sanitize }}", &vars, true, &test.repo, "test").unwrap();
assert_eq!(result, r"'user'\''s-feature'", "sanitize + shell escape");
let result = expand_template("{{ branch }}", &vars, true, &test.repo, "test").unwrap();
assert_eq!(
result, r"'user'\''s/feature'",
"shell escape without filter"
);
let result =
expand_template("prefix-{{ none }}-suffix", &vars, true, &test.repo, "test").unwrap();
assert_eq!(result, "prefix--suffix", "none renders as empty");
}
#[test]
fn test_expand_template_sanitize_db_filter() {
let test = test_repo();
let mut vars = HashMap::new();
vars.insert("branch", "feature/auth-oauth2");
let result = expand_template(
"{{ branch | sanitize_db }}",
&vars,
false,
&test.repo,
"test",
)
.unwrap();
assert!(result.starts_with("feature_auth_oauth2_"), "got: {result}");
vars.insert("branch", "123-bug-fix");
let result = expand_template(
"{{ branch | sanitize_db }}",
&vars,
false,
&test.repo,
"test",
)
.unwrap();
assert!(result.starts_with("_123_bug_fix_"), "got: {result}");
vars.insert("branch", "UPPERCASE.Branch");
let result = expand_template(
"{{ branch | sanitize_db }}",
&vars,
false,
&test.repo,
"test",
)
.unwrap();
assert!(result.starts_with("uppercase_branch_"), "got: {result}");
vars.insert("branch", "feature/foo");
assert_eq!(
expand_template("{{ branch }}", &vars, false, &test.repo, "test").unwrap(),
"feature/foo"
);
}
#[test]
fn test_expand_template_trailing_newline() {
let test = test_repo();
let mut vars = HashMap::new();
vars.insert("cmd", "echo hello");
assert!(
expand_template("{{ cmd }}\n", &vars, true, &test.repo, "test")
.unwrap()
.ends_with('\n')
);
}
#[test]
fn test_string_to_port_deterministic_and_in_range() {
for input in ["main", "feature-foo", "", "a", "long-branch-name-123"] {
let p1 = string_to_port(input);
let p2 = string_to_port(input);
assert_eq!(p1, p2, "same input should produce same port");
assert!((10000..20000).contains(&p1), "port {} out of range", p1);
}
}
#[test]
fn test_hash_port_filter() {
let test = test_repo();
let mut vars = HashMap::new();
vars.insert("branch", "feature-foo");
vars.insert("repo", "myrepo");
let result =
expand_template("{{ branch | hash_port }}", &vars, false, &test.repo, "test").unwrap();
let port: u16 = result.parse().expect("should be a number");
assert!((10000..20000).contains(&port));
let r1 = expand_template(
"{{ (repo ~ '-' ~ branch) | hash_port }}",
&vars,
false,
&test.repo,
"test",
)
.unwrap();
let r1_port: u16 = r1.parse().expect("should be a number");
let r2 = expand_template(
"{{ (repo ~ '-' ~ branch) | hash_port }}",
&vars,
false,
&test.repo,
"test",
)
.unwrap();
let r2_port: u16 = r2.parse().expect("should be a number");
assert!((10000..20000).contains(&r1_port));
assert!((10000..20000).contains(&r2_port));
assert_eq!(r1, r2);
}
#[test]
fn test_redact_credentials_https_token() {
assert_eq!(
redact_credentials("https://ghp_token123@github.com/owner/repo"),
"https://[REDACTED]@github.com/owner/repo"
);
assert_eq!(
redact_credentials("https://glpat-xxxxxxxxxxxx@gitlab.com/owner/repo.git"),
"https://[REDACTED]@gitlab.com/owner/repo.git"
);
}
#[test]
fn test_redact_credentials_https_user_pass() {
assert_eq!(
redact_credentials("https://user:password123@github.com/owner/repo"),
"https://[REDACTED]@github.com/owner/repo"
);
}
#[test]
fn test_redact_credentials_no_credentials() {
assert_eq!(
redact_credentials("https://github.com/owner/repo"),
"https://github.com/owner/repo"
);
assert_eq!(
redact_credentials("git@github.com:owner/repo.git"),
"git@github.com:owner/repo.git"
);
}
#[test]
fn test_redact_credentials_non_url() {
assert_eq!(redact_credentials("main"), "main");
assert_eq!(redact_credentials("feature/auth"), "feature/auth");
assert_eq!(redact_credentials("/path/to/worktree"), "/path/to/worktree");
assert_eq!(redact_credentials(""), "");
}
#[test]
fn test_redact_credentials_git_protocol() {
assert_eq!(
redact_credentials("git://token@github.com/owner/repo.git"),
"git://[REDACTED]@github.com/owner/repo.git"
);
}
#[test]
fn test_redact_credentials_preserves_path() {
assert_eq!(
redact_credentials("https://token@github.com/owner/repo.git?ref=main"),
"https://[REDACTED]@github.com/owner/repo.git?ref=main"
);
}
#[test]
fn test_expand_template_vars_data() {
let test = test_repo();
Cmd::new("git")
.args(["config", "worktrunk.state.main.vars.env", "staging"])
.current_dir(test.path())
.run()
.unwrap();
Cmd::new("git")
.args(["config", "worktrunk.state.main.vars.port", "3000"])
.current_dir(test.path())
.run()
.unwrap();
let mut vars = HashMap::new();
vars.insert("branch", "main");
assert_eq!(
expand_template("{{ vars.env }}", &vars, false, &test.repo, "test").unwrap(),
"staging"
);
assert_eq!(
expand_template("{{ vars.port }}", &vars, false, &test.repo, "test").unwrap(),
"3000"
);
assert_eq!(
expand_template(
"{{ vars.missing | default('fallback') }}",
&vars,
false,
&test.repo,
"test"
)
.unwrap(),
"fallback"
);
assert_eq!(
expand_template(
"{% if vars.env %}env={{ vars.env }}{% endif %}",
&vars,
false,
&test.repo,
"test"
)
.unwrap(),
"env=staging"
);
}
#[test]
fn test_expand_template_vars_json_dot_access() {
let test = test_repo();
Cmd::new("git")
.args([
"config",
"worktrunk.state.main.vars.config",
r#"{"port": 3000, "debug": true}"#,
])
.current_dir(test.path())
.run()
.unwrap();
Cmd::new("git")
.args([
"config",
"worktrunk.state.main.vars.tags",
r#"["alpha", "beta"]"#,
])
.current_dir(test.path())
.run()
.unwrap();
Cmd::new("git")
.args(["config", "worktrunk.state.main.vars.env", "staging"])
.current_dir(test.path())
.run()
.unwrap();
let mut vars = HashMap::new();
vars.insert("branch", "main");
assert_eq!(
expand_template("{{ vars.config.port }}", &vars, false, &test.repo, "test").unwrap(),
"3000"
);
assert_eq!(
expand_template("{{ vars.config.debug }}", &vars, false, &test.repo, "test").unwrap(),
"true"
);
assert_eq!(
expand_template("{{ vars.tags[0] }}", &vars, false, &test.repo, "test").unwrap(),
"alpha"
);
assert_eq!(
expand_template("{{ vars.env }}", &vars, false, &test.repo, "test").unwrap(),
"staging"
);
assert_eq!(
expand_template(
"{{ vars.config.missing | default('fallback') }}",
&vars,
false,
&test.repo,
"test"
)
.unwrap(),
"fallback"
);
}
#[test]
fn test_expand_template_vars_json_shell_escape() {
let test = test_repo();
Cmd::new("git")
.args([
"config",
"worktrunk.state.main.vars.config",
r#"{"name": "my project", "cmd": "echo hello"}"#,
])
.current_dir(test.path())
.run()
.unwrap();
let mut vars = HashMap::new();
vars.insert("branch", "main");
let result =
expand_template("{{ vars.config.name }}", &vars, true, &test.repo, "test").unwrap();
assert_eq!(result, "'my project'");
let result =
expand_template("{{ vars.config.cmd }}", &vars, true, &test.repo, "test").unwrap();
assert_eq!(result, "'echo hello'");
}
#[test]
fn test_expand_template_vars_empty_when_no_branch() {
let test = test_repo();
let vars = HashMap::new();
assert_eq!(
expand_template(
"{{ vars | default('none') }}",
&vars,
false,
&test.repo,
"test"
)
.unwrap(),
"none"
);
}
#[test]
fn test_expand_template_vars_empty_when_no_data() {
let test = test_repo();
let mut vars = HashMap::new();
vars.insert("branch", "main");
assert_eq!(
expand_template(
"{{ vars.env | default('dev') }}",
&vars,
false,
&test.repo,
"test"
)
.unwrap(),
"dev"
);
}
#[test]
fn test_expand_template_args_sequence() {
let test = test_repo();
let args_json = serde_json::to_string(&["foo", "bar baz", "qux"]).unwrap();
let mut vars = HashMap::new();
vars.insert("args", args_json.as_str());
assert_eq!(
expand_template("wt switch {{ args }}", &vars, true, &test.repo, "test").unwrap(),
"wt switch foo 'bar baz' qux"
);
assert_eq!(
expand_template("{{ args[0] }}", &vars, true, &test.repo, "test").unwrap(),
"foo"
);
assert_eq!(
expand_template("{{ args[1] }}", &vars, true, &test.repo, "test").unwrap(),
"'bar baz'"
);
assert_eq!(
expand_template("{{ args | length }}", &vars, false, &test.repo, "test").unwrap(),
"3"
);
assert_eq!(
expand_template(
"{% for a in args %}[{{ a }}]{% endfor %}",
&vars,
true,
&test.repo,
"test"
)
.unwrap(),
"[foo]['bar baz'][qux]"
);
}
#[test]
fn test_expand_template_args_empty() {
let test = test_repo();
let args_json = serde_json::to_string(&Vec::<String>::new()).unwrap();
let mut vars = HashMap::new();
vars.insert("args", args_json.as_str());
assert_eq!(
expand_template("wt switch{{ args }}", &vars, true, &test.repo, "test").unwrap(),
"wt switch"
);
assert_eq!(
expand_template("{{ args | length }}", &vars, false, &test.repo, "test").unwrap(),
"0"
);
assert_eq!(
expand_template(
"{% for a in args %}X{% endfor %}",
&vars,
true,
&test.repo,
"test"
)
.unwrap(),
""
);
}
#[test]
fn test_expand_template_args_shell_metachar_safety() {
let test = test_repo();
let args_json = serde_json::to_string(&["; rm -rf /", "$(whoami)", "a'b"]).unwrap();
let mut vars = HashMap::new();
vars.insert("args", args_json.as_str());
let rendered = expand_template("echo {{ args }}", &vars, true, &test.repo, "test").unwrap();
assert_eq!(rendered, r#"echo '; rm -rf /' '$(whoami)' 'a'\''b'"#);
}
#[test]
fn test_validate_template_valid() {
let test = test_repo();
let hook = ValidationScope::Hook(HookType::PostStart);
assert!(validate_template("echo hello", hook, &test.repo, "test").is_ok());
assert!(validate_template("{{ branch }}", hook, &test.repo, "test").is_ok());
assert!(validate_template("{{ repo }}/{{ branch }}", hook, &test.repo, "test").is_ok());
assert!(validate_template("{{ branch | sanitize }}", hook, &test.repo, "test").is_ok());
assert!(validate_template("{{ branch | sanitize_db }}", hook, &test.repo, "test").is_ok());
assert!(
validate_template("{{ branch | sanitize_hash }}", hook, &test.repo, "test").is_ok()
);
assert!(validate_template("{{ branch | hash_port }}", hook, &test.repo, "test").is_ok());
assert!(
validate_template(
"{% if upstream %}{{ upstream }}{% endif %}",
hook,
&test.repo,
"test"
)
.is_ok()
);
assert!(validate_template("{{ main_worktree }}", hook, &test.repo, "test").is_ok());
assert!(validate_template("echo {{ args }}", hook, &test.repo, "test").is_ok());
let alias = ValidationScope::Alias;
assert!(validate_template("wt switch {{ args }}", alias, &test.repo, "test").is_ok());
assert!(validate_template("{{ args | length }}", alias, &test.repo, "test").is_ok());
assert!(
validate_template(
"{% for a in args %}{{ a }}{% endfor %}",
alias,
&test.repo,
"test"
)
.is_ok()
);
}
#[test]
fn test_validate_template_scope_rejects_out_of_scope_vars() {
let test = test_repo();
let err = validate_template(
"{{ base }}",
ValidationScope::Hook(HookType::PreMerge),
&test.repo,
"test",
)
.unwrap_err();
assert!(
err.message.contains("undefined value"),
"got: {}",
err.message
);
assert!(
validate_template(
"{{ base }}",
ValidationScope::Hook(HookType::PreStart),
&test.repo,
"test"
)
.is_ok()
);
assert!(
validate_template(
"{{ target }}",
ValidationScope::Hook(HookType::PreMerge),
&test.repo,
"test"
)
.is_ok()
);
for var in ["pr_number", "pr_url"] {
assert!(
validate_template(
&format!("{{{{ {var} }}}}"),
ValidationScope::Hook(HookType::PreStart),
&test.repo,
"test"
)
.is_ok(),
"{var} should validate in pre-start scope"
);
}
let err = validate_template(
"{{ pr_number }}",
ValidationScope::Hook(HookType::PreMerge),
&test.repo,
"test",
)
.unwrap_err();
assert!(
err.message.contains("undefined value"),
"got: {}",
err.message
);
assert!(
validate_template(
"{{ args }}",
ValidationScope::Hook(HookType::PreStart),
&test.repo,
"test",
)
.is_ok()
);
let err = validate_template(
"{{ args }}",
ValidationScope::SwitchExecute,
&test.repo,
"test",
)
.unwrap_err();
assert!(
err.message.contains("undefined value"),
"got: {}",
err.message
);
}
#[test]
fn test_validate_template_syntax_error() {
let test = test_repo();
let err = validate_template("{{ unclosed", ValidationScope::Alias, &test.repo, "test")
.unwrap_err();
assert!(err.message.contains("syntax error"), "got: {}", err.message);
}
#[test]
fn test_referenced_vars_for_config_syntax_error_propagates() {
let cfg = super::super::CommandConfig::single("echo {{ unclosed");
let err = referenced_vars_for_config(&cfg).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("Failed to parse template"), "got: {msg}");
assert!(msg.contains("syntax error"), "got: {msg}");
}
#[test]
fn test_validate_template_undefined_var() {
let test = test_repo();
let err = validate_template(
"{{ nonexistent_var }}",
ValidationScope::Hook(HookType::PostStart),
&test.repo,
"test",
)
.unwrap_err();
assert!(
err.message.contains("undefined value"),
"got: {}",
err.message
);
assert!(!err.available_vars.is_empty(), "should list available vars");
assert!(err.available_vars.contains(&"branch".to_string()));
}
#[test]
fn test_format_hook_variables_groups_and_unset() {
let mut ctx: HashMap<String, String> = HashMap::new();
ctx.insert("branch".into(), "feature".into());
ctx.insert("worktree_path".into(), "/tmp/feature".into());
ctx.insert("worktree_name".into(), "feature".into());
ctx.insert("base".into(), "main".into());
ctx.insert("base_worktree_path".into(), "/tmp/main".into());
ctx.insert("target".into(), "-".into());
ctx.insert("repo".into(), "demo".into());
ctx.insert("repo_path".into(), "/tmp/demo".into());
ctx.insert("cwd".into(), "/tmp/feature".into());
ctx.insert("hook_type".into(), "pre-switch".into());
ctx.insert("hook_name".into(), "show-variables".into());
assert_snapshot!(format_hook_variables(HookType::PreSwitch, &ctx), @r"
branch = feature
worktree_path = /tmp/feature
worktree_name = feature
commit = (unset)
short_commit = (unset)
upstream = (unset)
base = main
base_worktree_path = /tmp/main
target = -
target_worktree_path = (unset)
pr_number = (unset)
pr_url = (unset)
repo = demo
repo_path = /tmp/demo
owner = (unset)
primary_worktree_path = (unset)
default_branch = (unset)
remote = (unset)
remote_url = (unset)
cwd = /tmp/feature
hook_type = pre-switch
hook_name = show-variables
");
}
#[test]
fn test_format_hook_variables_filters_operation() {
let mut ctx: HashMap<String, String> = HashMap::new();
ctx.insert("target".into(), "main".into());
let out = format_hook_variables(HookType::PreCommit, &ctx);
assert!(out.contains("target = main"), "got: {out}");
assert!(
!out.contains("base "),
"pre-commit has no `base`; got: {out}"
);
assert!(
!out.contains("pr_number"),
"pre-commit has no `pr_number`; got: {out}"
);
}
#[test]
fn test_format_alias_variables_includes_args_no_hook_keys() {
let mut ctx: HashMap<String, String> = HashMap::new();
ctx.insert("branch".into(), "feature".into());
ctx.insert("worktree_path".into(), "/tmp/feature".into());
ctx.insert("worktree_name".into(), "feature".into());
ctx.insert("repo".into(), "demo".into());
ctx.insert("repo_path".into(), "/tmp/demo".into());
ctx.insert("cwd".into(), "/tmp/feature".into());
ctx.insert(ALIAS_ARGS_KEY.into(), r#"["a","b c"]"#.into());
let out = format_alias_variables(&ctx);
assert!(
out.contains("args = a 'b c'"),
"got: {out}"
);
assert!(!out.contains("hook_type"), "got: {out}");
assert!(!out.contains("target"), "got: {out}");
assert!(!out.contains("base "), "got: {out}");
}
#[test]
fn test_format_alias_variables_args_empty() {
let mut ctx: HashMap<String, String> = HashMap::new();
ctx.insert(ALIAS_ARGS_KEY.into(), "[]".into());
let out = format_alias_variables(&ctx);
assert!(out.ends_with("args = "), "got: {out:?}");
}
}