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
change kyuyxukytzqlzmsmssrsvznwppmmkttv
commit 34f81e6c1ef2c5ac5c5c0adaca7242291dbd5b43
author Alpha Chen <alpha@kejadlen.dev>
date
parent wvwsxsvz
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();
+}