Skip to main content

jj_hooks/
init.rs

1//! `jj-hooks init` — interactive setup for the user-level config.
2//!
3//! Three yes/no prompts:
4//! 1. Install a `jj push` alias that delegates to `jj-hooks push`.
5//! 2. Auto-advance bookmarks when hooks modify files.
6//! 3. Install jjui actions/bindings so `jj-hp push` is reachable from
7//!    inside [jjui](https://github.com/idursun/jjui).
8//!
9//! The first two write to the user-level jj config via `jj config set`.
10//! The third merges into `~/.config/jjui/config.toml` (or the path passed
11//! via `JJUI_CONFIG_DIR`). Prompts go through the [`Prompter`] trait so
12//! tests can script answers.
13
14use 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
24/// A prompter that returns pre-canned answers in order. Used in tests.
25pub 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
43/// Interactive prompter backed by dialoguer.
44pub 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
78/// Build an [`InitPlan`] by asking the user (via `prompter`) which optional
79/// integrations to install. `detected_runner` is for informational printing
80/// — the plan itself is the same regardless.
81pub 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
108/// Apply an [`InitPlan`] by invoking `jj config set --user` for each
109/// requested jj key, and merging jjui actions/bindings into the jjui
110/// config file when requested.
111///
112/// - `jj_config_path`: if `Some`, `JJ_CONFIG` is set to that path for the
113///   subprocess so writes are scoped (used in tests). `None` writes to
114///   the real user config.
115/// - `jjui_config_path`: where to merge the jjui actions/bindings. `None`
116///   resolves to the canonical path (`$JJUI_CONFIG_DIR/config.toml` or
117///   `~/.config/jjui/config.toml`).
118pub 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
172/// Resolve the canonical jjui config path: `$JJUI_CONFIG_DIR/config.toml`
173/// if set, otherwise `$XDG_CONFIG_HOME/jjui/config.toml`, otherwise
174/// `~/.config/jjui/config.toml`.
175fn 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
192/// Read the jjui config at `path` (treating a missing file as empty),
193/// merge in our actions/bindings, and write back. Returns which items
194/// were added (none if everything was already present).
195fn 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
211/// Merge `jj-hp-push` / `jj-hp-push-selected` actions and their `x p` / `x P`
212/// bindings into a jjui config TOML string.
213///
214/// Three cases per action:
215/// 1. Neither old nor new name present → add the new one.
216/// 2. New name already present → leave alone (idempotent).
217/// 3. Old `jj-push` / `jj-push-selected` name present with a lua body
218///    matching one of our known auto-installed forms → rename in place
219///    (action name + any bindings pointing at it, plus binding desc).
220/// 4. Old name present with a custom lua body the user wrote → leave
221///    alone. The user owns that name.
222///
223/// Returns the new TOML and a record of which items were newly added.
224/// Renames count as 0 added (they're migrations, not adds).
225pub 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    // -- Actions ------------------------------------------------------------
237    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    // -- Bindings -----------------------------------------------------------
268    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 each binding pointing at an old action name, rename its `action`
276    // field to the new name and update `desc`. (This is independent of
277    // whether the action itself got renamed — there could be a stale
278    // binding referencing an action we already renamed.)
279    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    // Add any missing bindings (idempotent).
302    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
334/// Known lua bodies we have auto-installed for `jj-hp-push` historically.
335/// Index 0 is the current form; later indices are older forms we still
336/// recognize as ours (so we can safely rename them).
337fn push_lua_forms() -> Vec<&'static str> {
338    vec![
339        // Current form.
340        "  jj_async(\"util\", \"exec\", \"--\", \"jj-hp\", \"push\")\n  revisions.refresh()\n",
341        // Pre-jj-hp form (called `jj push` via the alias).
342        "  jj_async(\"push\")\n  revisions.refresh()\n",
343    ]
344}
345
346fn push_selected_lua_forms() -> Vec<&'static str> {
347    vec![
348        // Current form.
349        "  jj_async(\"util\", \"exec\", \"--\", \"jj-hp\", \"push\", \"-r\", context.commit_id())\n  revisions.refresh()\n",
350        // Pre-jj-hp form.
351        "  jj_async(\"push\", \"-r\", context.commit_id())\n  revisions.refresh()\n",
352    ]
353}
354
355/// What we found when classifying an action by its name + lua body.
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357enum ActionState {
358    /// Neither name present anywhere — we need to add the new action.
359    Missing,
360    /// New name already present — leave alone.
361    AlreadyNewName,
362    /// Old name present, lua matches one of our known forms — safe to rename.
363    OldManaged,
364    /// Old name present, lua is custom (user-owned) — leave alone.
365    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            // Either we own the slot and need to add it, or the user has
407            // co-opted the old name with custom lua — in both cases we
408            // want to add (or skip if user-owned and we have no slot).
409            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            // Idempotent: leave alone.
419        }
420        ActionState::OldManaged => {
421            // Rename in place. Find the old entry and rename it; also
422            // refresh the lua to the current form.
423            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}