cli: add the merge and check commands
d3j merge O A B [-o out] [--lang X] exits 0 on a clean merge, 1 with
whole-file diff3 markers on conflicts (rule names and byte spans go to
stderr), and 2 on errors like unparsable input or an unknown
extension. d3j check O A B M judges an existing merge against the
universality conditions, one line per violation with a source excerpt.
miette's Result main would map every error to exit 1, so main renders
error reports by hand to keep the three-way exit contract.
Assisted-by: Claude Fable 5 via Claude Code
diff --git a/Cargo.lock b/Cargo.lock
index 0f92d42..97bff8f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -206,6 +206,15 @@ dependencies = [
"strsim",
]
+[[package]]
+name = "clap_complete"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b"
+dependencies = [
+ "clap",
+]
+
[[package]]
name = "clap_derive"
version = "4.6.1"
@@ -254,6 +263,7 @@ version = "0.1.0"
dependencies = [
"assert_cmd",
"clap",
+ "clap_complete",
"fs-err",
"hegeltest",
"miette",
diff --git a/Cargo.toml b/Cargo.toml
index 2cc5cf0..6e87ce1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@ path = "src/bin/d3j/main.rs"
[dependencies]
clap = { version = "*", features = ["derive", "env"] }
+clap_complete = "*"
fs-err = "*"
miette = { version = "*", features = ["fancy"] }
serde = { version = "*", features = ["derive"] }
diff --git a/src/bin/d3j/commands/check.rs b/src/bin/d3j/commands/check.rs
new file mode 100644
index 0000000..e7d8244
--- /dev/null
+++ b/src/bin/d3j/commands/check.rs
@@ -0,0 +1,73 @@
+use std::ops::Range;
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use clap::Args;
+
+use d3j::Tree;
+use d3j::check::{Branch, Violation, check};
+
+use super::{load, resolve_lang};
+
+#[derive(Args)]
+pub struct CheckArgs {
+ /// The common origin file.
+ origin: PathBuf,
+ /// One branch.
+ a: PathBuf,
+ /// The other branch.
+ b: PathBuf,
+ /// The merged file to judge.
+ merged: PathBuf,
+ /// Override language detection (rust, java, json).
+ #[arg(long)]
+ lang: Option<String>,
+}
+
+pub fn run(args: CheckArgs) -> miette::Result<ExitCode> {
+ let lang = resolve_lang(&args.origin, args.lang.as_deref())?;
+ let (_, o) = load(&args.origin, lang)?;
+ let (_, a) = load(&args.a, lang)?;
+ let (_, b) = load(&args.b, lang)?;
+ let (_, m) = load(&args.merged, lang)?;
+
+ let report = check(&o, &a, &b, &m);
+ for violation in &report.violations {
+ // One line per violation: condition, span, source excerpt.
+ let (tree, span) = witness(&o, &a, &b, &m, violation);
+ let excerpt = tree.source_slice(span.clone()).unwrap_or("");
+ println!("{violation}: {excerpt:?}");
+ }
+
+ if report.is_correct() {
+ Ok(ExitCode::SUCCESS)
+ } else {
+ Ok(ExitCode::from(1))
+ }
+}
+
+/// The tree a violation's witness span points into.
+fn witness<'t>(
+ o: &'t Tree,
+ a: &'t Tree,
+ b: &'t Tree,
+ m: &'t Tree,
+ violation: &Violation,
+) -> (&'t Tree, Range<usize>) {
+ match violation {
+ Violation::ExtraInsertion { span, .. } => (m, span.clone()),
+ Violation::MissedInsertion {
+ branch: Branch::A,
+ span,
+ ..
+ } => (a, span.clone()),
+ Violation::MissedInsertion {
+ branch: Branch::B,
+ span,
+ ..
+ } => (b, span.clone()),
+ Violation::ExtraDeletion { span, .. } | Violation::MissedDeletion { span, .. } => {
+ (o, span.clone())
+ }
+ }
+}
diff --git a/src/bin/d3j/commands/merge.rs b/src/bin/d3j/commands/merge.rs
new file mode 100644
index 0000000..2434e04
--- /dev/null
+++ b/src/bin/d3j/commands/merge.rs
@@ -0,0 +1,91 @@
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use clap::Args;
+use miette::IntoDiagnostic as _;
+
+use d3j::merge::{Conflict, MergeResult, merge_to_text};
+
+use super::{load, resolve_lang};
+
+#[derive(Args)]
+pub struct MergeArgs {
+ /// The common origin file.
+ origin: PathBuf,
+ /// One branch.
+ a: PathBuf,
+ /// The other branch.
+ b: PathBuf,
+ /// Write the result here instead of stdout.
+ #[arg(short, long)]
+ output: Option<PathBuf>,
+ /// Override language detection (rust, java, json).
+ #[arg(long)]
+ lang: Option<String>,
+}
+
+pub fn run(args: MergeArgs) -> miette::Result<ExitCode> {
+ let lang = resolve_lang(&args.origin, args.lang.as_deref())?;
+ let (o_source, o) = load(&args.origin, lang)?;
+ let (a_source, a) = load(&args.a, lang)?;
+ let (b_source, b) = load(&args.b, lang)?;
+
+ match merge_to_text(&o, &a, &b)? {
+ MergeResult::Merged(text) => {
+ emit(&args.output, &text)?;
+ Ok(ExitCode::SUCCESS)
+ }
+ MergeResult::Conflicts(conflicts) => {
+ report_conflicts(&conflicts);
+ // v1 renders conflicts as whole-file diff3 markers; the
+ // rules' byte spans go to stderr above.
+ let rendered = diff3(&o_source, &a_source, &b_source);
+ emit(&args.output, &rendered)?;
+ Ok(ExitCode::from(1))
+ }
+ }
+}
+
+fn emit(output: &Option<PathBuf>, text: &str) -> miette::Result<()> {
+ match output {
+ Some(path) => fs_err::write(path, text).into_diagnostic(),
+ None => {
+ print!("{text}");
+ Ok(())
+ }
+ }
+}
+
+fn report_conflicts(conflicts: &[Conflict]) {
+ for conflict in conflicts {
+ let spans: Vec<String> = [
+ ("O", &conflict.span_o),
+ ("A", &conflict.span_a),
+ ("B", &conflict.span_b),
+ ]
+ .iter()
+ .filter_map(|(name, span)| {
+ span.as_ref()
+ .map(|s| format!("{name} bytes {}..{}", s.start, s.end))
+ })
+ .collect();
+ eprintln!("conflict: {} ({})", conflict.rule, spans.join(", "));
+ }
+}
+
+/// Whole-file diff3-style markers.
+fn diff3(o: &str, a: &str, b: &str) -> String {
+ let terminated = |s: &str| {
+ if s.is_empty() || s.ends_with('\n') {
+ s.to_string()
+ } else {
+ format!("{s}\n")
+ }
+ };
+ format!(
+ "<<<<<<< A\n{}||||||| O\n{}=======\n{}>>>>>>> B\n",
+ terminated(a),
+ terminated(o),
+ terminated(b),
+ )
+}
diff --git a/src/bin/d3j/commands/mod.rs b/src/bin/d3j/commands/mod.rs
new file mode 100644
index 0000000..e463fbe
--- /dev/null
+++ b/src/bin/d3j/commands/mod.rs
@@ -0,0 +1,31 @@
+pub mod check;
+pub mod merge;
+
+use std::path::Path;
+
+use d3j::{Error, Lang, Tree};
+
+/// Resolves the language: an explicit `--lang` wins, otherwise the
+/// file extension decides.
+pub fn resolve_lang(path: &Path, lang: Option<&str>) -> Result<&'static Lang, Error> {
+ match lang {
+ Some(name) => Lang::by_name(name).ok_or(Error::UnknownLanguage { path: name.into() }),
+ None => Lang::detect(path).ok_or(Error::UnknownLanguage { path: path.into() }),
+ }
+}
+
+/// Reads and parses one input file, wiring the path into any error.
+pub fn load(path: &Path, lang: &'static Lang) -> Result<(String, Tree), Error> {
+ let source = fs_err::read_to_string(path).map_err(|source| Error::Io {
+ path: path.into(),
+ source,
+ })?;
+ let tree = Tree::parse(&source, lang).map_err(|error| match error {
+ Error::Parse { lang, .. } => Error::Parse {
+ path: path.into(),
+ lang,
+ },
+ other => other,
+ })?;
+ Ok((source, tree))
+}
diff --git a/src/bin/d3j/main.rs b/src/bin/d3j/main.rs
index 1e713bf..d4b9835 100644
--- a/src/bin/d3j/main.rs
+++ b/src/bin/d3j/main.rs
@@ -1,11 +1,65 @@
+mod commands;
+
+use std::process::ExitCode;
+
+use clap::{CommandFactory, Parser, Subcommand};
+use clap_complete::Shell;
+use miette::IntoDiagnostic as _;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
-fn main() -> miette::Result<()> {
+#[derive(Parser)]
+#[command(name = "d3j", about = "Structural three-way merge")]
+struct Cli {
+ /// Generate shell completions and exit.
+ #[arg(long, value_enum)]
+ completions: Option<Shell>,
+
+ #[command(subcommand)]
+ command: Option<Commands>,
+}
+
+#[derive(Subcommand)]
+enum Commands {
+ /// Merge two branches against their common origin.
+ Merge(commands::merge::MergeArgs),
+ /// Check a merged file against the universality conditions.
+ Check(commands::check::CheckArgs),
+}
+
+/// Exit codes: 0 merged/correct, 1 conflicts/violations, 2 error.
+/// miette's Result would map every error to 1, so errors are rendered
+/// by hand (Report's Debug is the fancy renderer).
+fn main() -> ExitCode {
miette::set_panic_hook();
tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_default_env())
.init();
- Ok(())
+ match run() {
+ Ok(code) => code,
+ Err(report) => {
+ eprintln!("{report:?}");
+ ExitCode::from(2)
+ }
+ }
+}
+
+fn run() -> miette::Result<ExitCode> {
+ let cli = Cli::parse();
+
+ if let Some(shell) = cli.completions {
+ clap_complete::generate(shell, &mut Cli::command(), "d3j", &mut std::io::stdout());
+ return Ok(ExitCode::SUCCESS);
+ }
+
+ let Some(command) = cli.command else {
+ Cli::command().print_help().into_diagnostic()?;
+ return Ok(ExitCode::SUCCESS);
+ };
+
+ match command {
+ Commands::Merge(args) => commands::merge::run(args),
+ Commands::Check(args) => commands::check::run(args),
+ }
}
diff --git a/tests/cli.rs b/tests/cli.rs
new file mode 100644
index 0000000..a15a292
--- /dev/null
+++ b/tests/cli.rs
@@ -0,0 +1,157 @@
+//! End-to-end tests against the compiled d3j binary.
+
+use std::path::PathBuf;
+
+use assert_cmd::Command;
+use predicates::prelude::*;
+
+fn cmd() -> Command {
+ Command::cargo_bin("d3j").expect("d3j binary builds")
+}
+
+/// Writes the given files into a fresh temp dir; returns their paths.
+fn fixtures(files: &[(&str, &str)]) -> (tempfile::TempDir, Vec<PathBuf>) {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let paths = files
+ .iter()
+ .map(|(name, content)| {
+ let path = dir.path().join(name);
+ fs_err::write(&path, content).expect("write fixture");
+ path
+ })
+ .collect();
+ (dir, paths)
+}
+
+#[test]
+fn clean_merge_exits_zero_with_expected_output() {
+ let (_dir, paths) = fixtures(&[
+ ("O.json", "[1, 2, 3]\n"),
+ ("A.json", "[1, 2, 4, 5, 3]\n"),
+ ("B.json", "[1, 6, 3]\n"),
+ ]);
+ cmd()
+ .arg("merge")
+ .args(&paths)
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("6").and(predicate::str::contains("4")));
+}
+
+#[test]
+fn merge_writes_to_the_output_file() {
+ let (dir, paths) = fixtures(&[
+ ("O.json", "[1, 2]\n"),
+ ("A.json", "[1, 2, 3]\n"),
+ ("B.json", "[1, 2]\n"),
+ ]);
+ let out = dir.path().join("M.json");
+ cmd()
+ .arg("merge")
+ .args(&paths)
+ .arg("-o")
+ .arg(&out)
+ .assert()
+ .success();
+ let written = fs_err::read_to_string(&out).expect("output file written");
+ assert!(written.contains('3'), "{written:?}");
+}
+
+#[test]
+fn conflicting_merge_exits_one_with_markers() {
+ let (_dir, paths) = fixtures(&[
+ ("O.rs", "fn a() {}\n"),
+ ("A.rs", "fn b() {}\n"),
+ ("B.rs", "fn c() {}\n"),
+ ]);
+ cmd()
+ .arg("merge")
+ .args(&paths)
+ .assert()
+ .code(1)
+ .stdout(predicate::str::contains("<<<<<<<"))
+ .stderr(predicate::str::contains("relabel-relabel"));
+}
+
+#[test]
+fn unparsable_input_exits_two_with_the_parse_diagnostic() {
+ let (_dir, paths) = fixtures(&[
+ ("O.rs", "fn main( {\n"),
+ ("A.rs", "fn main() {}\n"),
+ ("B.rs", "fn main() {}\n"),
+ ]);
+ cmd()
+ .arg("merge")
+ .args(&paths)
+ .assert()
+ .code(2)
+ .stderr(predicate::str::contains("d3j::parse"));
+}
+
+#[test]
+fn unknown_extension_exits_two() {
+ let (_dir, paths) = fixtures(&[("O.zig", "x\n"), ("A.zig", "x\n"), ("B.zig", "x\n")]);
+ cmd()
+ .arg("merge")
+ .args(&paths)
+ .assert()
+ .code(2)
+ .stderr(predicate::str::contains("d3j::unknown_language"));
+}
+
+#[test]
+fn lang_flag_overrides_detection() {
+ let (_dir, paths) = fixtures(&[
+ ("O.zig", "[1]\n"),
+ ("A.zig", "[1, 2]\n"),
+ ("B.zig", "[1]\n"),
+ ]);
+ cmd()
+ .arg("merge")
+ .args(&paths)
+ .args(["--lang", "json"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("2"));
+}
+
+#[test]
+fn check_flags_a_bad_merge() {
+ // Figure 10: A inserts 3, B deletes 1; M dropped the insertion.
+ let (_dir, paths) = fixtures(&[
+ ("O.json", "[1, 2]\n"),
+ ("A.json", "[1, 2, 3]\n"),
+ ("B.json", "[2]\n"),
+ ("M.json", "[2]\n"),
+ ]);
+ cmd()
+ .arg("check")
+ .args(&paths)
+ .assert()
+ .code(1)
+ .stdout(predicate::str::contains("missed insertion"));
+}
+
+#[test]
+fn check_passes_a_correct_merge() {
+ let (_dir, paths) = fixtures(&[
+ ("O.json", "[1, 2]\n"),
+ ("A.json", "[1, 2, 3]\n"),
+ ("B.json", "[2]\n"),
+ ("M.json", "[2, 3]\n"),
+ ]);
+ cmd().arg("check").args(&paths).assert().success();
+}
+
+#[test]
+fn no_arguments_prints_help() {
+ cmd()
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("merge"));
+}
+
+#[test]
+fn completions_generate() {
+ cmd().args(["--completions", "zsh"]).assert().success();
+}