use std::path::{Path, PathBuf};
use std::process::Command;
use crate::error::{JjHooksError, Result};
use crate::runner::Runner;
pub trait Prompter {
fn confirm(&mut self, message: &str, default: bool) -> Result<bool>;
}
pub struct ScriptedPrompter {
answers: std::vec::IntoIter<bool>,
}
impl ScriptedPrompter {
pub fn new(answers: Vec<bool>) -> Self {
Self {
answers: answers.into_iter(),
}
}
}
impl Prompter for ScriptedPrompter {
fn confirm(&mut self, _message: &str, default: bool) -> Result<bool> {
Ok(self.answers.next().unwrap_or(default))
}
}
pub struct InteractivePrompter;
impl Prompter for InteractivePrompter {
fn confirm(&mut self, message: &str, default: bool) -> Result<bool> {
dialoguer::Confirm::new()
.with_prompt(message)
.default(default)
.interact()
.map_err(|e| JjHooksError::Io(std::io::Error::other(e.to_string())))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InitPlan {
pub install_alias: bool,
pub advance_bookmarks: bool,
pub install_jjui_actions: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct AddedItems {
pub added_jj_push: bool,
pub added_jj_push_selected: bool,
pub added_binding_x_p: bool,
pub added_binding_x_p_caps: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InitOutcome {
pub alias_set: bool,
pub advance_bookmarks_set: bool,
pub jjui_actions_added: AddedItems,
}
pub fn plan(detected_runner: Option<Runner>, prompter: &mut dyn Prompter) -> Result<InitPlan> {
if let Some(runner) = detected_runner {
tracing::info!("detected hook runner: {}", runner.bin());
} else {
tracing::info!("no hook-runner config detected at workspace root");
}
let install_alias = prompter.confirm(
"Set up `jj push` alias so it runs hooks before pushing?",
false,
)?;
let advance_bookmarks = prompter.confirm(
"Auto-advance bookmarks to fixup commits when hooks modify files?",
false,
)?;
let install_jjui_actions = prompter.confirm(
"Install jjui actions/bindings so `jj-hp push` is reachable from inside jjui?",
false,
)?;
Ok(InitPlan {
install_alias,
advance_bookmarks,
install_jjui_actions,
})
}
pub fn apply(
plan: &InitPlan,
jj_config_path: Option<&Path>,
jjui_config_path: Option<&Path>,
) -> Result<InitOutcome> {
let mut outcome = InitOutcome {
alias_set: false,
advance_bookmarks_set: false,
jjui_actions_added: AddedItems::default(),
};
if plan.install_alias {
jj_config_set(
"aliases.push",
r#"["util", "exec", "--", "jj-hp", "push"]"#,
jj_config_path,
)?;
outcome.alias_set = true;
}
if plan.advance_bookmarks {
jj_config_set("jj-hooks.advance-bookmarks", "true", jj_config_path)?;
outcome.advance_bookmarks_set = true;
}
if plan.install_jjui_actions {
let path = match jjui_config_path {
Some(p) => p.to_path_buf(),
None => default_jjui_config_path()?,
};
outcome.jjui_actions_added = apply_jjui_config(&path)?;
}
Ok(outcome)
}
fn jj_config_set(key: &str, value: &str, config_path: Option<&Path>) -> Result<()> {
let mut cmd = Command::new("jj");
cmd.args(["config", "set", "--user", key, value]);
if let Some(path) = config_path {
cmd.env("JJ_CONFIG", path);
}
let output = cmd.output()?;
if !output.status.success() {
return Err(JjHooksError::JjFailed {
status: output.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
Ok(())
}
fn default_jjui_config_path() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os("JJUI_CONFIG_DIR") {
return Ok(PathBuf::from(dir).join("config.toml"));
}
let base = if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
PathBuf::from(xdg)
} else {
let home = std::env::var_os("HOME").ok_or_else(|| {
JjHooksError::Io(std::io::Error::other(
"neither JJUI_CONFIG_DIR, XDG_CONFIG_HOME, nor HOME is set",
))
})?;
PathBuf::from(home).join(".config")
};
Ok(base.join("jjui").join("config.toml"))
}
fn apply_jjui_config(path: &Path) -> Result<AddedItems> {
let existing = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let (merged, added) = add_jjui_actions(&existing)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, merged)?;
Ok(added)
}
pub fn add_jjui_actions(existing: &str) -> Result<(String, AddedItems)> {
let mut doc: toml::Table = if existing.trim().is_empty() {
toml::Table::new()
} else {
existing
.parse()
.map_err(|e: toml::de::Error| JjHooksError::Parse(format!("jjui config: {e}")))?
};
let mut added = AddedItems::default();
let actions = doc
.entry("actions")
.or_insert_with(|| toml::Value::Array(Vec::new()));
let actions_arr = actions
.as_array_mut()
.ok_or_else(|| JjHooksError::Parse("jjui config: `actions` is not an array".into()))?;
let jj_push_already = actions_arr
.iter()
.any(|v| v.get("name").and_then(|n| n.as_str()) == Some("jj-push"));
let jj_push_selected_already = actions_arr
.iter()
.any(|v| v.get("name").and_then(|n| n.as_str()) == Some("jj-push-selected"));
if !jj_push_already {
let mut t = toml::Table::new();
t.insert("name".into(), toml::Value::String("jj-push".into()));
t.insert(
"lua".into(),
toml::Value::String(
" jj_async(\"util\", \"exec\", \"--\", \"jj-hp\", \"push\")\n revisions.refresh()\n"
.into(),
),
);
actions_arr.push(toml::Value::Table(t));
added.added_jj_push = true;
}
if !jj_push_selected_already {
let mut t = toml::Table::new();
t.insert(
"name".into(),
toml::Value::String("jj-push-selected".into()),
);
t.insert(
"lua".into(),
toml::Value::String(
" jj_async(\"util\", \"exec\", \"--\", \"jj-hp\", \"push\", \"-r\", context.commit_id())\n revisions.refresh()\n".into(),
),
);
actions_arr.push(toml::Value::Table(t));
added.added_jj_push_selected = true;
}
let bindings = doc
.entry("bindings")
.or_insert_with(|| toml::Value::Array(Vec::new()));
let bindings_arr = bindings
.as_array_mut()
.ok_or_else(|| JjHooksError::Parse("jjui config: `bindings` is not an array".into()))?;
let binding_x_p_already = bindings_has_action(bindings_arr, "jj-push");
let binding_x_p_caps_already = bindings_has_action(bindings_arr, "jj-push-selected");
if !binding_x_p_already {
bindings_arr.push(make_binding("jj-push", &["x", "p"], "revisions", "jj push"));
added.added_binding_x_p = true;
}
if !binding_x_p_caps_already {
bindings_arr.push(make_binding(
"jj-push-selected",
&["x", "P"],
"revisions",
"jj push selected bookmark(s)",
));
added.added_binding_x_p_caps = true;
}
let serialized = toml::to_string_pretty(&doc)
.map_err(|e| JjHooksError::Parse(format!("serializing jjui config: {e}")))?;
Ok((serialized, added))
}
fn bindings_has_action(arr: &[toml::Value], action: &str) -> bool {
arr.iter()
.any(|v| v.get("action").and_then(|n| n.as_str()) == Some(action))
}
fn make_binding(action: &str, seq: &[&str], scope: &str, desc: &str) -> toml::Value {
let mut t = toml::Table::new();
t.insert("action".into(), toml::Value::String(action.into()));
t.insert(
"seq".into(),
toml::Value::Array(
seq.iter()
.map(|s| toml::Value::String((*s).into()))
.collect(),
),
);
t.insert("scope".into(), toml::Value::String(scope.into()));
t.insert("desc".into(), toml::Value::String(desc.into()));
toml::Value::Table(t)
}