use std::io::IsTerminal;
use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
use clap::{Parser, Subcommand};
use omnidiff::tui;
use omnidiff::tui::positional::{
binary_notice, invoked_as_git_external_diff, resolve_before_after,
};
mod configure_prompt;
mod git_configure;
mod jj_configure;
#[cfg(target_env = "musl")]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[derive(Subcommand)]
enum Command {
Git {
#[command(subcommand)]
action: GitAction,
},
Jj {
#[command(subcommand)]
action: JjAction,
},
Util {
#[command(subcommand)]
action: UtilAction,
},
}
#[derive(Subcommand)]
enum UtilAction {
Completions {
shell: clap_complete::Shell,
},
Man,
}
#[derive(Subcommand)]
enum GitAction {
Configure,
}
#[derive(Subcommand)]
enum JjAction {
Configure,
}
#[derive(clap::ValueEnum, Clone, Copy, PartialEq, Eq, Debug, Default)]
enum ColorChoice {
#[default]
Auto,
Always,
Never,
}
#[derive(clap::ValueEnum, Clone, Copy, PartialEq, Eq, Debug, Default)]
enum Mode {
#[default]
Tui,
Headless,
Json,
}
const EXIT_FAILURE: i32 = 2;
fn parse_positive_rate(value: &str) -> Result<f64, String> {
let rate: f64 = value.parse().map_err(|e| format!("{e}"))?;
if rate.is_finite() && rate > 0.0 {
Ok(rate)
} else {
Err("must be a positive number".to_string())
}
}
#[derive(Parser)]
#[command(
// Explicit: without it clap's derive takes the nearest preceding doc comment (`Command`'s)
// as the description in `--help` and the man page.
about = "Fast, robust, syntax-aware code diffing using tree-sitter ASTs",
long_about = "Fast, robust, syntax-aware code diffing.\n\n\
With no arguments, opens an interactive two-panel terminal UI. With BEFORE and AFTER \
file paths, diffs them directly - as the TUI, as plain text (--headless), or as a single \
JSON object (--mode json) for editor integrations. Also serves as a `git difftool` \
backend and a `jj` diff formatter; see `omnidiff git configure` and `omnidiff jj \
configure`.",
version,
after_help = "Exit codes: 0 on success, 2 on error. Pass --exit-code to additionally get \
1 when the files differ (the diff(1) convention), which is off by default for the same \
reason `git diff` defaults to 0: when a VCS drives omnidiff as a display tool, a non-zero \
exit means \"the tool failed\", not \"the files differ\"."
)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
paths: Vec<PathBuf>,
#[arg(long, value_enum, ignore_case = true, default_value_t = Mode::Tui)]
mode: Mode,
#[arg(long, alias = "batch", conflicts_with = "mode")]
headless: bool,
#[arg(long, conflicts_with = "full")]
minimal: bool,
#[arg(long)]
full: bool,
#[arg(long)]
whole_updates: bool,
#[arg(long)]
paint_reindent_moves: bool,
#[arg(long, value_enum, value_name = "WHEN", default_value_t = ColorChoice::Auto)]
color: ColorChoice,
#[arg(long)]
exit_code: bool,
#[arg(long, value_name = "N", default_value_t = tui::headless::CONTEXT_LINES)]
context: usize,
#[arg(long, conflicts_with_all = ["paths", "headless", "mode"])]
review: bool,
#[arg(long, hide = true, value_name = "FLOAT", default_value_t = 4.0, value_parser = parse_positive_rate)]
tui_tick_rate: f64,
#[arg(long, hide = true, value_name = "FLOAT", default_value_t = 60.0, value_parser = parse_positive_rate)]
tui_frame_rate: f64,
}
async fn tui_main(args: &Args, before_after: Option<(PathBuf, PathBuf)>) -> Result<()> {
tui::initialize_logging()?;
tui::ui::install_panic_hook();
let mut app = tui::app::App::new(args.tui_tick_rate, args.tui_frame_rate)?;
if let Some(options) = render_option_flags(args) {
app.override_render_options(options);
}
if let Some((before, after)) = before_after {
app.open_files(before, after)?;
}
if args.review {
app.start_in_review();
}
app.run().await?;
Ok(())
}
fn should_run_headless(args: &Args, stdout_is_terminal: bool) -> bool {
args.headless || args.mode == Mode::Headless || !stdout_is_terminal
}
fn should_run_json(args: &Args) -> bool {
args.mode == Mode::Json
}
fn headless_needs_files_message(args: &Args) -> &'static str {
if args.headless || args.mode == Mode::Headless {
"text mode needs BEFORE and AFTER - pass two files to diff"
} else {
"stdout is not a terminal and no files were given - pass BEFORE and AFTER to run in text \
mode, or run from a real terminal to use the interactive viewer"
}
}
fn render_option_flags(args: &Args) -> Option<omnidiff::diff::text::RenderOptions> {
use omnidiff::diff::text::RenderOptions;
let preset = if args.minimal {
Some(RenderOptions::MINIMAL)
} else if args.full {
Some(RenderOptions::FULL)
} else {
None
};
if !args.whole_updates && !args.paint_reindent_moves {
return preset;
}
let mut options = preset.unwrap_or_else(omnidiff::tui::theme::load_render_options);
options.whole_pair_updates |= args.whole_updates;
options.paint_reindent_only_moves |= args.paint_reindent_moves;
Some(options)
}
fn render_options(args: &Args) -> omnidiff::diff::text::RenderOptions {
render_option_flags(args).unwrap_or_else(omnidiff::tui::theme::load_render_options)
}
fn use_color(choice: ColorChoice) -> bool {
match choice {
ColorChoice::Always => true,
ColorChoice::Never => false,
ColorChoice::Auto => !no_color_is_set(std::env::var_os("NO_COLOR")),
}
}
fn no_color_is_set(value: Option<std::ffi::OsString>) -> bool {
value.is_some_and(|value| !value.is_empty())
}
fn exit_code_for(differed: bool, want_exit_code: bool, invoked_as_git_external_diff: bool) -> i32 {
if differed && want_exit_code && !invoked_as_git_external_diff {
1
} else {
0
}
}
fn run_binary(args: &Args, before: &std::path::Path, after: &std::path::Path) -> Result<i32> {
let differed = std::fs::read(before)? != std::fs::read(after)?;
if should_run_json(args) {
let mut json = tui::json_output::binary_diff_json(before, after)?;
json.push('\n');
tui::headless::write_stdout(&json)?;
} else {
tui::headless::write_stdout(&binary_notice(&args.paths, before, after, differed))?;
}
Ok(exit_code_for(
differed,
args.exit_code,
invoked_as_git_external_diff(&args.paths),
))
}
fn run_util(action: &UtilAction) -> Result<()> {
use clap::CommandFactory;
let mut command = Args::command();
match action {
UtilAction::Completions { shell } => {
clap_complete::generate(*shell, &mut command, "omnidiff", &mut std::io::stdout());
}
UtilAction::Man => {
clap_mangen::Man::new(command).render(&mut std::io::stdout())?;
}
}
Ok(())
}
fn is_broken_pipe(error: &anyhow::Error) -> bool {
error.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::BrokenPipe)
})
}
#[tokio::main]
async fn main() {
let code = match run().await {
Ok(code) => code,
Err(error) if is_broken_pipe(&error) => 0,
Err(error) => {
eprintln!("omnidiff: {error:#}");
EXIT_FAILURE
}
};
std::process::exit(code);
}
async fn run() -> Result<i32> {
let args = Args::parse();
if let Some(Command::Git {
action: GitAction::Configure,
}) = &args.command
{
return git_configure::run().map(|()| 0);
}
if let Some(Command::Jj {
action: JjAction::Configure,
}) = &args.command
{
return jj_configure::run().map(|()| 0);
}
if let Some(Command::Util { action }) = &args.command {
return run_util(action).map(|()| 0);
}
let before_after = resolve_before_after(&args.paths)?;
let invoked_as_git_external_diff = invoked_as_git_external_diff(&args.paths);
if let Some((before, after)) = before_after.as_ref() {
let either_is_binary =
omnidiff::code::is_binary_file(before)? || omnidiff::code::is_binary_file(after)?;
if either_is_binary {
return run_binary(&args, before, after);
}
}
if should_run_json(&args) {
let (before, after) = before_after
.context("`--mode json` needs BEFORE and AFTER - pass two files to diff")?;
let differed = tui::json_output::run(&before, &after, render_options(&args))?;
return Ok(exit_code_for(
differed,
args.exit_code,
invoked_as_git_external_diff,
));
}
if should_run_headless(&args, std::io::stdout().is_terminal()) {
let (before, after) = before_after.context(headless_needs_files_message(&args))?;
let differed = tui::headless::run(
&before,
&after,
use_color(args.color),
args.context,
render_options(&args),
)?;
return Ok(exit_code_for(
differed,
args.exit_code,
invoked_as_git_external_diff,
));
}
tui_main(&args, before_after).await?;
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_before_after_with_no_args_starts_an_empty_viewer() {
assert_eq!(resolve_before_after(&[]).unwrap(), None);
}
#[test]
fn resolve_before_after_with_two_args_is_before_after_directly() {
let paths = vec![PathBuf::from("before.rs"), PathBuf::from("after.rs")];
assert_eq!(
resolve_before_after(&paths).unwrap(),
Some((PathBuf::from("before.rs"), PathBuf::from("after.rs")))
);
}
#[test]
fn resolve_before_after_with_seven_args_picks_out_old_file_and_new_file() {
let paths = vec![
PathBuf::from("src/foo.rs"),
PathBuf::from("/tmp/git-blob-AAAA/foo.rs"),
PathBuf::from("abc123"),
PathBuf::from("100644"),
PathBuf::from("/tmp/git-blob-BBBB/foo.rs"),
PathBuf::from("def456"),
PathBuf::from("100644"),
];
assert_eq!(
resolve_before_after(&paths).unwrap(),
Some((
PathBuf::from("/tmp/git-blob-AAAA/foo.rs"),
PathBuf::from("/tmp/git-blob-BBBB/foo.rs")
))
);
}
#[test]
fn resolve_before_after_accepts_gits_nine_argument_rename_form() {
let paths = vec![
PathBuf::from("README.md"),
PathBuf::from("/tmp/git-blob-AAAA/README.md"),
PathBuf::from("abc123"),
PathBuf::from("100644"),
PathBuf::from("/tmp/git-blob-BBBB/README_tmp.md"),
PathBuf::from("abc123"),
PathBuf::from("100644"),
PathBuf::from("README_tmp.md"),
PathBuf::from("similarity index 100%"),
];
assert_eq!(
resolve_before_after(&paths).unwrap(),
Some((
PathBuf::from("/tmp/git-blob-AAAA/README.md"),
PathBuf::from("/tmp/git-blob-BBBB/README_tmp.md")
))
);
assert!(invoked_as_git_external_diff(&paths));
}
#[test]
fn resolve_before_after_still_rejects_an_unrecognized_argument_count() {
let paths: Vec<PathBuf> = (0..8).map(|n| PathBuf::from(n.to_string())).collect();
assert!(resolve_before_after(&paths).is_err());
assert!(!invoked_as_git_external_diff(&paths));
}
#[test]
fn resolve_before_after_with_seven_args_passes_dev_null_through_for_add_delete() {
let paths = vec![
PathBuf::from("src/foo.rs"),
PathBuf::from("/dev/null"),
PathBuf::from("."),
PathBuf::from("."),
PathBuf::from("/tmp/git-blob-BBBB/foo.rs"),
PathBuf::from("def456"),
PathBuf::from("100644"),
];
assert_eq!(
resolve_before_after(&paths).unwrap(),
Some((
PathBuf::from("/dev/null"),
PathBuf::from("/tmp/git-blob-BBBB/foo.rs")
))
);
}
fn args_with(mode: Mode, headless: bool) -> Args {
Args {
command: None,
paths: Vec::new(),
mode,
headless,
minimal: false,
full: false,
whole_updates: false,
paint_reindent_moves: false,
color: ColorChoice::Auto,
exit_code: false,
context: tui::headless::CONTEXT_LINES,
review: false,
tui_tick_rate: 4.0,
tui_frame_rate: 60.0,
}
}
#[test]
fn no_render_flag_leaves_the_saved_setting_alone() {
assert_eq!(render_option_flags(&args_with(Mode::Tui, false)), None);
let config = tempfile::NamedTempFile::new().expect("temp config");
unsafe { std::env::set_var(omnidiff::tui::theme::CONFIG_ENV, config.path()) };
omnidiff::tui::theme::save_render_options(omnidiff::diff::text::RenderOptions {
whole_pair_updates: true,
..omnidiff::diff::text::RenderOptions::FULL
});
let options = render_options(&args_with(Mode::Headless, false));
unsafe { std::env::remove_var(omnidiff::tui::theme::CONFIG_ENV) };
assert!(options.whole_pair_updates);
}
#[test]
fn contradictory_mode_flags_are_rejected() {
for argv in [
&["omnidiff", "--review", "a.rs", "b.rs"][..],
&["omnidiff", "--review", "--mode", "json"],
&["omnidiff", "--headless", "--mode", "json"],
] {
assert!(Args::try_parse_from(argv).is_err(), "{argv:?} was accepted");
}
assert!(Args::try_parse_from(["omnidiff", "--headless", "a.rs", "b.rs"]).is_ok());
assert!(Args::try_parse_from(["omnidiff", "--review"]).is_ok());
}
#[test]
fn whole_updates_flag_layers_onto_minimal_and_full_alike() {
let mut minimal = args_with(Mode::Tui, false);
minimal.minimal = true;
minimal.whole_updates = true;
assert!(render_options(&minimal).whole_pair_updates);
assert!(!render_options(&minimal).leading_whitespace);
let mut full = args_with(Mode::Tui, false);
full.full = true;
full.whole_updates = true;
assert!(render_options(&full).whole_pair_updates);
assert!(render_options(&full).leading_whitespace);
}
#[test]
fn paint_reindent_moves_flag_layers_onto_minimal_and_full_alike() {
let mut minimal = args_with(Mode::Tui, false);
minimal.minimal = true;
minimal.paint_reindent_moves = true;
assert!(render_options(&minimal).paint_reindent_only_moves);
assert!(!render_options(&minimal).leading_whitespace);
let mut full = args_with(Mode::Tui, false);
full.full = true;
full.paint_reindent_moves = true;
assert!(render_options(&full).paint_reindent_only_moves);
assert!(render_options(&full).leading_whitespace);
let mut without_the_flag = args_with(Mode::Tui, false);
without_the_flag.minimal = true;
assert!(
!render_options(&without_the_flag).paint_reindent_only_moves,
"omitting the flag must not touch this field"
);
}
#[test]
fn color_flag_parses_all_three_choices_and_defaults_to_auto() {
for (argv, expected) in [
(vec!["omnidiff"], ColorChoice::Auto),
(vec!["omnidiff", "--color", "always"], ColorChoice::Always),
(vec!["omnidiff", "--color", "never"], ColorChoice::Never),
(vec!["omnidiff", "--color", "auto"], ColorChoice::Auto),
] {
let args = Args::try_parse_from(argv.clone())
.unwrap_or_else(|e| panic!("{argv:?} should parse: {e}"));
assert_eq!(args.color, expected, "for {argv:?}");
}
}
#[test]
fn context_flag_defaults_to_the_headless_default_and_accepts_overrides() {
assert_eq!(
Args::try_parse_from(["omnidiff"]).unwrap().context,
tui::headless::CONTEXT_LINES
);
assert_eq!(
Args::try_parse_from(["omnidiff", "--context", "0"])
.unwrap()
.context,
0
);
}
#[test]
fn mode_accepts_its_three_values_case_insensitively_and_nothing_else() {
for (value, expected) in [
("tui", Mode::Tui),
("TUI", Mode::Tui),
("headless", Mode::Headless),
("Headless", Mode::Headless),
("json", Mode::Json),
("JSON", Mode::Json),
] {
let args = Args::try_parse_from(["omnidiff", "--mode", value]).unwrap();
assert_eq!(args.mode, expected, "--mode {value}");
}
assert!(Args::try_parse_from(["omnidiff", "--mode", "bogus"]).is_err());
assert!(Args::try_parse_from(["omnidiff", "--exact"]).is_err());
}
#[test]
fn tui_rates_reject_zero_negative_and_non_numbers() {
for value in ["0", "-1", "nan", "inf", "fast"] {
assert!(
Args::try_parse_from(["omnidiff", "--tui-tick-rate", value]).is_err(),
"--tui-tick-rate {value}"
);
assert!(
Args::try_parse_from(["omnidiff", "--tui-frame-rate", value]).is_err(),
"--tui-frame-rate {value}"
);
}
let args = Args::try_parse_from(["omnidiff", "--tui-tick-rate", "2.5"]).unwrap();
assert_eq!(args.tui_tick_rate, 2.5);
}
#[test]
fn tui_rates_are_hidden_from_help() {
use clap::CommandFactory;
let help = Args::command().render_long_help().to_string();
assert!(!help.contains("--tui-tick-rate"), "{help}");
assert!(!help.contains("--tui-frame-rate"), "{help}");
assert!(help.contains("--mode"), "{help}");
}
#[test]
fn no_color_opts_out_only_when_set_and_non_empty() {
assert!(!no_color_is_set(None));
assert!(!no_color_is_set(Some("".into())));
assert!(no_color_is_set(Some("1".into())));
}
#[test]
fn the_no_files_message_names_the_missing_files_when_text_mode_was_asked_for() {
assert!(headless_needs_files_message(&args_with(Mode::Tui, true)).starts_with("text mode"));
assert!(
headless_needs_files_message(&args_with(Mode::Headless, false))
.starts_with("text mode")
);
assert!(
headless_needs_files_message(&args_with(Mode::Tui, false))
.starts_with("stdout is not a terminal")
);
}
#[test]
fn a_broken_pipe_anywhere_in_the_chain_is_recognized() {
let io = std::io::Error::from(std::io::ErrorKind::BrokenPipe);
assert!(is_broken_pipe(&anyhow::Error::from(io).context("writing")));
let other = std::io::Error::from(std::io::ErrorKind::NotFound);
assert!(!is_broken_pipe(&anyhow::Error::from(other)));
}
#[test]
fn always_and_never_beat_the_no_color_environment_variable() {
assert!(use_color(ColorChoice::Always));
assert!(!use_color(ColorChoice::Never));
}
#[test]
fn without_the_flag_a_differing_pair_still_exits_zero() {
assert_eq!(exit_code_for(true, false, false), 0);
assert_eq!(exit_code_for(false, false, false), 0);
}
#[test]
fn with_the_flag_exit_codes_follow_the_diff_convention() {
assert_eq!(exit_code_for(false, true, false), 0);
assert_eq!(exit_code_for(true, true, false), 1);
}
#[test]
fn the_git_external_diff_form_never_returns_one_even_with_the_flag() {
assert_eq!(exit_code_for(true, true, true), 0);
assert_eq!(exit_code_for(false, true, true), 0);
}
#[test]
fn a_binary_pair_under_the_git_external_diff_form_still_exits_zero() {
assert_eq!(exit_code_for(true, false, true), 0);
assert_eq!(exit_code_for(true, true, true), 0);
}
#[test]
fn the_binary_notice_names_gits_logical_path_not_its_temp_blobs() {
let paths = vec![
PathBuf::from("doc/paper.pdf"),
PathBuf::from("/tmp/git-blob-AAAA/paper.pdf"),
PathBuf::from("abc123"),
PathBuf::from("100644"),
PathBuf::from("/tmp/git-blob-BBBB/paper.pdf"),
PathBuf::from("def456"),
PathBuf::from("100644"),
];
let (before, after) = resolve_before_after(&paths).unwrap().unwrap();
assert_eq!(
binary_notice(&paths, &before, &after, true),
"Binary file doc/paper.pdf differs\n"
);
}
#[test]
fn the_binary_notice_names_both_sides_for_the_two_argument_form() {
let paths = vec![PathBuf::from("old.pdf"), PathBuf::from("new.pdf")];
let (before, after) = resolve_before_after(&paths).unwrap().unwrap();
assert_eq!(
binary_notice(&paths, &before, &after, true),
"Binary files old.pdf and new.pdf differ\n"
);
}
#[test]
fn the_binary_notice_does_not_claim_identical_files_differ() {
let paths = vec![PathBuf::from("a.pdf"), PathBuf::from("b.pdf")];
let (before, after) = resolve_before_after(&paths).unwrap().unwrap();
assert_eq!(
binary_notice(&paths, &before, &after, false),
"Binary files a.pdf and b.pdf are identical\n"
);
}
#[test]
fn exit_code_flag_defaults_to_off_and_parses() {
assert!(!Args::try_parse_from(["omnidiff"]).unwrap().exit_code);
assert!(
Args::try_parse_from(["omnidiff", "--exit-code"])
.unwrap()
.exit_code
);
}
#[test]
fn git_configure_parses_as_a_subcommand() {
let args = Args::try_parse_from(["omnidiff", "git", "configure"]).unwrap();
assert!(matches!(
args.command,
Some(Command::Git {
action: GitAction::Configure
})
));
assert!(args.paths.is_empty());
}
#[test]
fn two_paths_still_parse_as_paths_not_a_subcommand() {
let args = Args::try_parse_from(["omnidiff", "a.rs", "b.rs"]).unwrap();
assert!(args.command.is_none());
assert_eq!(
args.paths,
vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")]
);
}
#[test]
fn util_generates_completions_and_a_man_page() {
let args = Args::try_parse_from(["omnidiff", "util", "completions", "bash"]).unwrap();
assert!(matches!(
args.command,
Some(Command::Util {
action: UtilAction::Completions {
shell: clap_complete::Shell::Bash
}
})
));
let args = Args::try_parse_from(["omnidiff", "util", "man"]).unwrap();
assert!(matches!(
args.command,
Some(Command::Util {
action: UtilAction::Man
})
));
}
#[test]
fn the_help_text_describes_omnidiff_rather_than_a_doc_comment() {
use clap::CommandFactory;
let about = Args::command().get_about().unwrap().to_string();
assert!(
about.starts_with("Fast, robust, syntax-aware code diffing"),
"{about}"
);
assert_eq!(
Args::command().get_version(),
Some(env!("CARGO_PKG_VERSION"))
);
}
#[test]
fn no_args_still_opens_empty_viewer() {
let args = Args::try_parse_from(["omnidiff"]).unwrap();
assert!(args.command.is_none());
assert!(args.paths.is_empty());
}
#[test]
fn should_run_headless_when_stdout_is_not_a_terminal_even_without_any_flag() {
assert!(should_run_headless(&args_with(Mode::Tui, false), false));
}
#[test]
fn should_run_headless_is_false_on_a_real_terminal_with_no_flags() {
assert!(!should_run_headless(&args_with(Mode::Tui, false), true));
}
#[test]
fn should_run_headless_honors_the_headless_flag_even_on_a_real_terminal() {
assert!(should_run_headless(&args_with(Mode::Tui, true), true));
}
#[test]
fn batch_flag_is_a_clap_alias_for_headless() {
let args = Args::try_parse_from(["omnidiff", "--batch"]).expect("--batch should parse");
assert!(
args.headless,
"--batch should set the same field as --headless"
);
}
#[test]
fn should_run_headless_honors_mode_headless_case_insensitively() {
assert!(should_run_headless(&args_with(Mode::Headless, false), true));
}
#[test]
fn should_run_json_honors_mode_json_case_insensitively() {
assert!(should_run_json(&args_with(Mode::Json, false)));
assert!(should_run_json(&args_with(Mode::Json, false)));
assert!(!should_run_json(&args_with(Mode::Tui, false)));
assert!(!should_run_json(&args_with(Mode::Headless, false)));
}
#[test]
fn should_run_json_is_false_on_a_non_terminal_stdout_unless_explicitly_asked_for() {
assert!(!should_run_json(&args_with(Mode::Tui, false)));
}
#[test]
fn resolve_before_after_rejects_any_other_count() {
for n in [1, 3, 4, 5, 6, 8] {
let paths: Vec<PathBuf> = (0..n).map(|i| PathBuf::from(format!("p{i}"))).collect();
assert!(
resolve_before_after(&paths).is_err(),
"{n} positional arguments should be rejected"
);
}
}
}