feat: MVP — move files, rewrite imports, atomically
- core: gitignore-aware index (ignore crate), forward/reverse import graph,
relative-specifier math, pure-data move plans
- apply: rewrite-first + rename-last with fsync, temp+rename, full rollback
(incl. stale-plan detection before any write); unified diff for dry-run
- cli: mv + check commands, human output, --json envelope with stable error
codes (SOURCE_NOT_FOUND, TARGET_EXISTS, ...) and hints; exit codes 0/1/2
- parser/core split into submodules to keep files <=250 lines
- tests: 37 unit + 10 e2e + 2 doctests; fixtures tests/typescript/{basic,normal,complex}
- docs: SKILL.md synced (no --force in Phase 1), EXAMPLES.md
- plan: AI/agent support done; Phase 1.5 = Java niche
This commit is contained in:
parent
9d8f1e0bba
commit
55670030c3
37 changed files with 1851 additions and 139 deletions
248
tests/cli.rs
Normal file
248
tests/cli.rs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
//! End-to-end tests for the `jmove` CLI against the TypeScript fixtures.
|
||||
//!
|
||||
//! Each test copies a fixture tree into a fresh tempdir, runs the real
|
||||
//! binary with `--root <tmp>` and asserts on exit codes, stdout/stderr and
|
||||
//! the resulting files on disk.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use assert_cmd::Command;
|
||||
use assert_cmd::assert::Assert;
|
||||
use predicates::prelude::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Recursively copy `tests/typescript/<name>` into a tempdir and return it.
|
||||
///
|
||||
/// An empty `.git` marker is created in the copy: the `ignore` crate only
|
||||
/// applies `.gitignore` rules inside a git repository by default, and the
|
||||
/// `normal` fixture relies on its `node_modules/` rule being effective.
|
||||
fn copy_fixture(name: &str) -> TempDir {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let from = fixture_dir(name);
|
||||
copy_dir(&from, tmp.path());
|
||||
fs::create_dir(tmp.path().join(".git")).expect("git marker");
|
||||
tmp
|
||||
}
|
||||
|
||||
/// Source path of a TypeScript fixture tree inside the repository.
|
||||
fn fixture_dir(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/typescript")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
/// Recursive file/dir copy; plain `std::fs` only, no symlinks in fixtures.
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
fs::create_dir_all(to).expect("create dir");
|
||||
for entry in fs::read_dir(from).expect("read dir") {
|
||||
let entry = entry.expect("entry");
|
||||
let target = to.join(entry.file_name());
|
||||
if entry.file_type().expect("file type").is_dir() {
|
||||
copy_dir(&entry.path(), &target);
|
||||
} else {
|
||||
fs::copy(entry.path(), target).expect("copy file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `jmove --root <dir> <args…>` and return an assertable outcome.
|
||||
fn jmove(root: &TempDir, args: &[&str]) -> Assert {
|
||||
let mut cmd = Command::cargo_bin("jmove").expect("jmove binary");
|
||||
cmd.arg("--root").arg(root.path()).args(args);
|
||||
cmd.assert()
|
||||
}
|
||||
|
||||
/// Read `path` as a string, panicking with the path on failure.
|
||||
fn read(path: &Path) -> String {
|
||||
fs::read_to_string(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()))
|
||||
}
|
||||
|
||||
/// Build a path inside the tempdir root from a `/`-separated relative path.
|
||||
fn in_root(root: &Path, rel: &str) -> PathBuf {
|
||||
root.join(rel)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv_dry_run_prints_diff_and_leaves_disk_untouched() {
|
||||
let tmp = copy_fixture("basic");
|
||||
jmove(&tmp, &["mv", "lib/sum.ts", "utils/sum.ts", "--dry-run"])
|
||||
.success()
|
||||
.stdout(predicate::str::contains("./lib/sum"))
|
||||
.stdout(predicate::str::contains("./utils/sum"));
|
||||
assert!(
|
||||
in_root(tmp.path(), "lib/sum.ts").is_file(),
|
||||
"source untouched"
|
||||
);
|
||||
assert!(
|
||||
!in_root(tmp.path(), "utils/sum.ts").exists(),
|
||||
"target not created"
|
||||
);
|
||||
assert_eq!(
|
||||
read(&in_root(tmp.path(), "app.ts")),
|
||||
read(&fixture_dir("basic").join("app.ts")),
|
||||
"importer untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv_rewrites_importer_and_moves_the_file() {
|
||||
let tmp = copy_fixture("basic");
|
||||
jmove(&tmp, &["mv", "lib/sum.ts", "utils/sum.ts"])
|
||||
.success()
|
||||
.stdout(predicate::str::contains("moved lib/sum.ts -> utils/sum.ts"))
|
||||
.stdout(predicate::str::contains("1 import"))
|
||||
.stdout(predicate::str::contains("1 file"));
|
||||
assert!(
|
||||
!in_root(tmp.path(), "lib/sum.ts").exists(),
|
||||
"source is gone"
|
||||
);
|
||||
assert!(
|
||||
in_root(tmp.path(), "utils/sum.ts").is_file(),
|
||||
"target exists"
|
||||
);
|
||||
assert!(
|
||||
read(&in_root(tmp.path(), "app.ts")).contains("./utils/sum"),
|
||||
"importer updated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv_updates_barrel_and_never_touches_node_modules() {
|
||||
let tmp = copy_fixture("normal");
|
||||
jmove(&tmp, &["mv", "src/impl/core.ts", "src/impl/calc.ts"]).success();
|
||||
assert!(
|
||||
read(&in_root(tmp.path(), "src/impl/index.ts")).contains("./calc"),
|
||||
"barrel re-export updated"
|
||||
);
|
||||
let app = read(&in_root(tmp.path(), "src/app.ts"));
|
||||
assert!(!app.contains("./impl/core"), "side-effect import updated");
|
||||
assert!(app.contains("./utils/logger"), "unrelated import preserved");
|
||||
let vendored = read(&in_root(tmp.path(), "node_modules/legacy-lib/index.ts"));
|
||||
assert!(
|
||||
vendored.contains("../../src/impl/core"),
|
||||
"ignored directories must never be rewritten"
|
||||
);
|
||||
jmove(&tmp, &["check"]).success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv_rewrites_only_the_specifier_line_in_multi_line_imports() {
|
||||
let tmp = copy_fixture("complex");
|
||||
let view = in_root(tmp.path(), "src/ui/deep/nested/view.ts");
|
||||
let before: Vec<String> = read(&view).lines().map(str::to_owned).collect();
|
||||
|
||||
jmove(&tmp, &["mv", "src/util/text.ts", "src/core/text.ts"]).success();
|
||||
|
||||
// Second importer at another depth: the messy `./util/../util/text`
|
||||
// specifier resolves to the moved file and must be rewritten as well.
|
||||
let config = read(&in_root(tmp.path(), "src/config.ts"));
|
||||
assert!(
|
||||
config.contains("./core/text") && !config.contains("../util"),
|
||||
"messy relative specifier updated: {config}"
|
||||
);
|
||||
let after: Vec<String> = read(&view).lines().map(str::to_owned).collect();
|
||||
assert_eq!(before.len(), after.len(), "import layout preserved");
|
||||
assert_eq!(before[0], after[0], "opening line is byte-identical");
|
||||
assert_eq!(before[1], after[1], "binding line is byte-identical");
|
||||
assert_eq!(before[2], "} from \"../../../util/text\";");
|
||||
assert_eq!(
|
||||
after[2], "} from \"../../../core/text\";",
|
||||
"only the specifier line changed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_passes_on_clean_and_fails_on_broken_fixture() {
|
||||
let clean = copy_fixture("basic");
|
||||
jmove(&clean, &["check"])
|
||||
.success()
|
||||
.stdout(predicate::str::contains("no broken imports"));
|
||||
|
||||
let messy = copy_fixture("complex");
|
||||
jmove(&messy, &["check"])
|
||||
.code(2)
|
||||
.stdout(predicate::str::contains(
|
||||
"src/broken.ts:4: cannot resolve './gone'",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_json_reports_broken_import_payload() {
|
||||
let tmp = copy_fixture("complex");
|
||||
jmove(&tmp, &["check", "--json"]).code(2).stdout(
|
||||
predicate::str::contains("\"status\": \"ok\"")
|
||||
.and(predicate::str::contains("\"operation\": \"check\""))
|
||||
.and(predicate::str::contains("\"file\": \"src/broken.ts\""))
|
||||
.and(predicate::str::contains("\"line\": 4"))
|
||||
.and(predicate::str::contains("\"import\": \"./gone\""))
|
||||
.and(predicate::str::contains("\"reason\": \"file_not_found\""))
|
||||
.and(predicate::str::contains("\"total\": 1")),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv_json_happy_path_reports_changed_files() {
|
||||
let tmp = copy_fixture("basic");
|
||||
jmove(&tmp, &["mv", "lib/sum.ts", "utils/sum.ts", "--json"])
|
||||
.success()
|
||||
.stdout(
|
||||
predicate::str::contains("\"status\": \"ok\"")
|
||||
.and(predicate::str::contains("\"operation\": \"mv\""))
|
||||
.and(predicate::str::contains("\"source\": \"lib/sum.ts\""))
|
||||
.and(predicate::str::contains("\"target\": \"utils/sum.ts\""))
|
||||
.and(predicate::str::contains("\"path\": \"app.ts\""))
|
||||
.and(predicate::str::contains("\"line\": 1"))
|
||||
.and(predicate::str::contains("\"old\": \"./lib/sum\""))
|
||||
.and(predicate::str::contains("\"new\": \"./utils/sum\""))
|
||||
.and(predicate::str::contains("\"moved\": 1"))
|
||||
.and(predicate::str::contains("\"updated_imports\": 1")),
|
||||
);
|
||||
assert!(in_root(tmp.path(), "utils/sum.ts").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv_json_dry_run_reports_preview_payload() {
|
||||
let tmp = copy_fixture("basic");
|
||||
jmove(
|
||||
&tmp,
|
||||
&["mv", "lib/sum.ts", "utils/sum.ts", "--dry-run", "--json"],
|
||||
)
|
||||
.success()
|
||||
.stdout(
|
||||
predicate::str::contains("\"status\": \"dry_run\"")
|
||||
.and(predicate::str::contains("\"would_move\": \"lib/sum.ts\""))
|
||||
.and(predicate::str::contains("\"would_update\": 1"))
|
||||
.and(predicate::str::contains("\"affected_files\""))
|
||||
.and(predicate::str::contains("\"app.ts\"")),
|
||||
);
|
||||
assert!(
|
||||
in_root(tmp.path(), "lib/sum.ts").is_file(),
|
||||
"dry-run writes nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv_json_reports_target_exists_error_shape() {
|
||||
let tmp = copy_fixture("basic");
|
||||
jmove(&tmp, &["mv", "app.ts", "lib/sum.ts", "--json"])
|
||||
.code(1)
|
||||
.stdout(
|
||||
predicate::str::contains("\"status\": \"error\"")
|
||||
.and(predicate::str::contains("\"operation\": \"mv\""))
|
||||
.and(predicate::str::contains("\"code\": \"TARGET_EXISTS\""))
|
||||
.and(predicate::str::contains("\"hint\"")),
|
||||
);
|
||||
assert!(
|
||||
read(&in_root(tmp.path(), "lib/sum.ts")).contains("export function sum"),
|
||||
"rejected move changes nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv_reports_source_not_found() {
|
||||
let tmp = copy_fixture("basic");
|
||||
jmove(&tmp, &["mv", "lib/nope.ts", "utils/nope.ts", "--json"])
|
||||
.code(1)
|
||||
.stdout(predicate::str::contains("\"code\": \"SOURCE_NOT_FOUND\""));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue