1use std::path::{Path, PathBuf};
15use std::process::Command;
16
17use crate::error::{JjHooksError, Result};
18use crate::runner::Runner;
19
20pub trait Prompter {
21 fn confirm(&mut self, message: &str, default: bool) -> Result<bool>;
22}
23
24pub struct ScriptedPrompter {
26 answers: std::vec::IntoIter<bool>,
27}
28
29impl ScriptedPrompter {
30 pub fn new(answers: Vec<bool>) -> Self {
31 Self {
32 answers: answers.into_iter(),
33 }
34 }
35}
36
37impl Prompter for ScriptedPrompter {
38 fn confirm(&mut self, _message: &str, default: bool) -> Result<bool> {
39 Ok(self.answers.next().unwrap_or(default))
40 }
41}
42
43pub struct InteractivePrompter;
45
46impl Prompter for InteractivePrompter {
47 fn confirm(&mut self, message: &str, default: bool) -> Result<bool> {
48 dialoguer::Confirm::new()
49 .with_prompt(message)
50 .default(default)
51 .interact()
52 .map_err(|e| JjHooksError::Io(std::io::Error::other(e.to_string())))
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct InitPlan {
58 pub install_alias: bool,
59 pub advance_bookmarks: bool,
60 pub install_jjui_actions: bool,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub struct AddedItems {
65 pub added_jj_push: bool,
66 pub added_jj_push_selected: bool,
67 pub added_binding_x_p: bool,
68 pub added_binding_x_p_caps: bool,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct InitOutcome {
73 pub alias_set: bool,
74 pub advance_bookmarks_set: bool,
75 pub jjui_actions_added: AddedItems,
76}
77
78pub fn plan(detected_runner: Option<Runner>, prompter: &mut dyn Prompter) -> Result<InitPlan> {
82 if let Some(runner) = detected_runner {
83 tracing::info!("detected hook runner: {}", runner.bin());
84 } else {
85 tracing::info!("no hook-runner config detected at workspace root");
86 }
87
88 let install_alias = prompter.confirm(
89 "Set up `jj push` alias so it runs hooks before pushing?",
90 false,
91 )?;
92 let advance_bookmarks = prompter.confirm(
93 "Auto-advance bookmarks to fixup commits when hooks modify files?",
94 false,
95 )?;
96 let install_jjui_actions = prompter.confirm(
97 "Install jjui actions/bindings so `jj-hp push` is reachable from inside jjui?",
98 false,
99 )?;
100
101 Ok(InitPlan {
102 install_alias,
103 advance_bookmarks,
104 install_jjui_actions,
105 })
106}
107
108pub fn apply(
119 plan: &InitPlan,
120 jj_config_path: Option<&Path>,
121 jjui_config_path: Option<&Path>,
122) -> Result<InitOutcome> {
123 let mut outcome = InitOutcome {
124 alias_set: false,
125 advance_bookmarks_set: false,
126 jjui_actions_added: AddedItems::default(),
127 };
128
129 if plan.install_alias {
130 jj_config_set(
131 "aliases.push",
132 r#"["util", "exec", "--", "jj-hp", "push"]"#,
133 jj_config_path,
134 )?;
135 outcome.alias_set = true;
136 }
137
138 if plan.advance_bookmarks {
139 jj_config_set("jj-hooks.advance-bookmarks", "true", jj_config_path)?;
140 outcome.advance_bookmarks_set = true;
141 }
142
143 if plan.install_jjui_actions {
144 let path = match jjui_config_path {
145 Some(p) => p.to_path_buf(),
146 None => default_jjui_config_path()?,
147 };
148 outcome.jjui_actions_added = apply_jjui_config(&path)?;
149 }
150
151 Ok(outcome)
152}
153
154fn jj_config_set(key: &str, value: &str, config_path: Option<&Path>) -> Result<()> {
155 let mut cmd = Command::new("jj");
156 cmd.args(["config", "set", "--user", key, value]);
157
158 if let Some(path) = config_path {
159 cmd.env("JJ_CONFIG", path);
160 }
161
162 let output = cmd.output()?;
163 if !output.status.success() {
164 return Err(JjHooksError::JjFailed {
165 status: output.status.code().unwrap_or(-1),
166 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
167 });
168 }
169 Ok(())
170}
171
172fn default_jjui_config_path() -> Result<PathBuf> {
176 if let Some(dir) = std::env::var_os("JJUI_CONFIG_DIR") {
177 return Ok(PathBuf::from(dir).join("config.toml"));
178 }
179 let base = if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
180 PathBuf::from(xdg)
181 } else {
182 let home = std::env::var_os("HOME").ok_or_else(|| {
183 JjHooksError::Io(std::io::Error::other(
184 "neither JJUI_CONFIG_DIR, XDG_CONFIG_HOME, nor HOME is set",
185 ))
186 })?;
187 PathBuf::from(home).join(".config")
188 };
189 Ok(base.join("jjui").join("config.toml"))
190}
191
192fn apply_jjui_config(path: &Path) -> Result<AddedItems> {
196 let existing = match std::fs::read_to_string(path) {
197 Ok(s) => s,
198 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
199 Err(e) => return Err(e.into()),
200 };
201
202 let (merged, added) = add_jjui_actions(&existing)?;
203
204 if let Some(parent) = path.parent() {
205 std::fs::create_dir_all(parent)?;
206 }
207 std::fs::write(path, merged)?;
208 Ok(added)
209}
210
211pub fn add_jjui_actions(existing: &str) -> Result<(String, AddedItems)> {
226 let mut doc: toml::Table = if existing.trim().is_empty() {
227 toml::Table::new()
228 } else {
229 existing
230 .parse()
231 .map_err(|e: toml::de::Error| JjHooksError::Parse(format!("jjui config: {e}")))?
232 };
233
234 let mut added = AddedItems::default();
235
236 let actions = doc
238 .entry("actions")
239 .or_insert_with(|| toml::Value::Array(Vec::new()));
240 let actions_arr = actions
241 .as_array_mut()
242 .ok_or_else(|| JjHooksError::Parse("jjui config: `actions` is not an array".into()))?;
243
244 let push_state = classify_action(actions_arr, NEW_PUSH_NAME, OLD_PUSH_NAME, &push_lua_forms());
245 let push_selected_state = classify_action(
246 actions_arr,
247 NEW_PUSH_SELECTED_NAME,
248 OLD_PUSH_SELECTED_NAME,
249 &push_selected_lua_forms(),
250 );
251
252 apply_action(
253 actions_arr,
254 push_state,
255 NEW_PUSH_NAME,
256 push_lua_forms()[0].to_owned(),
257 &mut added.added_jj_push,
258 );
259 apply_action(
260 actions_arr,
261 push_selected_state,
262 NEW_PUSH_SELECTED_NAME,
263 push_selected_lua_forms()[0].to_owned(),
264 &mut added.added_jj_push_selected,
265 );
266
267 let bindings = doc
269 .entry("bindings")
270 .or_insert_with(|| toml::Value::Array(Vec::new()));
271 let bindings_arr = bindings
272 .as_array_mut()
273 .ok_or_else(|| JjHooksError::Parse("jjui config: `bindings` is not an array".into()))?;
274
275 for b in bindings_arr.iter_mut() {
280 let Some(action) = b.get("action").and_then(|v| v.as_str()) else {
281 continue;
282 };
283 if action == OLD_PUSH_NAME && push_state == ActionState::OldManaged {
284 let table = b.as_table_mut().unwrap();
285 table.insert("action".into(), toml::Value::String(NEW_PUSH_NAME.into()));
286 table.insert("desc".into(), toml::Value::String(NEW_PUSH_DESC.into()));
287 } else if action == OLD_PUSH_SELECTED_NAME && push_selected_state == ActionState::OldManaged
288 {
289 let table = b.as_table_mut().unwrap();
290 table.insert(
291 "action".into(),
292 toml::Value::String(NEW_PUSH_SELECTED_NAME.into()),
293 );
294 table.insert(
295 "desc".into(),
296 toml::Value::String(NEW_PUSH_SELECTED_DESC.into()),
297 );
298 }
299 }
300
301 if !bindings_has_action(bindings_arr, NEW_PUSH_NAME) {
303 bindings_arr.push(make_binding(
304 NEW_PUSH_NAME,
305 &["x", "p"],
306 "revisions",
307 NEW_PUSH_DESC,
308 ));
309 added.added_binding_x_p = true;
310 }
311 if !bindings_has_action(bindings_arr, NEW_PUSH_SELECTED_NAME) {
312 bindings_arr.push(make_binding(
313 NEW_PUSH_SELECTED_NAME,
314 &["x", "P"],
315 "revisions",
316 NEW_PUSH_SELECTED_DESC,
317 ));
318 added.added_binding_x_p_caps = true;
319 }
320
321 let serialized = toml::to_string_pretty(&doc)
322 .map_err(|e| JjHooksError::Parse(format!("serializing jjui config: {e}")))?;
323
324 Ok((serialized, added))
325}
326
327const NEW_PUSH_NAME: &str = "jj-hp-push";
328const NEW_PUSH_SELECTED_NAME: &str = "jj-hp-push-selected";
329const OLD_PUSH_NAME: &str = "jj-push";
330const OLD_PUSH_SELECTED_NAME: &str = "jj-push-selected";
331const NEW_PUSH_DESC: &str = "jj-hp push";
332const NEW_PUSH_SELECTED_DESC: &str = "jj-hp push selected bookmark(s)";
333
334fn push_lua_forms() -> Vec<&'static str> {
338 vec![
339 " jj_async(\"util\", \"exec\", \"--\", \"jj-hp\", \"push\")\n revisions.refresh()\n",
341 " jj_async(\"push\")\n revisions.refresh()\n",
343 ]
344}
345
346fn push_selected_lua_forms() -> Vec<&'static str> {
347 vec![
348 " jj_async(\"util\", \"exec\", \"--\", \"jj-hp\", \"push\", \"-r\", context.commit_id())\n revisions.refresh()\n",
350 " jj_async(\"push\", \"-r\", context.commit_id())\n revisions.refresh()\n",
352 ]
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357enum ActionState {
358 Missing,
360 AlreadyNewName,
362 OldManaged,
364 OldUserOwned,
366}
367
368fn classify_action(
369 actions: &[toml::Value],
370 new_name: &str,
371 old_name: &str,
372 known_lua: &[&str],
373) -> ActionState {
374 let mut found_new = false;
375 let mut found_old: Option<&str> = None;
376 for a in actions {
377 let Some(name) = a.get("name").and_then(|v| v.as_str()) else {
378 continue;
379 };
380 if name == new_name {
381 found_new = true;
382 }
383 if name == old_name {
384 found_old = a.get("lua").and_then(|v| v.as_str());
385 }
386 }
387 if found_new {
388 return ActionState::AlreadyNewName;
389 }
390 match found_old {
391 None => ActionState::Missing,
392 Some(lua) if known_lua.contains(&lua) => ActionState::OldManaged,
393 Some(_) => ActionState::OldUserOwned,
394 }
395}
396
397fn apply_action(
398 actions: &mut Vec<toml::Value>,
399 state: ActionState,
400 new_name: &str,
401 new_lua: String,
402 added_flag: &mut bool,
403) {
404 match state {
405 ActionState::Missing | ActionState::OldUserOwned => {
406 if matches!(state, ActionState::Missing) {
410 let mut t = toml::Table::new();
411 t.insert("name".into(), toml::Value::String(new_name.into()));
412 t.insert("lua".into(), toml::Value::String(new_lua));
413 actions.push(toml::Value::Table(t));
414 *added_flag = true;
415 }
416 }
417 ActionState::AlreadyNewName => {
418 }
420 ActionState::OldManaged => {
421 for a in actions.iter_mut() {
424 let Some(table) = a.as_table_mut() else {
425 continue;
426 };
427 let name = table
428 .get("name")
429 .and_then(|v| v.as_str())
430 .map(|s| s.to_owned());
431 let old_match = match name.as_deref() {
432 Some("jj-push") if new_name == NEW_PUSH_NAME => true,
433 Some("jj-push-selected") if new_name == NEW_PUSH_SELECTED_NAME => true,
434 _ => false,
435 };
436 if old_match {
437 table.insert("name".into(), toml::Value::String(new_name.into()));
438 table.insert("lua".into(), toml::Value::String(new_lua));
439 break;
440 }
441 }
442 }
443 }
444}
445
446fn bindings_has_action(arr: &[toml::Value], action: &str) -> bool {
447 arr.iter()
448 .any(|v| v.get("action").and_then(|n| n.as_str()) == Some(action))
449}
450
451fn make_binding(action: &str, seq: &[&str], scope: &str, desc: &str) -> toml::Value {
452 let mut t = toml::Table::new();
453 t.insert("action".into(), toml::Value::String(action.into()));
454 t.insert(
455 "seq".into(),
456 toml::Value::Array(
457 seq.iter()
458 .map(|s| toml::Value::String((*s).into()))
459 .collect(),
460 ),
461 );
462 t.insert("scope".into(), toml::Value::String(scope.into()));
463 t.insert("desc".into(), toml::Value::String(desc.into()));
464 toml::Value::Table(t)
465}