use std::io::{self, IsTerminal};
use std::process::Command;
use anyhow::{Context, Result};
use crate::configure_prompt::{ask_yes_no, read_line, resolve_omnidiff_path};
const TOOL: &str = "omnidiff";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Scope {
User,
Repo,
}
impl Scope {
fn flag(self) -> &'static str {
match self {
Scope::User => "--user",
Scope::Repo => "--repo",
}
}
fn label(self) -> &'static str {
match self {
Scope::User => "for your user, in every repository",
Scope::Repo => "for this repository only",
}
}
}
pub fn run() -> Result<()> {
if !io::stdin().is_terminal() {
print_manual_instructions();
anyhow::bail!("`omnidiff jj configure` needs an interactive terminal");
}
ensure_jj_available()?;
let omnidiff_path = resolve_omnidiff_path();
println!("Configuring jj to use {omnidiff_path} as its diff tool.\n");
let scope = ask_scope()?;
if scope == Scope::Repo {
ensure_inside_jj_repo()?;
}
let register = ask_yes_no(
&format!(
"{}Register omnidiff as a jj diff tool (`merge-tools.{TOOL}`)? You can then run \
`jj diff --tool {TOOL}`. [Y/n] ",
existing_value_note(&format!("merge-tools.{TOOL}.program"), scope)
),
true,
)?;
let set_default = ask_yes_no(
&format!(
"{}Also make it the default for plain `jj diff` (`ui.diff-formatter`)? [y/N] ",
existing_value_note("ui.diff-formatter", scope)
),
false,
)?;
if !register && !set_default {
println!("\nNothing selected - no changes made.");
return Ok(());
}
println!();
if register {
set_config(
scope,
&format!("merge-tools.{TOOL}.program"),
&omnidiff_path,
)?;
set_config(
scope,
&format!("merge-tools.{TOOL}.diff-args"),
r#"["$left","$right"]"#,
)?;
set_config(
scope,
&format!("merge-tools.{TOOL}.diff-invocation-mode"),
"file-by-file",
)?;
}
if set_default {
set_config(scope, "ui.diff-formatter", TOOL)?;
}
println!("\nDone - configured {}.", scope.label());
if set_default && !register {
println!(
"Note: ui.diff-formatter now names `{TOOL}`, but merge-tools.{TOOL} was not written - \
jj will not find the tool until it is."
);
}
Ok(())
}
fn existing_value_note(key: &str, scope: Scope) -> String {
match get_config(scope, key) {
Some(value) => format!("(currently: {value})\n"),
None => String::new(),
}
}
fn get_config(scope: Scope, key: &str) -> Option<String> {
let output = Command::new("jj")
.arg("config")
.arg("list")
.arg(scope.flag())
.arg(key)
.output()
.ok()?;
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!value.is_empty()).then_some(value)
}
fn set_config(scope: Scope, key: &str, value: &str) -> Result<()> {
let status = Command::new("jj")
.arg("config")
.arg("set")
.arg(scope.flag())
.arg(key)
.arg(value)
.status()
.with_context(|| {
format!(
"failed to run `jj config set {} {key} {value}`",
scope.flag()
)
})?;
if !status.success() {
anyhow::bail!("`jj config set {} {key} {value}` failed", scope.flag());
}
println!(" jj config set {} {key} {value}", scope.flag());
Ok(())
}
fn ensure_jj_available() -> Result<()> {
Command::new("jj")
.arg("--version")
.output()
.context("failed to run `jj --version` - is jj installed and on PATH?")?;
Ok(())
}
fn ensure_inside_jj_repo() -> Result<()> {
let output = Command::new("jj")
.args(["root"])
.output()
.context("failed to run `jj root` - is jj installed?")?;
if !output.status.success() {
anyhow::bail!(
"not inside a jj repository - run this from within one, or choose user-wide scope"
);
}
Ok(())
}
fn print_manual_instructions() {
eprintln!(
"Run these manually instead (see README's \"Jujutsu (jj) integration\" section):\n\n\
jj config set --user merge-tools.omnidiff.program omnidiff\n\
jj config set --user merge-tools.omnidiff.diff-args '[\"$left\",\"$right\"]'\n\
jj config set --user merge-tools.omnidiff.diff-invocation-mode file-by-file\n\n\
Use --repo instead of --user to apply them to the current repository only. To make \
omnidiff the default for plain `jj diff` as well:\n\n\
jj config set --user ui.diff-formatter omnidiff\n\n\
diff-invocation-mode is required: without it jj passes two directories, which omnidiff \
cannot diff."
);
}
fn parse_scope(input: &str) -> Option<Scope> {
match input.trim().to_lowercase().as_str() {
"" | "u" | "user" => Some(Scope::User),
"r" | "repo" => Some(Scope::Repo),
_ => None,
}
}
fn ask_scope() -> Result<Scope> {
loop {
let input = read_line(
"Configure for your user (every repository) or just this one? [u/r] (default: u) ",
)?;
match parse_scope(&input) {
Some(scope) => return Ok(scope),
None => println!("'{input}' - please answer 'u' or 'r'."),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scope_flags_match_jjs_own_spelling() {
assert_eq!(Scope::User.flag(), "--user");
assert_eq!(Scope::Repo.flag(), "--repo");
}
#[test]
fn parse_scope_accepts_u_r_and_their_full_spellings_case_insensitively() {
assert_eq!(parse_scope("u"), Some(Scope::User));
assert_eq!(parse_scope("U"), Some(Scope::User));
assert_eq!(parse_scope("user"), Some(Scope::User));
assert_eq!(parse_scope("r"), Some(Scope::Repo));
assert_eq!(parse_scope("Repo"), Some(Scope::Repo));
}
#[test]
fn parse_scope_defaults_to_user_on_an_empty_line() {
assert_eq!(parse_scope(""), Some(Scope::User));
assert_eq!(parse_scope(" "), Some(Scope::User));
}
#[test]
fn parse_scope_rejects_anything_else() {
assert_eq!(parse_scope("yes"), None);
assert_eq!(
parse_scope("global"),
None,
"that's git's spelling, not jj's"
);
}
}