- Edit engine: generic span edits (replace/insert/delete) with stale-index guard; mv and fix share one atomic apply/rollback/diff/--json pipeline - jmove mv|check|fix commands; java rules: unused-import, missing-import (unique-FQN insert, ambiguous -> candidates for agents), import-order (google style; cross-rule byte conflicts prune by severity and converge) - mv of tracked files goes through git mv (staged rename, --no-git opt-out, moved_via/would_move_via in --json, GIT_ERROR code) - fix java extract_package losing single-segment packages (bare identifier, not scoped_identifier) — broke the FQN index for 'package p;' projects - core::rel_str: project-relative paths rendered with '/' on every platform (human/JSON/diff headers/git pathspecs), Path::display() no longer used for output; e2e fixtures: java/fix, java/fix_missing, java/order
249 lines
8.5 KiB
Rust
249 lines
8.5 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 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`
|
|
// 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();
|
|
}
|
|
|
|
#[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();
|
|
}
|