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:
parent
b96deeaf2f
commit
6f7a2190e6
17 changed files with 842 additions and 293 deletions
226
src/core/plan/dir.rs
Normal file
226
src/core/plan/dir.rs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
//! Directory moves: relocate every indexed file under `source` into the
|
||||
//! mirrored layout under `target`, merging the per-file rewrite plans.
|
||||
//!
|
||||
//! A directory move is exactly N file moves that must apply atomically
|
||||
//! together, so it reuses the per-file planner and folds its rewrites;
|
||||
//! a file that imports two moved classes from the same directory simply
|
||||
//! gets both specifier edits. Files that exist on disk but are not
|
||||
//! indexable (assets, binaries) cannot be rewritten and would silently
|
||||
//! stay behind — they are reported as `left_behind` instead of being
|
||||
//! moved blindly.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{FileMove, MovePlan, Rewrite};
|
||||
use crate::core::index::Index;
|
||||
use crate::core::{JmoveError, JmoveResult, rel_str};
|
||||
|
||||
pub(super) fn plan_dir(index: &Index, source: &Path, target: &Path) -> JmoveResult<MovePlan> {
|
||||
let rejected = |msg: String| JmoveError::PlanRejected(format!("Directory move: {msg}"));
|
||||
if source.starts_with(target) || target.starts_with(source) {
|
||||
return Err(rejected(format!(
|
||||
"'{}' and '{}' are nested; a directory cannot move into or out of itself",
|
||||
rel_str(source),
|
||||
rel_str(target)
|
||||
)));
|
||||
}
|
||||
let members: Vec<PathBuf> = index
|
||||
.files
|
||||
.sorted()
|
||||
.into_iter()
|
||||
.filter(|f| f.starts_with(source))
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
let s = rel_str(source);
|
||||
return Err(JmoveError::InvalidArgument(format!(
|
||||
"source '{s}' has no indexed source files"
|
||||
)));
|
||||
}
|
||||
let moves: Vec<FileMove> = members
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let dst = target.join(f.strip_prefix(source).unwrap_or(f));
|
||||
FileMove {
|
||||
source: f.clone(),
|
||||
target: dst,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for m in &moves {
|
||||
if index.files.contains(&m.target) {
|
||||
let t = rel_str(&m.target);
|
||||
return Err(rejected(format!("target '{t}' already exists")));
|
||||
}
|
||||
}
|
||||
let mut rewrites: Vec<Rewrite> = moves
|
||||
.iter()
|
||||
.map(|m| super::file_rewrites(index, &m.source, &m.target))
|
||||
.collect::<JmoveResult<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
rewrites.sort_by_key(|r| (r.file.clone(), r.span.start));
|
||||
rewrites.dedup();
|
||||
let mut prune_dirs = prune_chain(source, &members);
|
||||
prune_dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
|
||||
Ok(MovePlan {
|
||||
source: source.to_path_buf(),
|
||||
target: target.to_path_buf(),
|
||||
moves,
|
||||
rewrites,
|
||||
left_behind: unsupported_left_behind(index, source),
|
||||
prune_dirs,
|
||||
})
|
||||
}
|
||||
|
||||
// Every directory that loses files: the moved members' parent chains up to
|
||||
// and including `source` itself.
|
||||
fn prune_chain(source: &Path, members: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for m in members {
|
||||
let mut dir = m.parent().unwrap_or(Path::new("")).to_path_buf();
|
||||
loop {
|
||||
if dir == source {
|
||||
if !out.contains(&source.to_path_buf()) {
|
||||
out.push(source.to_path_buf());
|
||||
}
|
||||
break;
|
||||
}
|
||||
if !out.contains(&dir) {
|
||||
out.push(dir.clone());
|
||||
}
|
||||
let Some(up) = dir.parent() else { break };
|
||||
dir = up.to_path_buf();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// Real files anywhere under `source` that the index does not know (and
|
||||
// therefore this plan will not move). Walked with the same gitignore rules
|
||||
// as the scanner; hidden paths are nobody's source files and skipped.
|
||||
fn unsupported_left_behind(index: &Index, source: &Path) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let walk = ignore::WalkBuilder::new(index.root.join(source))
|
||||
.require_git(false)
|
||||
.build();
|
||||
for entry in walk.flatten() {
|
||||
if entry.path_is_symlink() || !entry.file_type().is_some_and(|t| t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(stripped) = entry.path().strip_prefix(&index.root) else {
|
||||
continue;
|
||||
};
|
||||
let Some(rel) = crate::core::normalize_rel_path(stripped) else {
|
||||
continue;
|
||||
};
|
||||
let hidden = rel
|
||||
.components()
|
||||
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'));
|
||||
if !hidden && !index.files.contains(&rel) {
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Shared `#[cfg(test)]` graph builders for the plan submodules.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests_support {
|
||||
use std::ops::Range;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::core::index::ResolvedImport;
|
||||
use crate::parser::ImportRecord;
|
||||
|
||||
/// Hand-wired resolved edge: plan tests never touch the parser.
|
||||
pub(crate) fn edge(spec: &str, span: Range<usize>, target: &str) -> ResolvedImport {
|
||||
let record = ImportRecord {
|
||||
specifier: spec.into(),
|
||||
span,
|
||||
is_dynamic: false,
|
||||
};
|
||||
ResolvedImport {
|
||||
record,
|
||||
target: Some(PathBuf::from(target)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::core::JmoveResult;
|
||||
use crate::core::index::Index;
|
||||
use crate::core::plan::plan_move;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn project() -> JmoveResult<tempfile::TempDir> {
|
||||
let dir = tempfile::TempDir::new()?;
|
||||
let root = dir.path().to_path_buf();
|
||||
let write = |rel: &str, body: &str| -> JmoveResult<()> {
|
||||
let p = root.join(rel);
|
||||
fs::create_dir_all(p.parent().unwrap())?;
|
||||
fs::write(p, body)?;
|
||||
Ok(())
|
||||
};
|
||||
write("docs/notes.md", "not source\n")?;
|
||||
write("lib/a.ts", "export const a = 1;\n")?;
|
||||
write("lib/b.ts", "export const b = 2;\n")?;
|
||||
write("lib/data.json", "{}\n")?;
|
||||
write(
|
||||
"app.ts",
|
||||
"import { a } from './lib/a';\nimport { b } from './lib/b';\n",
|
||||
)?;
|
||||
write("deep/c.ts", "import { a } from '../lib/a';\n")?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_plan_moves_every_indexed_file_and_merges_rewrites() -> JmoveResult<()> {
|
||||
let dir = project()?;
|
||||
let index = Index::build(dir.path())?;
|
||||
let plan = plan_move(&index, Path::new("lib"), Path::new("core/lib"))?;
|
||||
assert_eq!(plan.prune_dirs, [PathBuf::from("lib")]);
|
||||
let moves: Vec<(&str, &str)> = plan
|
||||
.moves
|
||||
.iter()
|
||||
.map(|m| (m.source.to_str().unwrap(), m.target.to_str().unwrap()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
moves,
|
||||
[("lib/a.ts", "core/lib/a.ts"), ("lib/b.ts", "core/lib/b.ts"),]
|
||||
);
|
||||
// app.ts (root) imports both moved modules: two merged specifier
|
||||
// edits; deep/c.ts gets its own relative rewrite.
|
||||
let app: Vec<_> = plan
|
||||
.rewrites
|
||||
.iter()
|
||||
.filter(|r| r.file == Path::new("app.ts"))
|
||||
.map(|r| r.new_text.as_str())
|
||||
.collect();
|
||||
assert_eq!(app, ["./core/lib/a", "./core/lib/b"]);
|
||||
// The unindexed JSON would stay on disk: report, never silently move.
|
||||
assert_eq!(plan.left_behind, [PathBuf::from("lib/data.json")]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_plan_rejects_nested_and_empty_moves() -> JmoveResult<()> {
|
||||
let dir = project()?;
|
||||
let index = Index::build(dir.path())?;
|
||||
let err = plan_move(&index, Path::new("lib"), Path::new("lib/sub")).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, crate::core::JmoveError::PlanRejected(_)),
|
||||
"{err}"
|
||||
);
|
||||
// `docs` exists but holds no indexable file: nothing to move.
|
||||
let err = plan_move(&index, Path::new("docs"), Path::new("core")).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, crate::core::JmoveError::InvalidArgument(_)),
|
||||
"{err}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,14 @@
|
|||
//!
|
||||
//! A plan is pure data (no disk writes), so dry-run and `--json` can render
|
||||
//! it without touching the filesystem. Specifier arithmetic lives in
|
||||
//! [`specifier`]; the Java package/directory flavour in [`java`].
|
||||
//! [`specifier`]; the Java package/directory flavour in [`java`], and the
|
||||
//! mirrored batch move of a whole directory in [`dir`].
|
||||
|
||||
mod dir;
|
||||
mod java;
|
||||
mod specifier;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests_support;
|
||||
pub(crate) use dir::tests_support;
|
||||
mod specifier;
|
||||
|
||||
pub use specifier::relative_specifier;
|
||||
|
||||
|
|
@ -42,24 +44,44 @@ impl From<&Rewrite> for Edit {
|
|||
}
|
||||
}
|
||||
|
||||
/// Complete plan for moving `source` to `target`.
|
||||
/// One physical file relocation inside a plan.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MovePlan {
|
||||
/// Project-relative path being moved.
|
||||
pub struct FileMove {
|
||||
/// Project-relative file being moved.
|
||||
pub source: PathBuf,
|
||||
/// Project-relative destination path.
|
||||
pub target: PathBuf,
|
||||
/// Specifier rewrites, sorted by (file, span).
|
||||
pub rewrites: Vec<Rewrite>,
|
||||
}
|
||||
|
||||
/// Compute the rewrite plan for `source -> target`.
|
||||
/// Complete plan for moving `source` to `target`: a file move produces one
|
||||
/// [`FileMove`], a directory move one per indexed member (see [`dir`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MovePlan {
|
||||
/// Requested source: the file, or the directory whose members move.
|
||||
pub source: PathBuf,
|
||||
/// Requested destination.
|
||||
pub target: PathBuf,
|
||||
/// Every physical relocation, in sorted source order.
|
||||
pub moves: Vec<FileMove>,
|
||||
/// Specifier/package rewrites, merged across moves, sorted by (file, span).
|
||||
pub rewrites: Vec<Rewrite>,
|
||||
/// Directory moves only: real files under `source` that no plan step
|
||||
/// moves (unindexable assets) — reported, never silently relocated.
|
||||
pub left_behind: Vec<PathBuf>,
|
||||
/// Directory moves only: source directories to prune (deepest first)
|
||||
/// once every move landed. Removal only succeeds when a directory is
|
||||
/// empty, so `left_behind` files keep their home in place — exactly right.
|
||||
pub prune_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Compute the rewrite plan for `source -> target`; a `source` that is a
|
||||
/// directory on disk becomes a mirrored move of all indexed members below it.
|
||||
///
|
||||
/// TS/JS: every indexed import whose resolved target equals `source` gets a
|
||||
/// new relative specifier from the importer's directory to `target` (see
|
||||
/// [`relative_specifier`]). Java moves additionally rewrite the moved file's
|
||||
/// `package` declaration (see [`java`]). Rewrites whose result equals the
|
||||
/// old specifier are dropped; the result is sorted by (file, span).
|
||||
/// TS/JS: every indexed import whose resolved target equals a moved file gets
|
||||
/// a new relative specifier from the importer's directory to its destination
|
||||
/// (see [`relative_specifier`]). Java moves additionally rewrite the moved
|
||||
/// file's `package` declaration (see [`java`]). Rewrites whose result equals
|
||||
/// the old specifier are dropped; the result is sorted by (file, span).
|
||||
pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<MovePlan> {
|
||||
let rel = |label: &str, p: &Path| match normalize_rel_path(p) {
|
||||
Some(r) => Ok(r),
|
||||
|
|
@ -69,36 +91,54 @@ pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<Mov
|
|||
))),
|
||||
};
|
||||
let (source, target) = (rel("source path", source)?, rel("target path", target)?);
|
||||
if source == target {
|
||||
return Err(JmoveError::PlanRejected(
|
||||
"source and target are the same".into(),
|
||||
));
|
||||
}
|
||||
// A directory on disk turns the command into a mirrored batch move.
|
||||
if index.root.join(&source).is_dir() {
|
||||
return dir::plan_dir(index, &source, &target);
|
||||
}
|
||||
if !index.files.contains(&source) {
|
||||
let s = rel_str(&source);
|
||||
return Err(JmoveError::InvalidArgument(format!(
|
||||
"source '{s}' is not an indexed file"
|
||||
)));
|
||||
}
|
||||
if source == target {
|
||||
return Err(JmoveError::PlanRejected(
|
||||
"source and target are the same".into(),
|
||||
));
|
||||
}
|
||||
if index.files.contains(&target) {
|
||||
let t = rel_str(&target);
|
||||
return Err(JmoveError::PlanRejected(format!(
|
||||
"target '{t}' already exists"
|
||||
)));
|
||||
}
|
||||
|
||||
let rewrites = if SourceLanguage::for_path(&source) == Some(SourceLanguage::Java) {
|
||||
java::java_rewrites(index, &source, &target)?
|
||||
} else {
|
||||
ts_rewrites(index, &source, &target)
|
||||
};
|
||||
let rewrites = file_rewrites(index, &source, &target)?;
|
||||
Ok(MovePlan {
|
||||
source,
|
||||
target,
|
||||
source: source.clone(),
|
||||
target: target.clone(),
|
||||
moves: vec![FileMove { source, target }],
|
||||
rewrites,
|
||||
left_behind: Vec::new(),
|
||||
prune_dirs: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The per-file rewrite set: Java moves rewrite the `package` declaration
|
||||
/// too; shared by single-file and directory planning.
|
||||
pub(super) fn file_rewrites(
|
||||
index: &Index,
|
||||
source: &Path,
|
||||
target: &Path,
|
||||
) -> JmoveResult<Vec<Rewrite>> {
|
||||
Ok(
|
||||
if SourceLanguage::for_path(source) == Some(SourceLanguage::Java) {
|
||||
java::java_rewrites(index, source, target)?
|
||||
} else {
|
||||
ts_rewrites(index, source, target)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Relative-specifier rewrites for the TS/JS flavour of the graph.
|
||||
fn ts_rewrites(index: &Index, source: &Path, target: &Path) -> Vec<Rewrite> {
|
||||
let mut rewrites = Vec::new();
|
||||
|
|
@ -126,10 +166,10 @@ fn ts_rewrites(index: &Index, source: &Path, target: &Path) -> Vec<Rewrite> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tests_support::edge;
|
||||
use super::{Rewrite, plan_move};
|
||||
use crate::core::JmoveError;
|
||||
use crate::core::index::{Index, ResolvedImport};
|
||||
use crate::core::plan::tests_support::edge;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn index_with(files: &[&str], imports: &[(&str, Vec<ResolvedImport>)]) -> Index {
|
||||
|
|
@ -170,6 +210,7 @@ mod tests {
|
|||
new_text: "./sub/deep/s".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(plan.moves.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
//! Shared `#[cfg(test)]` graph builders for the plan submodules.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::core::index::ResolvedImport;
|
||||
use crate::parser::ImportRecord;
|
||||
|
||||
/// Hand-wired resolved edge: plan tests never touch the parser.
|
||||
pub(crate) fn edge(spec: &str, span: Range<usize>, target: &str) -> ResolvedImport {
|
||||
let record = ImportRecord {
|
||||
specifier: spec.into(),
|
||||
span,
|
||||
is_dynamic: false,
|
||||
};
|
||||
ResolvedImport {
|
||||
record,
|
||||
target: Some(PathBuf::from(target)),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue