tests: add the scenario corpus harness
Directory-per-scenario merge cases under tests/corpus/scenarios/: O,
A, B, plus expected.<ext> for a clean structural match or
expected.CONFLICT naming the rule that must fire. The harness
enumerates directories at runtime, so adding a scenario is adding
files. Seeded with the paper's Figures 2/3 and 10, one scenario per
conflict rule, the doc-comment preservation case, and the two dedupe
regressions.
Assisted-by: Claude Fable 5 via Claude Code
diff --git a/tests/corpus.rs b/tests/corpus.rs
new file mode 100644
index 0000000..8cdc2c5
--- /dev/null
+++ b/tests/corpus.rs
@@ -0,0 +1,90 @@
+//! The scenario corpus: every directory under tests/corpus/scenarios/
+//! is one merge case. Adding a scenario is adding files, no code.
+//!
+//! A scenario holds O.<ext>, A.<ext>, B.<ext>, and either
+//! expected.<ext> (must merge cleanly and match structurally) or
+//! expected.CONFLICT (must conflict; the file names the rule).
+
+use std::path::{Path, PathBuf};
+
+use d3j::diff::Shape;
+use d3j::merge::{MergeResult, merge_to_text};
+use d3j::{Lang, Tree};
+
+#[test]
+fn scenarios() {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/scenarios");
+ let mut ran = 0usize;
+ let entries = fs_err::read_dir(&root).expect("scenario corpus directory exists");
+ for entry in entries {
+ let dir = entry.expect("readable scenario entry").path();
+ if !dir.is_dir() {
+ continue;
+ }
+ run_scenario(&dir);
+ ran = ran.saturating_add(1);
+ }
+ assert!(ran > 0, "the scenario corpus must not be empty");
+}
+
+fn run_scenario(dir: &Path) {
+ let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("?");
+ let o_path = input(dir, "O").unwrap_or_else(|| panic!("{name}: missing O.<ext>"));
+ let lang = Lang::detect(&o_path).unwrap_or_else(|| panic!("{name}: unknown language"));
+ let parse = |stem: &str| -> Tree {
+ let path = input(dir, stem).unwrap_or_else(|| panic!("{name}: missing {stem}.<ext>"));
+ let source =
+ fs_err::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {stem}: {e}"));
+ Tree::parse(&source, lang).unwrap_or_else(|e| panic!("{name}: {stem} does not parse: {e}"))
+ };
+
+ let o = parse("O");
+ let a = parse("A");
+ let b = parse("B");
+ let outcome = merge_to_text(&o, &a, &b).unwrap_or_else(|e| panic!("{name}: merge failed: {e}"));
+
+ let conflict_marker = dir.join("expected.CONFLICT");
+ if conflict_marker.exists() {
+ let rule = fs_err::read_to_string(&conflict_marker)
+ .unwrap_or_else(|e| panic!("{name}: expected.CONFLICT: {e}"));
+ let rule = rule.trim();
+ match outcome {
+ MergeResult::Conflicts(conflicts) => {
+ assert!(
+ conflicts.iter().any(|c| c.rule == rule),
+ "{name}: expected a {rule} conflict, got {conflicts:?}"
+ );
+ }
+ MergeResult::Merged(text) => {
+ panic!("{name}: expected a {rule} conflict, merged to {text:?}");
+ }
+ }
+ } else {
+ let expected = parse("expected");
+ match outcome {
+ MergeResult::Merged(text) => {
+ let merged = Tree::parse(&text, lang)
+ .unwrap_or_else(|e| panic!("{name}: output does not parse: {e}\n{text}"));
+ assert_eq!(
+ Shape::of(&merged),
+ Shape::of(&expected),
+ "{name}: merged output {text:?} does not match expected"
+ );
+ }
+ MergeResult::Conflicts(conflicts) => {
+ panic!("{name}: expected a clean merge, got {conflicts:?}");
+ }
+ }
+ }
+}
+
+/// The scenario file with the given stem (any extension except the
+/// CONFLICT marker).
+fn input(dir: &Path, stem: &str) -> Option<PathBuf> {
+ fs_err::read_dir(dir).ok()?.find_map(|entry| {
+ let path = entry.ok()?.path();
+ let matches = path.file_stem().is_some_and(|s| s == stem)
+ && path.extension().is_some_and(|e| e != "CONFLICT");
+ matches.then_some(path)
+ })
+}
diff --git a/tests/corpus/scenarios/delete-delete/A.rs b/tests/corpus/scenarios/delete-delete/A.rs
new file mode 100644
index 0000000..e1a40b2
--- /dev/null
+++ b/tests/corpus/scenarios/delete-delete/A.rs
@@ -0,0 +1 @@
+fn main() { x = c; }
diff --git a/tests/corpus/scenarios/delete-delete/B.rs b/tests/corpus/scenarios/delete-delete/B.rs
new file mode 100644
index 0000000..45590d8
--- /dev/null
+++ b/tests/corpus/scenarios/delete-delete/B.rs
@@ -0,0 +1 @@
+fn main() { }
diff --git a/tests/corpus/scenarios/delete-delete/O.rs b/tests/corpus/scenarios/delete-delete/O.rs
new file mode 100644
index 0000000..58f6019
--- /dev/null
+++ b/tests/corpus/scenarios/delete-delete/O.rs
@@ -0,0 +1 @@
+fn main() { f(c); }
diff --git a/tests/corpus/scenarios/delete-delete/expected.CONFLICT b/tests/corpus/scenarios/delete-delete/expected.CONFLICT
new file mode 100644
index 0000000..3e1091e
--- /dev/null
+++ b/tests/corpus/scenarios/delete-delete/expected.CONFLICT
@@ -0,0 +1 @@
+delete-delete
diff --git a/tests/corpus/scenarios/doc-comments/A.rs b/tests/corpus/scenarios/doc-comments/A.rs
new file mode 100644
index 0000000..f858936
--- /dev/null
+++ b/tests/corpus/scenarios/doc-comments/A.rs
@@ -0,0 +1,6 @@
+/// From A.
+fn top() {}
+
+fn keep() {
+ x();
+}
diff --git a/tests/corpus/scenarios/doc-comments/B.rs b/tests/corpus/scenarios/doc-comments/B.rs
new file mode 100644
index 0000000..260ff23
--- /dev/null
+++ b/tests/corpus/scenarios/doc-comments/B.rs
@@ -0,0 +1,6 @@
+fn keep() {
+ x();
+}
+
+/// From B.
+fn bottom() {}
diff --git a/tests/corpus/scenarios/doc-comments/O.rs b/tests/corpus/scenarios/doc-comments/O.rs
new file mode 100644
index 0000000..d1ab122
--- /dev/null
+++ b/tests/corpus/scenarios/doc-comments/O.rs
@@ -0,0 +1,3 @@
+fn keep() {
+ x();
+}
diff --git a/tests/corpus/scenarios/doc-comments/expected.rs b/tests/corpus/scenarios/doc-comments/expected.rs
new file mode 100644
index 0000000..049d754
--- /dev/null
+++ b/tests/corpus/scenarios/doc-comments/expected.rs
@@ -0,0 +1,9 @@
+/// From A.
+fn top() {}
+
+fn keep() {
+ x();
+}
+
+/// From B.
+fn bottom() {}
diff --git a/tests/corpus/scenarios/figure-10/A.json b/tests/corpus/scenarios/figure-10/A.json
new file mode 100644
index 0000000..b5d8bb5
--- /dev/null
+++ b/tests/corpus/scenarios/figure-10/A.json
@@ -0,0 +1 @@
+[1, 2, 3]
diff --git a/tests/corpus/scenarios/figure-10/B.json b/tests/corpus/scenarios/figure-10/B.json
new file mode 100644
index 0000000..22fd875
--- /dev/null
+++ b/tests/corpus/scenarios/figure-10/B.json
@@ -0,0 +1 @@
+[2]
diff --git a/tests/corpus/scenarios/figure-10/O.json b/tests/corpus/scenarios/figure-10/O.json
new file mode 100644
index 0000000..44e2ace
--- /dev/null
+++ b/tests/corpus/scenarios/figure-10/O.json
@@ -0,0 +1 @@
+[1, 2]
diff --git a/tests/corpus/scenarios/figure-10/expected.json b/tests/corpus/scenarios/figure-10/expected.json
new file mode 100644
index 0000000..96d3840
--- /dev/null
+++ b/tests/corpus/scenarios/figure-10/expected.json
@@ -0,0 +1 @@
+[2, 3]
diff --git a/tests/corpus/scenarios/figure-2-3/A.json b/tests/corpus/scenarios/figure-2-3/A.json
new file mode 100644
index 0000000..de69a7c
--- /dev/null
+++ b/tests/corpus/scenarios/figure-2-3/A.json
@@ -0,0 +1 @@
+[1, 2, 4, 5, 3]
diff --git a/tests/corpus/scenarios/figure-2-3/B.json b/tests/corpus/scenarios/figure-2-3/B.json
new file mode 100644
index 0000000..fe72928
--- /dev/null
+++ b/tests/corpus/scenarios/figure-2-3/B.json
@@ -0,0 +1 @@
+[1, 6, 3]
diff --git a/tests/corpus/scenarios/figure-2-3/O.json b/tests/corpus/scenarios/figure-2-3/O.json
new file mode 100644
index 0000000..b5d8bb5
--- /dev/null
+++ b/tests/corpus/scenarios/figure-2-3/O.json
@@ -0,0 +1 @@
+[1, 2, 3]
diff --git a/tests/corpus/scenarios/figure-2-3/expected.json b/tests/corpus/scenarios/figure-2-3/expected.json
new file mode 100644
index 0000000..e48d1e6
--- /dev/null
+++ b/tests/corpus/scenarios/figure-2-3/expected.json
@@ -0,0 +1 @@
+[1, 6, 4, 5, 3]
diff --git a/tests/corpus/scenarios/insert-dedupe/A.json b/tests/corpus/scenarios/insert-dedupe/A.json
new file mode 100644
index 0000000..24b39ef
--- /dev/null
+++ b/tests/corpus/scenarios/insert-dedupe/A.json
@@ -0,0 +1 @@
+[1, 9, 2]
diff --git a/tests/corpus/scenarios/insert-dedupe/B.json b/tests/corpus/scenarios/insert-dedupe/B.json
new file mode 100644
index 0000000..24b39ef
--- /dev/null
+++ b/tests/corpus/scenarios/insert-dedupe/B.json
@@ -0,0 +1 @@
+[1, 9, 2]
diff --git a/tests/corpus/scenarios/insert-dedupe/O.json b/tests/corpus/scenarios/insert-dedupe/O.json
new file mode 100644
index 0000000..44e2ace
--- /dev/null
+++ b/tests/corpus/scenarios/insert-dedupe/O.json
@@ -0,0 +1 @@
+[1, 2]
diff --git a/tests/corpus/scenarios/insert-dedupe/expected.json b/tests/corpus/scenarios/insert-dedupe/expected.json
new file mode 100644
index 0000000..24b39ef
--- /dev/null
+++ b/tests/corpus/scenarios/insert-dedupe/expected.json
@@ -0,0 +1 @@
+[1, 9, 2]
diff --git a/tests/corpus/scenarios/insert-delete-java/A.java b/tests/corpus/scenarios/insert-delete-java/A.java
new file mode 100644
index 0000000..730eb31
--- /dev/null
+++ b/tests/corpus/scenarios/insert-delete-java/A.java
@@ -0,0 +1 @@
+class O { class I { void m() { } } }
diff --git a/tests/corpus/scenarios/insert-delete-java/B.java b/tests/corpus/scenarios/insert-delete-java/B.java
new file mode 100644
index 0000000..4a00c43
--- /dev/null
+++ b/tests/corpus/scenarios/insert-delete-java/B.java
@@ -0,0 +1 @@
+class O { }
diff --git a/tests/corpus/scenarios/insert-delete-java/O.java b/tests/corpus/scenarios/insert-delete-java/O.java
new file mode 100644
index 0000000..1f05141
--- /dev/null
+++ b/tests/corpus/scenarios/insert-delete-java/O.java
@@ -0,0 +1 @@
+class O { class I { } }
diff --git a/tests/corpus/scenarios/insert-delete-java/expected.CONFLICT b/tests/corpus/scenarios/insert-delete-java/expected.CONFLICT
new file mode 100644
index 0000000..dda4ec4
--- /dev/null
+++ b/tests/corpus/scenarios/insert-delete-java/expected.CONFLICT
@@ -0,0 +1 @@
+insert-delete
diff --git a/tests/corpus/scenarios/insert-delete/A.rs b/tests/corpus/scenarios/insert-delete/A.rs
new file mode 100644
index 0000000..d24a446
--- /dev/null
+++ b/tests/corpus/scenarios/insert-delete/A.rs
@@ -0,0 +1,2 @@
+fn a() { x(); z(); }
+fn b() { y(); }
diff --git a/tests/corpus/scenarios/insert-delete/B.rs b/tests/corpus/scenarios/insert-delete/B.rs
new file mode 100644
index 0000000..349bfdb
--- /dev/null
+++ b/tests/corpus/scenarios/insert-delete/B.rs
@@ -0,0 +1 @@
+fn b() { y(); }
diff --git a/tests/corpus/scenarios/insert-delete/O.rs b/tests/corpus/scenarios/insert-delete/O.rs
new file mode 100644
index 0000000..11450f8
--- /dev/null
+++ b/tests/corpus/scenarios/insert-delete/O.rs
@@ -0,0 +1,2 @@
+fn a() { x(); }
+fn b() { y(); }
diff --git a/tests/corpus/scenarios/insert-delete/expected.CONFLICT b/tests/corpus/scenarios/insert-delete/expected.CONFLICT
new file mode 100644
index 0000000..dda4ec4
--- /dev/null
+++ b/tests/corpus/scenarios/insert-delete/expected.CONFLICT
@@ -0,0 +1 @@
+insert-delete
diff --git a/tests/corpus/scenarios/insert-insert/A.rs b/tests/corpus/scenarios/insert-insert/A.rs
new file mode 100644
index 0000000..05f8ae5
--- /dev/null
+++ b/tests/corpus/scenarios/insert-insert/A.rs
@@ -0,0 +1 @@
+fn f() { x(); y(); }
diff --git a/tests/corpus/scenarios/insert-insert/B.rs b/tests/corpus/scenarios/insert-insert/B.rs
new file mode 100644
index 0000000..bc8d822
--- /dev/null
+++ b/tests/corpus/scenarios/insert-insert/B.rs
@@ -0,0 +1 @@
+fn f() { x(); z(); }
diff --git a/tests/corpus/scenarios/insert-insert/O.rs b/tests/corpus/scenarios/insert-insert/O.rs
new file mode 100644
index 0000000..e9f71c4
--- /dev/null
+++ b/tests/corpus/scenarios/insert-insert/O.rs
@@ -0,0 +1 @@
+fn f() { x(); }
diff --git a/tests/corpus/scenarios/insert-insert/expected.CONFLICT b/tests/corpus/scenarios/insert-insert/expected.CONFLICT
new file mode 100644
index 0000000..8ea65cd
--- /dev/null
+++ b/tests/corpus/scenarios/insert-insert/expected.CONFLICT
@@ -0,0 +1 @@
+insert-insert
diff --git a/tests/corpus/scenarios/relabel-relabel/A.rs b/tests/corpus/scenarios/relabel-relabel/A.rs
new file mode 100644
index 0000000..77c3e26
--- /dev/null
+++ b/tests/corpus/scenarios/relabel-relabel/A.rs
@@ -0,0 +1 @@
+fn b() {}
diff --git a/tests/corpus/scenarios/relabel-relabel/B.rs b/tests/corpus/scenarios/relabel-relabel/B.rs
new file mode 100644
index 0000000..afcf5ac
--- /dev/null
+++ b/tests/corpus/scenarios/relabel-relabel/B.rs
@@ -0,0 +1 @@
+fn c() {}
diff --git a/tests/corpus/scenarios/relabel-relabel/O.rs b/tests/corpus/scenarios/relabel-relabel/O.rs
new file mode 100644
index 0000000..ca05282
--- /dev/null
+++ b/tests/corpus/scenarios/relabel-relabel/O.rs
@@ -0,0 +1 @@
+fn a() {}
diff --git a/tests/corpus/scenarios/relabel-relabel/expected.CONFLICT b/tests/corpus/scenarios/relabel-relabel/expected.CONFLICT
new file mode 100644
index 0000000..24a6d10
--- /dev/null
+++ b/tests/corpus/scenarios/relabel-relabel/expected.CONFLICT
@@ -0,0 +1 @@
+relabel-relabel
diff --git a/tests/corpus/scenarios/rename-dedupe/A.rs b/tests/corpus/scenarios/rename-dedupe/A.rs
new file mode 100644
index 0000000..77c3e26
--- /dev/null
+++ b/tests/corpus/scenarios/rename-dedupe/A.rs
@@ -0,0 +1 @@
+fn b() {}
diff --git a/tests/corpus/scenarios/rename-dedupe/B.rs b/tests/corpus/scenarios/rename-dedupe/B.rs
new file mode 100644
index 0000000..77c3e26
--- /dev/null
+++ b/tests/corpus/scenarios/rename-dedupe/B.rs
@@ -0,0 +1 @@
+fn b() {}
diff --git a/tests/corpus/scenarios/rename-dedupe/O.rs b/tests/corpus/scenarios/rename-dedupe/O.rs
new file mode 100644
index 0000000..ca05282
--- /dev/null
+++ b/tests/corpus/scenarios/rename-dedupe/O.rs
@@ -0,0 +1 @@
+fn a() {}
diff --git a/tests/corpus/scenarios/rename-dedupe/expected.rs b/tests/corpus/scenarios/rename-dedupe/expected.rs
new file mode 100644
index 0000000..77c3e26
--- /dev/null
+++ b/tests/corpus/scenarios/rename-dedupe/expected.rs
@@ -0,0 +1 @@
+fn b() {}