jmove/tests/cli_fix.rs
loki5512344 6f7a2190e6
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
2026-09-15 18:30:33 +02:00

180 lines
6.1 KiB
Rust

//! End-to-end tests for `jmove fix` against a Java fixture.
mod common;
use common::{in_root, jmove, read};
use predicates::prelude::*;
fn dirty_project() -> tempfile::TempDir {
common::copy_fixture("java", "fix")
}
#[test]
fn fix_dry_run_previews_deletions_without_writing() {
let tmp = dirty_project();
jmove(&tmp, &["fix", "--dry-run"])
.success()
.stdout(predicate::str::contains(
"-import com.example.unused.Ghost;",
))
.stdout(predicate::str::contains(" import java.util.List;"));
// Disk untouched: the unused import is still there.
let app = read(&in_root(
tmp.path(),
"src/main/java/com/example/app/App.java",
));
assert!(app.contains("import com.example.unused.Ghost;"), "{app}");
}
#[test]
fn fix_removes_unused_and_keeps_used_and_string_mentions() {
let tmp = dirty_project();
jmove(&tmp, &["fix"])
.success()
.stdout(predicate::str::contains("fixed 1 issue in 1 file"));
let app = read(&in_root(
tmp.path(),
"src/main/java/com/example/app/App.java",
));
// Unused single-type import is gone, whole line removed.
assert!(!app.contains("com.example.unused.Ghost"), "{app}");
// Used import stays.
assert!(app.contains("import com.example.Text;"), "{app}");
// `List` only appears inside a string literal => kept (safe direction).
assert!(app.contains("import java.util.List;"), "{app}");
assert!(app.contains("\"java.util.List\""), "{app}");
// Run 2: convergence — the deferred `java/import-order` fix (it
// overlapped the deletion in run 1) now applies on the clean block.
jmove(&tmp, &["fix"])
.success()
.stdout(predicate::str::contains("fixed 1 issue in 1 file"));
let app = read(&in_root(
tmp.path(),
"src/main/java/com/example/app/App.java",
));
assert!(
app.contains(
"import static com.example.Text.shout;\n\nimport com.example.Text;\nimport java.util.List;\n"
),
"{app}"
);
// Run 3: fixed point — nothing left to change.
jmove(&tmp, &["fix"])
.success()
.stdout(predicate::str::contains("nothing to change"));
// The project still checks clean.
jmove(&tmp, &["check"]).success();
}
#[test]
fn fix_json_reports_candidates_and_applied_count() {
let tmp = dirty_project();
jmove(&tmp, &["fix", "--dry-run", "--json"])
.success()
.stdout(
predicate::str::contains("\"status\": \"dry_run\"")
.and(predicate::str::contains("\"operation\": \"fix\""))
.and(predicate::str::contains("\"rule\": \"java/unused-import\""))
.and(predicate::str::contains("\"would_fix\": 1")),
);
jmove(&tmp, &["fix", "--json"]).success().stdout(
predicate::str::contains("\"status\": \"ok\"")
.and(predicate::str::contains("\"fixes\": 1"))
.and(predicate::str::contains("\"files_changed\": 1")),
);
let app = read(&in_root(
tmp.path(),
"src/main/java/com/example/app/App.java",
));
assert!(!app.contains("Ghost"), "{app}");
}
#[test]
fn fix_unknown_rule_is_rejected() {
let tmp = dirty_project();
jmove(&tmp, &["fix", "--rule", "does/not-exist"])
.failure()
.code(1)
.stderr(predicate::str::contains(
"unknown fix rule 'does/not-exist'",
))
.stderr(predicate::str::contains("java/unused-import"));
// Nothing on disk changed.
let app = read(&in_root(
tmp.path(),
"src/main/java/com/example/app/App.java",
));
assert!(app.contains("Ghost"), "{app}");
}
#[test]
fn fix_scoped_to_rule_and_reports_guava_like_sibling() {
// `Text` is referenced only by a static member import of the same type,
// so neither import is provably dead (mirrors the Guava smoke note).
let tmp = dirty_project();
let app = read(&in_root(
tmp.path(),
"src/main/java/com/example/app/App.java",
));
assert!(
app.contains("import static com.example.Text.shout;"),
"{app}"
);
jmove(&tmp, &["fix", "--rule", "java/unused-import"])
.success()
.stdout(predicate::str::contains("fixed 1 issue in 1 file"));
let app = read(&in_root(
tmp.path(),
"src/main/java/com/example/app/App.java",
));
assert!(app.contains("import com.example.Text;"), "{app}");
assert!(
app.contains("import static com.example.Text.shout;"),
"{app}"
);
}
fn missing_project() -> tempfile::TempDir {
common::copy_fixture("java", "fix_missing")
}
#[test]
fn unused_delete_and_missing_insert_coexist_in_one_file() {
// Dual.java: the unused import is deleted while the missing `Maths`
// import lands at the very byte of the deleted line's end — adjacent,
// not overlapping, so one atomic plan carries both.
let tmp = missing_project();
let dual = "src/main/java/com/example/app/Dual.java";
jmove(&tmp, &["fix"]).success();
let text = read(&in_root(tmp.path(), dual));
assert!(!text.contains("Gone"), "{text}");
assert!(text.contains("import com.example.util.Maths;"), "{text}");
jmove(&tmp, &["check"]).success();
}
fn ts_unused() -> tempfile::TempDir {
common::copy_fixture("typescript", "unused")
}
#[test]
fn ts_unused_import_deletes_dead_type_import_and_keeps_the_rest() {
let tmp = ts_unused();
// The whole-statement delete must fire for the dead `Ghost` type import.
jmove(&tmp, &["fix", "--rule", "ts/unused-import", "--json"])
.success()
.stdout(
predicate::str::contains("\"rule\": \"ts/unused-import\"")
.and(predicate::str::contains("\"applied\": true")),
);
let app = read(&in_root(tmp.path(), "src/app.ts"));
assert!(!app.contains("Ghost"), "{app}");
// Side-effect import, the mixed used/unused statement and the default
// class import all stay (under-delete safety).
assert!(app.contains("import './side-effects';"), "{app}");
assert!(app.contains("unused"), "{app}");
assert!(app.contains("import Logger"), "{app}");
jmove(&tmp, &["check"]).success();
}