use std::path::Path;
use std::process::Command;
use serde::Deserialize;
use crate::error::{JjHooksError, Result};
use crate::jj::JjCli;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct SetupStep {
#[serde(default)]
pub name: Option<String>,
pub run: Vec<String>,
}
impl SetupStep {
pub fn label(&self) -> &str {
if let Some(name) = self.name.as_deref() {
return name;
}
self.run.first().map_or("<empty>", String::as_str)
}
}
pub fn parse_steps(toml_fragment: &str) -> Result<Vec<SetupStep>> {
let wrapped = format!("setup = {toml_fragment}");
let table: SetupConfig = toml::from_str(&wrapped).map_err(|e| {
JjHooksError::Parse(format!(
"jj-hooks.setup must be an array of tables with a `run` field: {e}"
))
})?;
Ok(table.setup)
}
#[derive(Debug, Deserialize)]
struct SetupConfig {
#[serde(default)]
setup: Vec<SetupStep>,
}
pub fn load_steps(jj: &JjCli) -> Result<Vec<SetupStep>> {
let Ok(value) = jj.run(&["config", "get", "jj-hooks.setup"]) else {
return Ok(vec![]);
};
let value = value.trim();
if value.is_empty() {
return Ok(vec![]);
}
parse_steps(value)
}
pub fn run_steps(steps: &[SetupStep], worktree: &Path, workspace_root: &Path) -> Result<()> {
for step in steps {
if step.run.is_empty() {
return Err(JjHooksError::Parse(format!(
"jj-hooks.setup step `{}` has empty `run` array",
step.label()
)));
}
tracing::info!("setup step `{}`: running {:?}", step.label(), step.run);
let status = Command::new(&step.run[0])
.args(&step.run[1..])
.current_dir(worktree)
.env("JJ_HOOKS_WORKSPACE", workspace_root)
.status()?;
if !status.success() {
return Err(JjHooksError::SetupFailed {
name: step.label().to_owned(),
status: status.code().unwrap_or(-1),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_single_step_with_run_only() {
let steps = parse_steps(r#"[{ run = ["bun", "install"] }]"#).unwrap();
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].run, vec!["bun", "install"]);
assert_eq!(steps[0].name, None);
}
#[test]
fn parse_named_step() {
let steps = parse_steps(r#"[{ name = "deps", run = ["bun", "install"] }]"#).unwrap();
assert_eq!(steps[0].name.as_deref(), Some("deps"));
assert_eq!(steps[0].label(), "deps");
}
#[test]
fn parse_multiple_steps_preserves_order() {
let steps = parse_steps(
r#"[
{ run = ["echo", "one"] },
{ name = "two", run = ["echo", "two"] },
{ run = ["echo", "three"] },
]"#,
)
.unwrap();
assert_eq!(steps.len(), 3);
assert_eq!(steps[0].run, vec!["echo", "one"]);
assert_eq!(steps[1].label(), "two");
assert_eq!(steps[2].run, vec!["echo", "three"]);
}
#[test]
fn parse_empty_array_is_no_steps() {
let steps = parse_steps("[]").unwrap();
assert!(steps.is_empty());
}
#[test]
fn parse_missing_run_field_errors() {
let err = parse_steps(r#"[{ name = "no-run" }]"#).unwrap_err();
assert!(
matches!(err, JjHooksError::Parse(_)),
"missing `run` should be a parse error, got: {err:?}"
);
}
#[test]
fn parse_rejects_bare_string_value() {
let err = parse_steps(r#""bun install""#).unwrap_err();
assert!(matches!(err, JjHooksError::Parse(_)));
}
#[test]
fn label_falls_back_to_first_argv_when_name_missing() {
let step = SetupStep {
name: None,
run: vec!["bun".into(), "install".into()],
};
assert_eq!(step.label(), "bun");
}
#[test]
fn label_prefers_name_over_argv() {
let step = SetupStep {
name: Some("install deps".into()),
run: vec!["bun".into(), "install".into()],
};
assert_eq!(step.label(), "install deps");
}
#[test]
fn run_steps_passes_workspace_env_var() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("env.txt");
let step = SetupStep {
name: None,
run: vec![
"sh".into(),
"-c".into(),
format!("printf %s \"$JJ_HOOKS_WORKSPACE\" > {}", out.display()),
],
};
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
run_steps(&[step], tmp.path(), &workspace).unwrap();
let captured = std::fs::read_to_string(&out).unwrap();
assert_eq!(captured, workspace.display().to_string());
}
#[test]
fn run_steps_aborts_on_first_failure() {
let tmp = tempfile::TempDir::new().unwrap();
let marker = tmp.path().join("step2_ran");
let steps = vec![
SetupStep {
name: Some("step1".into()),
run: vec!["false".into()],
},
SetupStep {
name: Some("step2".into()),
run: vec![
"sh".into(),
"-c".into(),
format!("touch {}", marker.display()),
],
},
];
let err = run_steps(&steps, tmp.path(), tmp.path()).unwrap_err();
let JjHooksError::SetupFailed { name, .. } = err else {
panic!("expected SetupFailed, got {err:?}");
};
assert!(
name.contains("step1"),
"abort message should name the failed step `step1`: {name}"
);
assert!(
!marker.exists(),
"step2 must not run after step1 fails (marker file present)",
);
}
#[test]
fn run_steps_empty_run_array_errors() {
let steps = vec![SetupStep {
name: Some("bad".into()),
run: vec![],
}];
let err = run_steps(
&steps,
std::env::temp_dir().as_path(),
std::env::temp_dir().as_path(),
)
.unwrap_err();
assert!(matches!(err, JjHooksError::Parse(_)));
}
#[test]
fn run_steps_no_steps_is_silent_ok() {
run_steps(
&[],
std::env::temp_dir().as_path(),
std::env::temp_dir().as_path(),
)
.unwrap();
}
}