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(
258 actions_arr,
259 push_selected_state,
260 NEW_PUSH_SELECTED_NAME,
261 push_selected_lua_forms()[0].to_owned(),
262 &mut added.added_jj_push_selected,
263 );
264 apply_action(
265 actions_arr,
266 push_state,
267 NEW_PUSH_NAME,
268 push_lua_forms()[0].to_owned(),
269 &mut added.added_jj_push,
270 );
271
272 let bindings = doc
274 .entry("bindings")
275 .or_insert_with(|| toml::Value::Array(Vec::new()));
276 let bindings_arr = bindings
277 .as_array_mut()
278 .ok_or_else(|| JjHooksError::Parse("jjui config: `bindings` is not an array".into()))?;
279
280 for b in bindings_arr.iter_mut() {
285 let Some(action) = b.get("action").and_then(|v| v.as_str()) else {
286 continue;
287 };
288 if action == OLD_PUSH_NAME && push_state == ActionState::OldManaged {
289 let table = b.as_table_mut().unwrap();
290 table.insert("action".into(), toml::Value::String(NEW_PUSH_NAME.into()));
291 table.insert("desc".into(), toml::Value::String(NEW_PUSH_DESC.into()));
292 } else if action == OLD_PUSH_SELECTED_NAME && push_selected_state == ActionState::OldManaged
293 {
294 let table = b.as_table_mut().unwrap();
295 table.insert(
296 "action".into(),
297 toml::Value::String(NEW_PUSH_SELECTED_NAME.into()),
298 );
299 table.insert(
300 "desc".into(),
301 toml::Value::String(NEW_PUSH_SELECTED_DESC.into()),
302 );
303 }
304 }
305
306 migrate_seq_for_managed_binding(
315 bindings_arr,
316 NEW_PUSH_NAME,
317 &push_seq_history(),
318 NEW_PUSH_DESC,
319 );
320 migrate_seq_for_managed_binding(
321 bindings_arr,
322 NEW_PUSH_SELECTED_NAME,
323 &push_selected_seq_history(),
324 NEW_PUSH_SELECTED_DESC,
325 );
326
327 if !bindings_has_action(bindings_arr, NEW_PUSH_SELECTED_NAME) {
332 bindings_arr.push(make_binding(
333 NEW_PUSH_SELECTED_NAME,
334 &push_selected_seq_history()[0],
335 "revisions",
336 NEW_PUSH_SELECTED_DESC,
337 ));
338 added.added_binding_x_p_caps = true;
339 }
340 if !bindings_has_action(bindings_arr, NEW_PUSH_NAME) {
341 bindings_arr.push(make_binding(
342 NEW_PUSH_NAME,
343 &push_seq_history()[0],
344 "revisions",
345 NEW_PUSH_DESC,
346 ));
347 added.added_binding_x_p = true;
348 }
349
350 let serialized = toml::to_string_pretty(&doc)
351 .map_err(|e| JjHooksError::Parse(format!("serializing jjui config: {e}")))?;
352
353 Ok((serialized, added))
354}
355
356const NEW_PUSH_NAME: &str = "jj-hp-push";
357const NEW_PUSH_SELECTED_NAME: &str = "jj-hp-push-selected";
358const OLD_PUSH_NAME: &str = "jj-push";
359const OLD_PUSH_SELECTED_NAME: &str = "jj-push-selected";
360const NEW_PUSH_DESC: &str = "jj-hp push";
361const NEW_PUSH_SELECTED_DESC: &str = "jj-hp push selected bookmark(s)";
362
363fn push_seq_history() -> Vec<Vec<&'static str>> {
373 vec![
374 vec!["x", "P"], vec!["x", "p"], ]
377}
378
379fn push_selected_seq_history() -> Vec<Vec<&'static str>> {
382 vec![
383 vec!["x", "p"], vec!["x", "P"], ]
386}
387
388fn push_lua_forms() -> Vec<&'static str> {
392 vec![
393 " jj_async(\"util\", \"exec\", \"--\", \"jj-hp\", \"push\")\n revisions.refresh()\n",
395 " jj_async(\"push\")\n revisions.refresh()\n",
397 ]
398}
399
400fn push_selected_lua_forms() -> Vec<&'static str> {
401 vec![
402 " jj_async(\"util\", \"exec\", \"--\", \"jj-hp\", \"push\", \"-r\", context.commit_id())\n revisions.refresh()\n",
404 " jj_async(\"push\", \"-r\", context.commit_id())\n revisions.refresh()\n",
406 ]
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411enum ActionState {
412 Missing,
414 AlreadyNewName,
416 OldManaged,
418 OldUserOwned,
420}
421
422fn classify_action(
423 actions: &[toml::Value],
424 new_name: &str,
425 old_name: &str,
426 known_lua: &[&str],
427) -> ActionState {
428 let mut found_new = false;
429 let mut found_old: Option<&str> = None;
430 for a in actions {
431 let Some(name) = a.get("name").and_then(|v| v.as_str()) else {
432 continue;
433 };
434 if name == new_name {
435 found_new = true;
436 }
437 if name == old_name {
438 found_old = a.get("lua").and_then(|v| v.as_str());
439 }
440 }
441 if found_new {
442 return ActionState::AlreadyNewName;
443 }
444 match found_old {
445 None => ActionState::Missing,
446 Some(lua) if known_lua.contains(&lua) => ActionState::OldManaged,
447 Some(_) => ActionState::OldUserOwned,
448 }
449}
450
451fn apply_action(
452 actions: &mut Vec<toml::Value>,
453 state: ActionState,
454 new_name: &str,
455 new_lua: String,
456 added_flag: &mut bool,
457) {
458 match state {
459 ActionState::Missing | ActionState::OldUserOwned => {
460 if matches!(state, ActionState::Missing) {
464 let mut t = toml::Table::new();
465 t.insert("name".into(), toml::Value::String(new_name.into()));
466 t.insert("lua".into(), toml::Value::String(new_lua));
467 actions.push(toml::Value::Table(t));
468 *added_flag = true;
469 }
470 }
471 ActionState::AlreadyNewName => {
472 }
474 ActionState::OldManaged => {
475 for a in actions.iter_mut() {
478 let Some(table) = a.as_table_mut() else {
479 continue;
480 };
481 let name = table
482 .get("name")
483 .and_then(|v| v.as_str())
484 .map(|s| s.to_owned());
485 let old_match = match name.as_deref() {
486 Some("jj-push") if new_name == NEW_PUSH_NAME => true,
487 Some("jj-push-selected") if new_name == NEW_PUSH_SELECTED_NAME => true,
488 _ => false,
489 };
490 if old_match {
491 table.insert("name".into(), toml::Value::String(new_name.into()));
492 table.insert("lua".into(), toml::Value::String(new_lua));
493 break;
494 }
495 }
496 }
497 }
498}
499
500fn bindings_has_action(arr: &[toml::Value], action: &str) -> bool {
501 arr.iter()
502 .any(|v| v.get("action").and_then(|n| n.as_str()) == Some(action))
503}
504
505fn migrate_seq_for_managed_binding(
514 bindings: &mut [toml::Value],
515 name: &str,
516 seq_history: &[Vec<&'static str>],
517 desc: &str,
518) {
519 if seq_history.len() < 2 {
520 return; }
522 let current = &seq_history[0];
523 let prior: std::collections::HashSet<&[&str]> =
524 seq_history[1..].iter().map(|s| s.as_slice()).collect();
525 for b in bindings.iter_mut() {
526 let Some(action) = b.get("action").and_then(|v| v.as_str()) else {
527 continue;
528 };
529 if action != name {
530 continue;
531 }
532 let Some(existing_seq) = b.get("seq").and_then(|v| v.as_array()) else {
533 continue;
534 };
535 let as_strs: Vec<&str> = existing_seq.iter().filter_map(|v| v.as_str()).collect();
536 if !prior.contains(as_strs.as_slice()) {
537 continue;
540 }
541 let table = b.as_table_mut().unwrap();
542 table.insert(
543 "seq".into(),
544 toml::Value::Array(
545 current
546 .iter()
547 .map(|s| toml::Value::String((*s).into()))
548 .collect(),
549 ),
550 );
551 table.insert("desc".into(), toml::Value::String(desc.into()));
552 }
553}
554
555fn make_binding(action: &str, seq: &[&str], scope: &str, desc: &str) -> toml::Value {
556 let mut t = toml::Table::new();
557 t.insert("action".into(), toml::Value::String(action.into()));
558 t.insert(
559 "seq".into(),
560 toml::Value::Array(
561 seq.iter()
562 .map(|s| toml::Value::String((*s).into()))
563 .collect(),
564 ),
565 );
566 t.insert("scope".into(), toml::Value::String(scope.into()));
567 t.insert("desc".into(), toml::Value::String(desc.into()));
568 toml::Value::Table(t)
569}