use anyhow::Result;
use std::collections::HashMap;
use std::path::Path;
use worktrunk::HookType;
use worktrunk::config::{
Command, CommandConfig, HookStep, UserConfig, expand_template, template_references_var,
};
use worktrunk::git::Repository;
use worktrunk::path::to_posix_path;
use super::hook_filter::HookSource;
#[derive(Debug)]
pub struct PreparedCommand {
pub name: Option<String>,
pub expanded: String,
pub context_json: String,
pub lazy_template: Option<String>,
}
#[derive(Debug)]
pub enum PreparedStep {
Single(PreparedCommand),
Concurrent(Vec<PreparedCommand>),
}
#[derive(Clone, Copy, Debug)]
pub struct CommandContext<'a> {
pub repo: &'a Repository,
pub config: &'a UserConfig,
pub branch: Option<&'a str>,
pub worktree_path: &'a Path,
pub yes: bool,
}
impl<'a> CommandContext<'a> {
pub fn new(
repo: &'a Repository,
config: &'a UserConfig,
branch: Option<&'a str>,
worktree_path: &'a Path,
yes: bool,
) -> Self {
Self {
repo,
config,
branch,
worktree_path,
yes,
}
}
pub fn branch_or_head(&self) -> &str {
self.branch.unwrap_or("HEAD")
}
pub fn project_id(&self) -> Option<String> {
self.repo.project_identifier().ok()
}
pub fn commit_generation(&self) -> worktrunk::config::CommitGenerationConfig {
self.config.commit_generation(self.project_id().as_deref())
}
}
pub fn build_hook_context(
ctx: &CommandContext<'_>,
extra_vars: &[(&str, &str)],
) -> Result<HashMap<String, String>> {
let repo_root = ctx.repo.repo_path()?;
let repo_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let worktree = to_posix_path(&ctx.worktree_path.to_string_lossy());
let worktree_name = ctx
.worktree_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let repo_path = to_posix_path(&repo_root.to_string_lossy());
let mut map = HashMap::new();
map.insert("repo".into(), repo_name.into());
map.insert("branch".into(), ctx.branch_or_head().into());
map.insert("worktree_name".into(), worktree_name.into());
map.insert("repo_path".into(), repo_path.clone());
map.insert("worktree_path".into(), worktree.clone());
map.insert("main_worktree".into(), repo_name.into());
map.insert("repo_root".into(), repo_path);
map.insert("worktree".into(), worktree);
if let Some(default_branch) = ctx.repo.default_branch() {
map.insert("default_branch".into(), default_branch);
}
if let Ok(Some(path)) = ctx.repo.primary_worktree() {
let path_str = to_posix_path(&path.to_string_lossy());
map.insert("primary_worktree_path".into(), path_str.clone());
map.insert("main_worktree_path".into(), path_str);
}
let commit_ref = ctx.branch.unwrap_or("HEAD");
if let Ok(commit) = ctx.repo.run_command(&["rev-parse", commit_ref]) {
let commit = commit.trim();
map.insert("commit".into(), commit.into());
if commit.len() >= 7 {
map.insert("short_commit".into(), commit[..7].into());
}
}
if let Ok(remote) = ctx.repo.primary_remote() {
map.insert("remote".into(), remote.to_string());
if let Some(url) = ctx.repo.remote_url(&remote) {
map.insert("remote_url".into(), url);
}
if let Some(branch) = ctx.branch
&& let Ok(Some(upstream)) = ctx.repo.branch(branch).upstream()
{
map.insert("upstream".into(), upstream);
}
}
map.insert(
"cwd".into(),
to_posix_path(&ctx.worktree_path.to_string_lossy()),
);
for (k, v) in extra_vars {
map.insert((*k).into(), (*v).into());
}
Ok(map)
}
fn expand_commands(
commands: &[Command],
ctx: &CommandContext<'_>,
extra_vars: &[(&str, &str)],
hook_type: HookType,
source: HookSource,
lazy_enabled: bool,
) -> anyhow::Result<Vec<(Command, String, Option<String>)>> {
if commands.is_empty() {
return Ok(Vec::new());
}
let mut base_context = build_hook_context(ctx, extra_vars)?;
base_context.insert("hook_type".into(), hook_type.to_string());
let mut result = Vec::new();
for cmd in commands {
let mut cmd_context = base_context.clone();
if let Some(ref name) = cmd.name {
cmd_context.insert("hook_name".into(), name.clone());
}
let template_name = match &cmd.name {
Some(name) => format!("{}:{}", source, name),
None => format!("{} {} hook", source, hook_type),
};
let lazy = lazy_enabled && template_references_var(&cmd.template, "vars");
let (expanded_str, lazy_template) = if lazy {
let env = minijinja::Environment::new();
env.template_from_named_str(&template_name, &cmd.template)
.map_err(|e| anyhow::anyhow!("syntax error in {template_name}: {e}"))?;
let tpl = cmd.template.clone();
(tpl.clone(), Some(tpl))
} else {
let vars: HashMap<&str, &str> = cmd_context
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
(
expand_template(&cmd.template, &vars, true, ctx.repo, &template_name)?,
None,
)
};
let context_json = serde_json::to_string(&cmd_context)
.expect("HashMap<String, String> serialization should never fail");
result.push((
Command::with_expansion(cmd.name.clone(), cmd.template.clone(), expanded_str),
context_json,
lazy_template,
));
}
Ok(result)
}
pub fn prepare_commands(
command_config: &CommandConfig,
ctx: &CommandContext<'_>,
extra_vars: &[(&str, &str)],
hook_type: HookType,
source: HookSource,
) -> anyhow::Result<Vec<PreparedCommand>> {
let commands: Vec<Command> = command_config.commands().cloned().collect();
if commands.is_empty() {
return Ok(Vec::new());
}
let lazy = command_config.is_pipeline();
let expanded_with_json = expand_commands(&commands, ctx, extra_vars, hook_type, source, lazy)?;
Ok(expanded_with_json
.into_iter()
.map(|(cmd, context_json, lazy_template)| PreparedCommand {
name: cmd.name,
expanded: cmd.expanded,
context_json,
lazy_template,
})
.collect())
}
pub fn prepare_steps(
command_config: &CommandConfig,
ctx: &CommandContext<'_>,
extra_vars: &[(&str, &str)],
hook_type: HookType,
source: HookSource,
) -> anyhow::Result<Vec<PreparedStep>> {
let steps = command_config.steps();
if steps.is_empty() {
return Ok(Vec::new());
}
let step_sizes: Vec<usize> = steps
.iter()
.map(|s| match s {
HookStep::Single(_) => 1,
HookStep::Concurrent(cmds) => cmds.len(),
})
.collect();
let all_commands: Vec<Command> = command_config.commands().cloned().collect();
let all_expanded = expand_commands(&all_commands, ctx, extra_vars, hook_type, source, true)?;
let mut expanded_iter = all_expanded.into_iter();
let mut result = Vec::new();
for (step, &size) in steps.iter().zip(&step_sizes) {
let chunk: Vec<_> = expanded_iter.by_ref().take(size).collect();
match step {
HookStep::Single(_) => {
let (cmd, json, lazy) = chunk.into_iter().next().unwrap();
result.push(PreparedStep::Single(PreparedCommand {
name: cmd.name,
expanded: cmd.expanded,
context_json: json,
lazy_template: lazy,
}));
}
HookStep::Concurrent(_) => {
let prepared = chunk
.into_iter()
.map(|(cmd, json, lazy)| PreparedCommand {
name: cmd.name,
expanded: cmd.expanded,
context_json: json,
lazy_template: lazy,
})
.collect();
result.push(PreparedStep::Concurrent(prepared));
}
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_template_references_var_for_vars() {
assert!(template_references_var("{{ vars.container }}", "vars"));
assert!(template_references_var("{{vars.container}}", "vars"));
assert!(template_references_var(
"docker run --name {{ vars.name }}",
"vars"
));
assert!(template_references_var(
"{% if vars.key %}yes{% endif %}",
"vars"
));
assert!(!template_references_var(
"echo hello > template_vars.txt",
"vars"
));
assert!(!template_references_var("no vars references here", "vars"));
}
}