1use std::path::Path;
30use std::process::Command;
31
32use serde::Deserialize;
33
34use crate::error::{JjHooksError, Result};
35use crate::jj::JjCli;
36
37#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
39pub struct SetupStep {
40 #[serde(default)]
43 pub name: Option<String>,
44 pub run: Vec<String>,
48}
49
50impl SetupStep {
51 pub fn label(&self) -> &str {
54 if let Some(name) = self.name.as_deref() {
55 return name;
56 }
57 self.run.first().map_or("<empty>", String::as_str)
58 }
59}
60
61pub fn parse_steps(toml_fragment: &str) -> Result<Vec<SetupStep>> {
68 let wrapped = format!("setup = {toml_fragment}");
73 let table: SetupConfig = toml::from_str(&wrapped).map_err(|e| {
74 JjHooksError::Parse(format!(
75 "jj-hooks.setup must be an array of tables with a `run` field: {e}"
76 ))
77 })?;
78 Ok(table.setup)
79}
80
81#[derive(Debug, Deserialize)]
82struct SetupConfig {
83 #[serde(default)]
84 setup: Vec<SetupStep>,
85}
86
87pub fn load_steps(jj: &JjCli) -> Result<Vec<SetupStep>> {
92 let Ok(value) = jj.run(&["config", "get", "jj-hooks.setup"]) else {
97 return Ok(vec![]);
98 };
99 let value = value.trim();
100 if value.is_empty() {
101 return Ok(vec![]);
102 }
103 parse_steps(value)
104}
105
106pub fn run_steps(steps: &[SetupStep], worktree: &Path, workspace_root: &Path) -> Result<()> {
117 for step in steps {
118 if step.run.is_empty() {
119 return Err(JjHooksError::Parse(format!(
120 "jj-hooks.setup step `{}` has empty `run` array",
121 step.label()
122 )));
123 }
124
125 tracing::info!("setup step `{}`: running {:?}", step.label(), step.run);
126
127 let status = Command::new(&step.run[0])
128 .args(&step.run[1..])
129 .current_dir(worktree)
130 .env("JJ_HOOKS_WORKSPACE", workspace_root)
131 .status()?;
132
133 if !status.success() {
134 return Err(JjHooksError::SetupFailed {
135 name: step.label().to_owned(),
136 status: status.code().unwrap_or(-1),
137 });
138 }
139 }
140 Ok(())
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn parse_single_step_with_run_only() {
149 let steps = parse_steps(r#"[{ run = ["bun", "install"] }]"#).unwrap();
152 assert_eq!(steps.len(), 1);
153 assert_eq!(steps[0].run, vec!["bun", "install"]);
154 assert_eq!(steps[0].name, None);
155 }
156
157 #[test]
158 fn parse_named_step() {
159 let steps = parse_steps(r#"[{ name = "deps", run = ["bun", "install"] }]"#).unwrap();
160 assert_eq!(steps[0].name.as_deref(), Some("deps"));
161 assert_eq!(steps[0].label(), "deps");
162 }
163
164 #[test]
165 fn parse_multiple_steps_preserves_order() {
166 let steps = parse_steps(
167 r#"[
168 { run = ["echo", "one"] },
169 { name = "two", run = ["echo", "two"] },
170 { run = ["echo", "three"] },
171 ]"#,
172 )
173 .unwrap();
174 assert_eq!(steps.len(), 3);
175 assert_eq!(steps[0].run, vec!["echo", "one"]);
176 assert_eq!(steps[1].label(), "two");
177 assert_eq!(steps[2].run, vec!["echo", "three"]);
178 }
179
180 #[test]
181 fn parse_empty_array_is_no_steps() {
182 let steps = parse_steps("[]").unwrap();
183 assert!(steps.is_empty());
184 }
185
186 #[test]
187 fn parse_missing_run_field_errors() {
188 let err = parse_steps(r#"[{ name = "no-run" }]"#).unwrap_err();
191 assert!(
192 matches!(err, JjHooksError::Parse(_)),
193 "missing `run` should be a parse error, got: {err:?}"
194 );
195 }
196
197 #[test]
198 fn parse_rejects_bare_string_value() {
199 let err = parse_steps(r#""bun install""#).unwrap_err();
203 assert!(matches!(err, JjHooksError::Parse(_)));
204 }
205
206 #[test]
207 fn label_falls_back_to_first_argv_when_name_missing() {
208 let step = SetupStep {
209 name: None,
210 run: vec!["bun".into(), "install".into()],
211 };
212 assert_eq!(step.label(), "bun");
213 }
214
215 #[test]
216 fn label_prefers_name_over_argv() {
217 let step = SetupStep {
218 name: Some("install deps".into()),
219 run: vec!["bun".into(), "install".into()],
220 };
221 assert_eq!(step.label(), "install deps");
222 }
223
224 #[test]
225 fn run_steps_passes_workspace_env_var() {
226 let tmp = tempfile::TempDir::new().unwrap();
232 let out = tmp.path().join("env.txt");
233 let step = SetupStep {
234 name: None,
235 run: vec![
236 "sh".into(),
237 "-c".into(),
238 format!("printf %s \"$JJ_HOOKS_WORKSPACE\" > {}", out.display()),
239 ],
240 };
241 let workspace = tmp.path().join("workspace");
242 std::fs::create_dir(&workspace).unwrap();
243 run_steps(&[step], tmp.path(), &workspace).unwrap();
244 let captured = std::fs::read_to_string(&out).unwrap();
245 assert_eq!(captured, workspace.display().to_string());
246 }
247
248 #[test]
249 fn run_steps_aborts_on_first_failure() {
250 let tmp = tempfile::TempDir::new().unwrap();
254 let marker = tmp.path().join("step2_ran");
255 let steps = vec![
256 SetupStep {
257 name: Some("step1".into()),
258 run: vec!["false".into()],
259 },
260 SetupStep {
261 name: Some("step2".into()),
262 run: vec![
263 "sh".into(),
264 "-c".into(),
265 format!("touch {}", marker.display()),
266 ],
267 },
268 ];
269 let err = run_steps(&steps, tmp.path(), tmp.path()).unwrap_err();
270 let JjHooksError::SetupFailed { name, .. } = err else {
271 panic!("expected SetupFailed, got {err:?}");
272 };
273 assert!(
274 name.contains("step1"),
275 "abort message should name the failed step `step1`: {name}"
276 );
277 assert!(
278 !marker.exists(),
279 "step2 must not run after step1 fails (marker file present)",
280 );
281 }
282
283 #[test]
284 fn run_steps_empty_run_array_errors() {
285 let steps = vec![SetupStep {
286 name: Some("bad".into()),
287 run: vec![],
288 }];
289 let err = run_steps(
290 &steps,
291 std::env::temp_dir().as_path(),
292 std::env::temp_dir().as_path(),
293 )
294 .unwrap_err();
295 assert!(matches!(err, JjHooksError::Parse(_)));
296 }
297
298 #[test]
299 fn run_steps_no_steps_is_silent_ok() {
300 run_steps(
301 &[],
302 std::env::temp_dir().as_path(),
303 std::env::temp_dir().as_path(),
304 )
305 .unwrap();
306 }
307}