feat: directory moves — mirrored batch relocation of every indexed file under a dir

- MovePlan gains moves[] (file move = 1 relocation), left_behind[] for
  unindexable files that deliberately stay, prune_dirs[] applied after the
  batch (remove_dir only succeeds when empty, leftovers keep their home)
- plan::dir: nesting/target-exists guards, merged per-file rewrites
- apply: atomic N-rename batch (per-file git mv), rollback restores every
  move, rewrite and created dir newest-first
- mv_reject accepts dirs; --json adds moved_files/would_move_files only for
  real dir moves (single-file contract byte-identical); summary shows
  (N files) and left-behind count
- engine rollback/prune tests moved to tests/apply.rs (public API; a
  mid-batch failure cannot be forced through the CLI), java-rule e2e split
  into its own binary to keep every file under the 250-line budget
This commit is contained in:
loki5512344 2026-09-15 18:30:33 +02:00
parent b96deeaf2f
commit 6f7a2190e6
Signed by: boba
GPG key ID: 253067914055423B
17 changed files with 842 additions and 293 deletions

View file

@ -141,49 +141,6 @@ fn missing_project() -> tempfile::TempDir {
common::copy_fixture("java", "fix_missing")
}
#[test]
fn missing_import_adds_unique_and_reports_ambiguous() {
let tmp = missing_project();
jmove(&tmp, &["fix", "--rule", "java/missing-import", "--json"])
.success()
.stdout(
predicate::str::contains("\"rule\": \"java/missing-import\"")
.and(predicate::str::contains("\"applied\": false"))
.and(predicate::str::contains("\"com.example.a.Config\""))
.and(predicate::str::contains("\"com.example.b.Config\"")),
);
let calc = read(&in_root(
tmp.path(),
"src/main/java/com/example/app/Calc.java",
));
assert!(
calc.contains("package com.example.app;\nimport com.example.util.Maths;\n"),
"{calc}"
);
// The ambiguous `Config` is never guessed at.
let refer = read(&in_root(
tmp.path(),
"src/main/java/com/example/c/Refer.java",
));
assert!(!refer.contains("import com.example."), "{refer}");
jmove(&tmp, &["check"]).success();
}
#[test]
fn missing_import_dry_run_then_stable_reapply() {
let tmp = missing_project();
jmove(&tmp, &["fix", "--dry-run"])
.success()
.stdout(predicate::str::contains("+import com.example.util.Maths;"));
jmove(&tmp, &["fix"])
.success()
.stdout(predicate::str::contains("fixed 3 issues in 2 files"));
// Only manual (ambiguous) findings remain: nothing further applies.
jmove(&tmp, &["fix"])
.success()
.stdout(predicate::str::contains("nothing to change"));
}
#[test]
fn unused_delete_and_missing_insert_coexist_in_one_file() {
// Dual.java: the unused import is deleted while the missing `Maths`
@ -198,56 +155,6 @@ fn unused_delete_and_missing_insert_coexist_in_one_file() {
jmove(&tmp, &["check"]).success();
}
#[test]
fn three_rules_converge_on_one_project() {
// App.java: unused import inside an out-of-order block (the order
// rewrite overlaps the deletion, so it defers to run 2).
// C.java: out-of-order block + a bare `Maths` reference (insertion at
// the block boundary coexists with the rewrite in run 1).
let tmp = common::copy_fixture("java", "order");
jmove(&tmp, &["fix", "--dry-run", "--json"])
.success()
.stdout(
predicate::str::contains("\"rule\": \"java/import-order\"")
.and(predicate::str::contains("\"applied\": false"))
.and(predicate::str::contains("skipped")),
);
let app = "src/main/java/com/example/app/App.java";
let c = "src/main/java/com/example/app/C.java";
jmove(&tmp, &["fix"])
.success()
.stdout(predicate::str::contains("fixed 3 issues in 2 files"));
let app_text = read(&in_root(tmp.path(), app));
// Unused is gone; the deferred order fix has not touched the block.
assert!(!app_text.contains("Unneeded"), "{app_text}");
assert!(
app_text.contains("import java.util.List;\nimport com.example.util.Maths;"),
"{app_text}"
);
jmove(&tmp, &["fix"])
.success()
.stdout(predicate::str::contains("fixed 2 issues in 2 files"));
let c_text = read(&in_root(tmp.path(), c));
assert!(
c_text.contains(
"import com.example.util.Maths;\nimport java.util.List;\nimport java.util.Map;\n"
),
"{c_text}"
);
let app_text = read(&in_root(tmp.path(), app));
assert!(
app_text.contains(
"import com.example.util.Maths;\nimport java.util.List;\nimport java.util.Map;\n"
),
"{app_text}"
);
// Fixed point.
jmove(&tmp, &["fix"])
.success()
.stdout(predicate::str::contains("nothing to change"));
jmove(&tmp, &["check"]).success();
}
fn ts_unused() -> tempfile::TempDir {
common::copy_fixture("typescript", "unused")
}