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
188
src/cli/json.rs
188
src/cli/json.rs
|
|
@ -2,10 +2,16 @@
|
|||
//!
|
||||
//! Every response is an [`Envelope`] whose `status` is one of
|
||||
//! `"ok" | "dry_run" | "error"`, plus a machine-readable error `code`
|
||||
//! and a human `hint` on failure (see `docs/SKILL.md`).
|
||||
//! and a human `hint` on failure (see `docs/SKILL.md`). Payload structs
|
||||
//! are flattened into the envelope, so they never repeat `operation`.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::core::JmoveError;
|
||||
use crate::core::plan::MovePlan;
|
||||
|
||||
use super::output;
|
||||
|
||||
/// Top-level envelope for every `--json` response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Envelope<T: Serialize> {
|
||||
|
|
@ -18,6 +24,40 @@ pub struct Envelope<T: Serialize> {
|
|||
pub data: T,
|
||||
}
|
||||
|
||||
impl<T: Serialize> Envelope<T> {
|
||||
/// Success envelope: `status = "ok"`.
|
||||
#[must_use]
|
||||
pub fn ok(operation: &'static str, data: T) -> Self {
|
||||
Self {
|
||||
status: "ok",
|
||||
operation,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/// Dry-run preview envelope: `status = "dry_run"`.
|
||||
#[must_use]
|
||||
pub fn dry_run(operation: &'static str, data: T) -> Self {
|
||||
Self {
|
||||
status: "dry_run",
|
||||
operation,
|
||||
data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Envelope<ErrorData> {
|
||||
/// Failure envelope: `status = "error"` with a flattened [`ErrorData`].
|
||||
#[must_use]
|
||||
pub fn error(operation: &'static str, data: ErrorData) -> Self {
|
||||
Self {
|
||||
status: "error",
|
||||
operation,
|
||||
data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error payload: stable `code`, human `message`, actionable `hint`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorData {
|
||||
|
|
@ -25,12 +65,152 @@ pub struct ErrorData {
|
|||
pub code: String,
|
||||
/// Human-readable explanation.
|
||||
pub message: String,
|
||||
/// What the caller should do next (never null in output; omit if none).
|
||||
/// What the caller should do next (omitted from JSON when absent).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
impl ErrorData {
|
||||
/// Build an error payload from a stable code, message and optional hint.
|
||||
#[must_use]
|
||||
pub fn new(code: &str, message: String, hint: Option<String>) -> Self {
|
||||
Self {
|
||||
code: code.to_owned(),
|
||||
message,
|
||||
hint,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an engine error onto a stable code plus an actionable hint.
|
||||
#[must_use]
|
||||
pub fn from_core(err: &JmoveError) -> Self {
|
||||
let (code, hint) = match err {
|
||||
JmoveError::Io(_) => ("IO_ERROR", "check file permissions and disk space"),
|
||||
JmoveError::InvalidArgument(_) => (
|
||||
"INVALID_ARGUMENT",
|
||||
"paths must be inside the project root given by --root",
|
||||
),
|
||||
JmoveError::StaleIndex(_) => (
|
||||
"STALE_INDEX",
|
||||
"rerun the command; the index is rebuilt on every run",
|
||||
),
|
||||
JmoveError::PlanRejected(_) => (
|
||||
"PLAN_REJECTED",
|
||||
"run `jmove check --json` to inspect the import graph",
|
||||
),
|
||||
};
|
||||
Self::new(code, err.to_string(), Some(hint.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// One rewritten import inside a changed file.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Change {
|
||||
/// 1-based line of the rewritten specifier.
|
||||
pub line: usize,
|
||||
/// Specifier text before the move.
|
||||
pub old: String,
|
||||
/// Specifier text after the move.
|
||||
pub new: String,
|
||||
}
|
||||
|
||||
/// A file whose imports were rewritten, with line-level change details.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ChangedFile {
|
||||
/// Project-relative path of the importer.
|
||||
pub path: String,
|
||||
/// Rewritten specifiers, in source order.
|
||||
pub changes: Vec<Change>,
|
||||
}
|
||||
|
||||
/// Success payload of `mv --json` (flattened under `status: "ok"`).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MvData {
|
||||
/// Project-relative path the file moved from.
|
||||
pub source: String,
|
||||
/// Project-relative path the file moved to.
|
||||
pub target: String,
|
||||
/// Importer files touched by the move.
|
||||
pub changed_files: Vec<ChangedFile>,
|
||||
/// Number of files moved (always 1 in Phase 1).
|
||||
pub moved: usize,
|
||||
/// Total specifiers rewritten across all importers.
|
||||
pub updated_imports: usize,
|
||||
}
|
||||
|
||||
impl MvData {
|
||||
/// Assemble the payload from an applied plan and its change details.
|
||||
#[must_use]
|
||||
pub fn new(plan: &MovePlan, changed_files: Vec<ChangedFile>) -> Self {
|
||||
Self {
|
||||
source: plan.source.display().to_string(),
|
||||
target: plan.target.display().to_string(),
|
||||
changed_files,
|
||||
moved: 1,
|
||||
updated_imports: plan.rewrites.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dry-run payload of `mv --dry-run --json` (flattened under
|
||||
/// `status: "dry_run"`).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MvDryRunData {
|
||||
/// Project-relative path that would move.
|
||||
pub would_move: String,
|
||||
/// Project-relative destination that would be created.
|
||||
pub target: String,
|
||||
/// Specifiers that would be rewritten.
|
||||
pub would_update: usize,
|
||||
/// Importer files that would be touched, sorted.
|
||||
pub affected_files: Vec<String>,
|
||||
/// Unified diff (rewrites + rename) of the whole plan.
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
impl MvDryRunData {
|
||||
/// Assemble the preview payload from a plan and its rendered diff.
|
||||
#[must_use]
|
||||
pub fn new(plan: &MovePlan, diff: String) -> Self {
|
||||
Self {
|
||||
would_move: plan.source.display().to_string(),
|
||||
target: plan.target.display().to_string(),
|
||||
would_update: plan.rewrites.len(),
|
||||
affected_files: output::group_by_file(&plan.rewrites)
|
||||
.into_iter()
|
||||
.map(|(file, _)| file.display().to_string())
|
||||
.collect(),
|
||||
diff,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One unresolvable relative import found by `check`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BrokenImport {
|
||||
/// Project-relative file declaring the import.
|
||||
pub file: String,
|
||||
/// 1-based line of the specifier.
|
||||
pub line: usize,
|
||||
/// Specifier text as written.
|
||||
pub import: String,
|
||||
/// Stable reason code, currently always `"file_not_found"`.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
/// Success payload of `check --json` (flattened under the envelope).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CheckData {
|
||||
/// Broken imports, sorted by file then line.
|
||||
pub broken_imports: Vec<BrokenImport>,
|
||||
/// Number of broken imports (kept as an explicit counter for agents).
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
/// Serialize `value` as pretty JSON to stdout.
|
||||
pub fn print<T: Serialize>(value: &T) {
|
||||
let _ = value;
|
||||
todo!("cli agent: println!(serde_json::to_string_pretty)")
|
||||
match serde_json::to_string_pretty(value) {
|
||||
Ok(text) => println!("{text}"),
|
||||
Err(err) => eprintln!("jmove: failed to serialize JSON output: {err}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
198
src/cli/mod.rs
198
src/cli/mod.rs
|
|
@ -4,11 +4,20 @@
|
|||
//! `0` success · `1` operation error · `2` broken imports found.
|
||||
|
||||
pub mod json;
|
||||
pub mod output;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::convert::identity;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::core::apply;
|
||||
use crate::core::index::Index;
|
||||
use crate::core::plan::{self, MovePlan};
|
||||
use crate::core::{self, JmoveError, JmoveResult};
|
||||
|
||||
use json::{CheckData, Envelope, ErrorData, MvData, MvDryRunData};
|
||||
|
||||
/// jmove — move source files, keep every import intact.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "jmove", version, about, long_about = None)]
|
||||
|
|
@ -36,9 +45,6 @@ pub enum Command {
|
|||
/// Machine-readable JSON output (for AI agents).
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
/// Allow overwriting an existing target file.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Report broken imports in the project.
|
||||
Check {
|
||||
|
|
@ -58,39 +64,185 @@ pub mod exit {
|
|||
pub const BROKEN: i32 = 2;
|
||||
}
|
||||
|
||||
/// Handler flow: `Ok(value)` continues, `Err(code)` means the failure was
|
||||
/// already reported to the user and the process must exit with `code`.
|
||||
type Flow<T> = Result<T, i32>;
|
||||
|
||||
/// Parse arguments and run the selected command.
|
||||
/// Returns the process exit code; `Err` is reserved for unexpected failures.
|
||||
pub fn run() -> anyhow::Result<i32> {
|
||||
let args = Args::parse();
|
||||
match args.command {
|
||||
let outcome = match args.command {
|
||||
Command::Mv {
|
||||
source,
|
||||
target,
|
||||
dry_run,
|
||||
json,
|
||||
force,
|
||||
} => mv(&args.root, &source, &target, dry_run, json, force),
|
||||
} => mv(&args.root, &source, &target, dry_run, json),
|
||||
Command::Check { json } => check(&args.root, json),
|
||||
};
|
||||
// Handlers report their own failures; both arms carry an exit code.
|
||||
Ok(outcome.unwrap_or_else(identity))
|
||||
}
|
||||
|
||||
/// `mv` handler: normalize paths, validate, index, plan, then dry-run or apply.
|
||||
fn mv(root: &Path, source: &Path, target: &Path, dry_run: bool, json: bool) -> Flow<i32> {
|
||||
let root = flow(json, "mv", root.canonicalize().map_err(JmoveError::from))?;
|
||||
let source = flow(json, "mv", rel_from_root(&root, source))?;
|
||||
let target = flow(json, "mv", rel_from_root(&root, target))?;
|
||||
if let Some(rejected) = mv_reject(&root, &source, &target) {
|
||||
return Err(fail(json, "mv", rejected));
|
||||
}
|
||||
|
||||
let index = flow(json, "mv", Index::build(&root))?;
|
||||
let plan = flow(json, "mv", plan::plan_move(&index, &source, &target))?;
|
||||
if dry_run {
|
||||
return mv_dry_run(&root, json, &plan);
|
||||
}
|
||||
// Line numbers use spans against the *original* contents, so the JSON
|
||||
// payload is assembled before the rewrites hit the disk.
|
||||
let changed = if json {
|
||||
flow(json, "mv", output::changed_files(&root, &plan))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
flow(json, "mv", apply::apply(&root, &plan))?;
|
||||
|
||||
if json {
|
||||
json::print(&Envelope::ok("mv", MvData::new(&plan, changed)));
|
||||
} else {
|
||||
println!("{}", output::mv_summary(&plan));
|
||||
}
|
||||
Ok(exit::OK)
|
||||
}
|
||||
|
||||
/// `mv` handler: build index, plan, then dry-run-print or apply.
|
||||
fn mv(
|
||||
root: &Path,
|
||||
source: &Path,
|
||||
target: &Path,
|
||||
dry_run: bool,
|
||||
json: bool,
|
||||
force: bool,
|
||||
) -> anyhow::Result<i32> {
|
||||
let _ = (root, source, target, dry_run, json, force);
|
||||
todo!("cli agent: wire mv to core::index/plan/apply")
|
||||
/// Pre-flight `mv` validation. A file that exists on disk but is absent
|
||||
/// from the import index stays moveable: its plan simply has no rewrites.
|
||||
fn mv_reject(root: &Path, source: &Path, target: &Path) -> Option<ErrorData> {
|
||||
let bad = |code: &str, message: String, hint: &str| {
|
||||
Some(ErrorData::new(code, message, Some(hint.into())))
|
||||
};
|
||||
if source == target {
|
||||
let msg = "source and target are the same path".into();
|
||||
return bad("INVALID_ARGUMENT", msg, "pick a different destination");
|
||||
}
|
||||
if !root.join(source).is_file() {
|
||||
let msg = format!("source file '{}' does not exist", source.display());
|
||||
return bad(
|
||||
"SOURCE_NOT_FOUND",
|
||||
msg,
|
||||
"check the path or run `jmove check`",
|
||||
);
|
||||
}
|
||||
if root.join(target).exists() {
|
||||
let msg = format!("target path '{}' already exists", target.display());
|
||||
return bad(
|
||||
"TARGET_EXISTS",
|
||||
msg,
|
||||
"remove or rename the existing target first",
|
||||
);
|
||||
}
|
||||
// `target` names a file, so `parent()` always yields the directory part.
|
||||
let parent = root.join(target.parent().unwrap_or(Path::new("")));
|
||||
if parent.exists() && !parent.is_dir() {
|
||||
let msg = format!("target parent of '{}' is not a directory", target.display());
|
||||
return bad(
|
||||
"INVALID_ARGUMENT",
|
||||
msg,
|
||||
"pick a destination inside a directory",
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `check` handler: report imports that resolve to nothing.
|
||||
fn check(root: &Path, json: bool) -> anyhow::Result<i32> {
|
||||
let _ = (root, json);
|
||||
todo!("cli agent: wire check to core::index")
|
||||
/// Dry-run branch: unified diff for humans, structured preview for agents.
|
||||
fn mv_dry_run(root: &Path, json: bool, plan: &MovePlan) -> Flow<i32> {
|
||||
let diff = flow(json, "mv", apply::render_diff(root, plan))?;
|
||||
if json {
|
||||
json::print(&Envelope::dry_run("mv", MvDryRunData::new(plan, diff)));
|
||||
} else {
|
||||
print!("{diff}");
|
||||
}
|
||||
Ok(exit::OK)
|
||||
}
|
||||
|
||||
use std::path::Path;
|
||||
/// `check` handler: report relative imports that resolve to nothing.
|
||||
///
|
||||
/// Exit code is `2` when at least one broken import was found, in both the
|
||||
/// human and the `--json` mode (the JSON `status` stays `"ok"` — the
|
||||
/// command itself succeeded; agents read `total` or the exit code).
|
||||
fn check(root: &Path, json: bool) -> Flow<i32> {
|
||||
let root = flow(json, "check", root.canonicalize().map_err(JmoveError::from))?;
|
||||
let index = flow(json, "check", Index::build(&root))?;
|
||||
let broken = flow(json, "check", output::broken_imports(&root, &index))?;
|
||||
let code = if broken.is_empty() {
|
||||
exit::OK
|
||||
} else {
|
||||
exit::BROKEN
|
||||
};
|
||||
|
||||
if json {
|
||||
let total = broken.len();
|
||||
let data = CheckData {
|
||||
broken_imports: broken,
|
||||
total,
|
||||
};
|
||||
json::print(&Envelope::ok("check", data));
|
||||
} else {
|
||||
output::report_check(&broken);
|
||||
}
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Unwrap a core result, routing failures through the CLI error channel.
|
||||
fn flow<T>(json: bool, operation: &'static str, result: JmoveResult<T>) -> Flow<T> {
|
||||
result.map_err(|err| fail(json, operation, ErrorData::from_core(&err)))
|
||||
}
|
||||
|
||||
/// Report `err` as a JSON envelope or stderr lines; return the exit code.
|
||||
fn fail(json: bool, operation: &'static str, err: ErrorData) -> i32 {
|
||||
if json {
|
||||
json::print(&Envelope::error(operation, err));
|
||||
} else {
|
||||
output::print_error(&err.message, err.hint.as_deref());
|
||||
}
|
||||
exit::ERROR
|
||||
}
|
||||
|
||||
/// Convert a user path to a normalized project-relative path. Relative
|
||||
/// paths are taken against `root`; absolute ones must live underneath it.
|
||||
fn rel_from_root(root: &Path, path: &Path) -> JmoveResult<PathBuf> {
|
||||
let joined = if path.is_absolute() {
|
||||
path.into()
|
||||
} else {
|
||||
root.join(path)
|
||||
};
|
||||
let outside = || {
|
||||
JmoveError::InvalidArgument(format!(
|
||||
"path '{}' is outside the project root",
|
||||
path.display()
|
||||
))
|
||||
};
|
||||
let abs = collapse(&joined);
|
||||
let rel = abs.strip_prefix(root).map_err(|_| outside())?;
|
||||
core::normalize_rel_path(rel).ok_or_else(|| {
|
||||
JmoveError::InvalidArgument(format!("invalid project path '{}'", path.display()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Lexically normalize a path: drop `.` segments, apply `..` where possible.
|
||||
fn collapse(path: &Path) -> PathBuf {
|
||||
let mut stack: Vec<Component<'_>> = Vec::new();
|
||||
for comp in path.components() {
|
||||
match comp {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if stack.last() != Some(&Component::ParentDir) {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
other => stack.push(other),
|
||||
}
|
||||
}
|
||||
stack.into_iter().collect()
|
||||
}
|
||||
|
|
|
|||
145
src/cli/output.rs
Normal file
145
src/cli/output.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
//! Human-readable rendering plus the payload builders that both output
|
||||
//! modes share: grouping rewrites, resolving broken imports, line lookup.
|
||||
//!
|
||||
//! Pure functions returning data, except [`report_check`] and
|
||||
//! [`print_error`] which perform the only I/O (stdout and stderr).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::core::JmoveResult;
|
||||
use crate::core::index::Index;
|
||||
use crate::core::plan::{MovePlan, Rewrite};
|
||||
|
||||
use super::json::{BrokenImport, Change, ChangedFile};
|
||||
|
||||
/// `check` stdout line when the project has no broken imports.
|
||||
const CHECK_CLEAN: &str = "check: no broken imports found";
|
||||
|
||||
/// Read a project file (project-relative path) as UTF-8 text.
|
||||
fn read_file(root: &Path, rel: &Path) -> JmoveResult<String> {
|
||||
Ok(std::fs::read_to_string(root.join(rel))?)
|
||||
}
|
||||
|
||||
/// 1-based line containing the byte offset `byte` in `source`.
|
||||
#[must_use]
|
||||
pub fn line_of(source: &str, byte: usize) -> usize {
|
||||
let upto = source.len().min(byte);
|
||||
source.as_bytes()[..upto]
|
||||
.iter()
|
||||
.filter(|b| **b == b'\n')
|
||||
.count()
|
||||
+ 1
|
||||
}
|
||||
|
||||
/// Group rewrites by importer file; files in sorted order, rewrites of one
|
||||
/// file keep their source order. Shared by JSON payloads and summaries.
|
||||
#[must_use]
|
||||
pub fn group_by_file(rewrites: &[Rewrite]) -> Vec<(&Path, Vec<&Rewrite>)> {
|
||||
let mut map: BTreeMap<&Path, Vec<&Rewrite>> = BTreeMap::new();
|
||||
for rewrite in rewrites {
|
||||
map.entry(&rewrite.file).or_default().push(rewrite);
|
||||
}
|
||||
map.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Collect every relative import that resolves to nothing in `index`.
|
||||
///
|
||||
/// A specifier starting with `.` whose target is `None` is broken; a bare
|
||||
/// package specifier without a target is an external dependency, not an
|
||||
/// error. Results are sorted by file, then line.
|
||||
pub fn broken_imports(root: &Path, index: &Index) -> JmoveResult<Vec<BrokenImport>> {
|
||||
let mut broken: Vec<BrokenImport> = Vec::new();
|
||||
for (file, imports) in &index.imports {
|
||||
for import in imports {
|
||||
if import.target.is_some() || !import.record.specifier.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let text = read_file(root, file)?;
|
||||
broken.push(BrokenImport {
|
||||
file: file.display().to_string(),
|
||||
line: line_of(&text, import.record.span.start),
|
||||
import: import.record.specifier.clone(),
|
||||
reason: "file_not_found",
|
||||
});
|
||||
}
|
||||
}
|
||||
broken.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line)));
|
||||
Ok(broken)
|
||||
}
|
||||
|
||||
/// Build the `changed_files` payload: line-level specifier diffs grouped per
|
||||
/// importer, computed against the on-disk contents at call time.
|
||||
pub fn changed_files(root: &Path, plan: &MovePlan) -> JmoveResult<Vec<ChangedFile>> {
|
||||
let mut per_file: BTreeMap<&PathBuf, Vec<Change>> = BTreeMap::new();
|
||||
let mut contents: BTreeMap<&PathBuf, String> = BTreeMap::new();
|
||||
for rewrite in &plan.rewrites {
|
||||
if !contents.contains_key(&rewrite.file) {
|
||||
contents.insert(&rewrite.file, read_file(root, &rewrite.file)?);
|
||||
}
|
||||
// The key was just ensured, so this lookup cannot fail.
|
||||
let line = line_of(&contents[&rewrite.file], rewrite.span.start);
|
||||
let change = Change {
|
||||
line,
|
||||
old: rewrite.old_text.clone(),
|
||||
new: rewrite.new_text.clone(),
|
||||
};
|
||||
per_file.entry(&rewrite.file).or_default().push(change);
|
||||
}
|
||||
Ok(per_file
|
||||
.into_iter()
|
||||
.map(|(path, changes)| ChangedFile {
|
||||
path: path.display().to_string(),
|
||||
changes,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `moved src -> tgt, updated N imports in M files` success summary.
|
||||
#[must_use]
|
||||
pub fn mv_summary(plan: &MovePlan) -> String {
|
||||
let imports = plan.rewrites.len();
|
||||
let files = group_by_file(&plan.rewrites).len();
|
||||
format!(
|
||||
"moved {} -> {}, updated {} {} in {} {}",
|
||||
plan.source.display(),
|
||||
plan.target.display(),
|
||||
imports,
|
||||
plural(imports, "import"),
|
||||
files,
|
||||
plural(files, "file"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Print the human `check` report: the clean note, or one
|
||||
/// `path:line: cannot resolve 'spec'` line per broken import.
|
||||
pub fn report_check(broken: &[BrokenImport]) {
|
||||
if broken.is_empty() {
|
||||
println!("{CHECK_CLEAN}");
|
||||
return;
|
||||
}
|
||||
for entry in broken {
|
||||
let line = format!(
|
||||
"{}:{}: cannot resolve '{}'",
|
||||
entry.file, entry.line, entry.import
|
||||
);
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Print an operation error to stderr, with the hint on its own line.
|
||||
pub fn print_error(message: &str, hint: Option<&str>) {
|
||||
eprintln!("jmove: {message}");
|
||||
if let Some(hint) = hint {
|
||||
eprintln!(" hint: {hint}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `N noun` with a naive English plural.
|
||||
fn plural(count: usize, noun: &str) -> String {
|
||||
if count == 1 {
|
||||
noun.to_owned()
|
||||
} else {
|
||||
format!("{noun}s")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
//! Atomic apply with rollback, plus unified-diff rendering for dry-run.
|
||||
//!
|
||||
//! Order matters: specifier rewrites are applied to importer files first
|
||||
//! (each written atomically via temp-file + rename), and the actual
|
||||
//! `source -> target` rename happens last. Any failure mid-way triggers
|
||||
//! rollback of everything already written.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::core::JmoveResult;
|
||||
use crate::core::plan::MovePlan;
|
||||
|
||||
/// Summary of a successfully applied plan.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Applied {
|
||||
/// Number of files whose imports were rewritten.
|
||||
pub files_rewritten: usize,
|
||||
/// The moved file's new project-relative path.
|
||||
pub new_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
/// Apply `plan` under `root` atomically (see module docs). Rollback is
|
||||
/// best-effort: on restore failure the error message states which files
|
||||
/// need manual recovery.
|
||||
pub fn apply(root: &Path, plan: &MovePlan) -> JmoveResult<Applied> {
|
||||
let _ = (root, plan);
|
||||
todo!("index agent: atomic apply + rollback")
|
||||
}
|
||||
|
||||
/// Render the plan as a unified diff (rewrites + file rename) for dry-run.
|
||||
#[must_use]
|
||||
pub fn render_diff(root: &Path, plan: &MovePlan) -> JmoveResult<String> {
|
||||
let _ = (root, plan);
|
||||
todo!("index agent: diff rendering via `similar`")
|
||||
}
|
||||
80
src/core/apply/diff.rs
Normal file
80
src/core/apply/diff.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//! Unified-diff rendering of a plan, used by `mv --dry-run`.
|
||||
|
||||
use similar::TextDiff;
|
||||
|
||||
use crate::core::JmoveResult;
|
||||
use crate::core::apply::fsops::{group_by_file, rewrite_bytes};
|
||||
use crate::core::plan::MovePlan;
|
||||
use std::path::Path;
|
||||
|
||||
/// Render the plan as a unified diff per rewritten file plus a final
|
||||
/// `move <source> -> <target>` line, for dry-run. A plan without rewrites
|
||||
/// renders the empty string.
|
||||
pub fn render_diff(root: &Path, plan: &MovePlan) -> JmoveResult<String> {
|
||||
let mut out = String::new();
|
||||
for (file, rewrites) in group_by_file(plan) {
|
||||
let original = std::fs::read(root.join(&file))?;
|
||||
let patched = rewrite_bytes(&original, &rewrites)?;
|
||||
let name = file.display().to_string();
|
||||
let old = String::from_utf8_lossy(&original);
|
||||
let new = String::from_utf8_lossy(&patched);
|
||||
let text = TextDiff::from_lines(&old, &new);
|
||||
out.push_str(&text.unified_diff().header(&name, &name).to_string());
|
||||
}
|
||||
if !out.is_empty() {
|
||||
let (src, dst) = (plan.source.display(), plan.target.display());
|
||||
out.push_str(&format!("move {src} -> {dst}\n"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::render_diff;
|
||||
use crate::core::JmoveResult;
|
||||
use crate::core::plan::{MovePlan, Rewrite};
|
||||
use std::path::Path;
|
||||
|
||||
const OLD: &str = "import {\n fmt,\n} from '../lib/fmt';\n";
|
||||
|
||||
fn plan() -> MovePlan {
|
||||
MovePlan {
|
||||
source: "lib/fmt.ts".into(),
|
||||
target: "deep/fmt.ts".into(),
|
||||
rewrites: vec![Rewrite {
|
||||
file: "src/app.ts".into(),
|
||||
span: 24..34,
|
||||
old_text: "../lib/fmt".into(),
|
||||
new_text: "../deep/fmt".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_diff_smoke() -> JmoveResult<()> {
|
||||
let dir = tempfile::TempDir::new()?;
|
||||
std::fs::create_dir_all(dir.path().join("src"))?;
|
||||
std::fs::write(dir.path().join("src/app.ts"), OLD)?;
|
||||
let diff = render_diff(dir.path(), &plan())?;
|
||||
assert!(diff.contains("--- src/app.ts") && diff.contains("+++ src/app.ts"));
|
||||
assert!(diff.contains("@@"));
|
||||
assert!(diff.contains("-} from '../lib/fmt';") && diff.contains("+} from '../deep/fmt';"));
|
||||
assert!(diff.ends_with("move lib/fmt.ts -> deep/fmt.ts\n"));
|
||||
let mut empty = plan();
|
||||
empty.rewrites.clear();
|
||||
assert_eq!(render_diff(dir.path(), &empty)?, "");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_diff_reads_from_root() -> JmoveResult<()> {
|
||||
// Relative-root sanity: same content, root passed as `.` style path.
|
||||
let dir = tempfile::TempDir::new()?;
|
||||
std::fs::create_dir_all(dir.path().join("src"))?;
|
||||
std::fs::write(dir.path().join("src/app.ts"), OLD)?;
|
||||
let abs: &Path = dir.path();
|
||||
let diff = render_diff(abs, &plan())?;
|
||||
assert!(diff.contains("../deep/fmt"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
71
src/core/apply/fsops.rs
Normal file
71
src/core/apply/fsops.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
//! Low-level byte and filesystem helpers shared by apply and diff.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::core::plan::{MovePlan, Rewrite};
|
||||
use crate::core::{JmoveError, JmoveResult};
|
||||
|
||||
// Group rewrites by file; the BTreeMap keeps the order deterministic.
|
||||
pub(super) fn group_by_file(plan: &MovePlan) -> BTreeMap<PathBuf, Vec<&Rewrite>> {
|
||||
let mut m: BTreeMap<PathBuf, Vec<&Rewrite>> = BTreeMap::new();
|
||||
for r in &plan.rewrites {
|
||||
m.entry(r.file.clone()).or_default().push(r);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
// `<file>.jmove-tmp` next to `path` (same dir => same filesystem).
|
||||
pub(super) fn sibling_temp(path: &Path) -> PathBuf {
|
||||
let mut temp = path.as_os_str().to_os_string();
|
||||
temp.push(".jmove-tmp");
|
||||
PathBuf::from(temp)
|
||||
}
|
||||
|
||||
// Apply byte-span replacements in reverse offset order so earlier spans
|
||||
// stay valid; a span/content mismatch means the plan is stale. Valid
|
||||
// UTF-8 needles can only match on char boundaries, so a successful
|
||||
// rewrite of valid UTF-8 stays valid UTF-8.
|
||||
pub(super) fn rewrite_bytes(original: &[u8], rewrites: &[&Rewrite]) -> JmoveResult<Vec<u8>> {
|
||||
let mut out = original.to_vec();
|
||||
let mut sorted = rewrites.to_vec();
|
||||
sorted.sort_by_key(|r| std::cmp::Reverse(r.span.start));
|
||||
for r in sorted {
|
||||
if r.span.end > out.len() || &out[r.span.clone()] != r.old_text.as_bytes() {
|
||||
return Err(JmoveError::StaleIndex(format!(
|
||||
"'{}' changed since indexing (expected {:?} at {:?})",
|
||||
r.file.display(),
|
||||
r.old_text,
|
||||
r.span
|
||||
)));
|
||||
}
|
||||
out.splice(r.span.clone(), r.new_text.as_bytes().iter().copied());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// Write `bytes` to `temp` durably: create, write, flush, fsync.
|
||||
pub(super) fn write_durable(temp: &Path, bytes: &[u8]) -> JmoveResult<()> {
|
||||
let mut file = fs::File::create(temp)?;
|
||||
file.write_all(bytes)?;
|
||||
file.flush()?;
|
||||
Ok(file.sync_all()?)
|
||||
}
|
||||
|
||||
// Create the missing parent dirs of `dst`; returns the ones actually
|
||||
// created (innermost last) so rollback can remove them in reverse.
|
||||
pub(super) fn create_missing_dirs(dst: &Path) -> JmoveResult<Vec<PathBuf>> {
|
||||
let mut created = Vec::new();
|
||||
let mut current = dst.parent().unwrap_or(Path::new("")).to_path_buf();
|
||||
while !current.exists() {
|
||||
created.push(current.clone());
|
||||
let Some(up) = current.parent() else { break };
|
||||
current = up.to_path_buf();
|
||||
}
|
||||
for dir in created.iter().rev() {
|
||||
fs::create_dir(dir)?;
|
||||
}
|
||||
Ok(created)
|
||||
}
|
||||
201
src/core/apply/mod.rs
Normal file
201
src/core/apply/mod.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
//! Atomic apply with rollback, plus unified-diff rendering for dry-run.
|
||||
//!
|
||||
//! Rewrites land on importer files first (each atomically via temp-file +
|
||||
//! rename), the `source -> target` rename happens last, and any failure
|
||||
//! mid-way rolls back everything already written.
|
||||
|
||||
mod diff;
|
||||
mod fsops;
|
||||
|
||||
pub use diff::render_diff;
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::core::apply::fsops::{
|
||||
create_missing_dirs, group_by_file, rewrite_bytes, sibling_temp, write_durable,
|
||||
};
|
||||
use crate::core::plan::{MovePlan, Rewrite};
|
||||
use crate::core::{JmoveError, JmoveResult};
|
||||
|
||||
/// Summary of a successfully applied plan.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Applied {
|
||||
/// Number of files whose imports were rewritten.
|
||||
pub files_rewritten: usize,
|
||||
/// The moved file's new project-relative path.
|
||||
pub new_path: PathBuf,
|
||||
}
|
||||
|
||||
// Rollback state for one run: originals of rewritten files (newest last),
|
||||
// dirs created for the target, and the final rename once it happened.
|
||||
#[derive(Default)]
|
||||
struct Run {
|
||||
root: PathBuf,
|
||||
backups: Vec<(PathBuf, Vec<u8>)>,
|
||||
dirs: Vec<PathBuf>,
|
||||
moved: Option<(PathBuf, PathBuf)>,
|
||||
}
|
||||
|
||||
/// Apply `plan` under `root` atomically (see module docs). Rollback is
|
||||
/// best-effort: on restore failure the error names the files that need
|
||||
/// manual recovery.
|
||||
pub fn apply(root: &Path, plan: &MovePlan) -> JmoveResult<Applied> {
|
||||
let mut run = Run {
|
||||
root: root.to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
match run.try_apply(plan) {
|
||||
Ok(applied) => Ok(applied),
|
||||
Err(err) => Err(run.undo(err)),
|
||||
}
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn try_apply(&mut self, plan: &MovePlan) -> JmoveResult<Applied> {
|
||||
let by_file = group_by_file(plan);
|
||||
for (file, rewrites) in &by_file {
|
||||
self.rewrite_one(file, rewrites)?;
|
||||
}
|
||||
// The move comes last, after every importer was rewritten.
|
||||
let (src, dst) = (self.root.join(&plan.source), self.root.join(&plan.target));
|
||||
self.dirs = create_missing_dirs(&dst)?;
|
||||
fs::rename(&src, &dst)?;
|
||||
self.moved = Some((src, dst));
|
||||
Ok(Applied {
|
||||
files_rewritten: by_file.len(),
|
||||
new_path: plan.target.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
// Patch one file in memory, then temp-file + fsync + rename over it;
|
||||
// the original bytes go to `backups` for rollback.
|
||||
fn rewrite_one(&mut self, file: &Path, rewrites: &[&Rewrite]) -> JmoveResult<()> {
|
||||
let path = self.root.join(file);
|
||||
let original = fs::read(&path)?;
|
||||
let patched = rewrite_bytes(&original, rewrites)?;
|
||||
self.backups.push((file.to_path_buf(), original));
|
||||
let temp = sibling_temp(&path); // same dir => rename stays atomic
|
||||
write_durable(&temp, &patched)?;
|
||||
if let Err(err) = fs::rename(&temp, &path) {
|
||||
let _ = fs::remove_file(&temp); // no stray temp behind
|
||||
return Err(err.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Undo newest-first; keep the original error, appending any rollback
|
||||
// problems to its message.
|
||||
fn undo(&mut self, err: JmoveError) -> JmoveError {
|
||||
let mut problems = Vec::new();
|
||||
if let Some((src, dst)) = self.moved.take()
|
||||
&& let Err(e) = fs::rename(&dst, &src)
|
||||
{
|
||||
problems.push(format!("could not move back {}: {e}", dst.display()));
|
||||
}
|
||||
for (file, bytes) in self.backups.drain(..).rev() {
|
||||
if let Err(e) = fs::write(self.root.join(&file), &bytes) {
|
||||
problems.push(format!("could not restore {}: {e}", file.display()));
|
||||
}
|
||||
}
|
||||
for dir in self.dirs.drain(..).rev() {
|
||||
let _ = fs::remove_dir(&dir); // best-effort: only empty dirs
|
||||
}
|
||||
if problems.is_empty() {
|
||||
return err;
|
||||
}
|
||||
let msg = format!("{err}; rollback incomplete: {}", problems.join("; "));
|
||||
JmoveError::Io(std::io::Error::other(msg))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::apply;
|
||||
use crate::core::JmoveResult;
|
||||
use crate::core::plan::{MovePlan, Rewrite};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const OLD: &str = "import {\n fmt,\n} from '../lib/fmt';\n";
|
||||
const NEW: &str = "import {\n fmt,\n} from '../deep/fmt';\n";
|
||||
// Byte span of `../lib/fmt` (between the quotes) inside OLD.
|
||||
const SPAN: std::ops::Range<usize> = 24..34;
|
||||
|
||||
// Plan moving lib/fmt.ts -> deep/fmt.ts, rewriting src/app.ts.
|
||||
fn plan() -> MovePlan {
|
||||
let rewrite = Rewrite {
|
||||
file: "src/app.ts".into(),
|
||||
span: SPAN,
|
||||
old_text: "../lib/fmt".into(),
|
||||
new_text: "../deep/fmt".into(),
|
||||
};
|
||||
MovePlan {
|
||||
source: "lib/fmt.ts".into(),
|
||||
target: "deep/fmt.ts".into(),
|
||||
rewrites: vec![rewrite],
|
||||
}
|
||||
}
|
||||
|
||||
fn mk(dir: &Path, rel: &str, body: &str) -> JmoveResult<()> {
|
||||
let path = dir.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap())?;
|
||||
fs::write(path, body)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_rewrites_spans_creates_dirs_and_moves_last() -> JmoveResult<()> {
|
||||
assert_eq!(&OLD[SPAN], "../lib/fmt"); // sanity: the span is real
|
||||
let dir = tempfile::TempDir::new()?;
|
||||
let root = dir.path();
|
||||
mk(root, "src/app.ts", OLD)?;
|
||||
mk(root, "lib/fmt.ts", "export const fmt = 1;\n")?;
|
||||
let applied = apply(root, &plan())?;
|
||||
assert_eq!(
|
||||
(applied.files_rewritten, &applied.new_path),
|
||||
(1, &PathBuf::from("deep/fmt.ts"))
|
||||
);
|
||||
assert!(!root.join("lib/fmt.ts").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("deep/fmt.ts"))?,
|
||||
"export const fmt = 1;\n"
|
||||
);
|
||||
// Only the specifier bytes changed; the layout is kept byte-exact.
|
||||
assert_eq!(fs::read_to_string(root.join("src/app.ts"))?, NEW);
|
||||
assert!(!root.join("src/app.ts.jmove-tmp").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_rolls_back_when_the_move_fails() -> JmoveResult<()> {
|
||||
// Missing source: the last rename fails after the rewrite landed.
|
||||
let dir = tempfile::TempDir::new()?;
|
||||
mk(dir.path(), "src/app.ts", OLD)?;
|
||||
let err = apply(dir.path(), &plan()).expect_err("missing source");
|
||||
assert!(matches!(err, crate::core::JmoveError::Io(_)), "{err}");
|
||||
// Importer restored to its exact original bytes; created dirs gone.
|
||||
assert_eq!(fs::read_to_string(dir.path().join("src/app.ts"))?, OLD);
|
||||
assert!(!dir.path().join("deep").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_rejects_a_stale_plan_without_writing() -> JmoveResult<()> {
|
||||
// SPAN was computed on OLD's layout; a single-line importer has
|
||||
// different bytes there, so the run fails before any write.
|
||||
let other = "import { fmt } from '../lib/fmt';\n";
|
||||
let dir = tempfile::TempDir::new()?;
|
||||
let root = dir.path();
|
||||
mk(root, "src/app.ts", other)?;
|
||||
mk(root, "lib/fmt.ts", "export const fmt = 1;\n")?;
|
||||
let err = apply(root, &plan()).expect_err("span mismatch");
|
||||
assert!(
|
||||
matches!(err, crate::core::JmoveError::StaleIndex(_)),
|
||||
"{err}"
|
||||
);
|
||||
assert_eq!(fs::read_to_string(root.join("src/app.ts"))?, other);
|
||||
assert!(root.join("lib/fmt.ts").exists());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -5,11 +5,15 @@
|
|||
//! parsed through [`crate::parser`] and its specifiers resolved through
|
||||
//! [`crate::parser::resolve`].
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::core::JmoveResult;
|
||||
use crate::parser::ImportRecord;
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
use crate::core::{JmoveResult, normalize_rel_path};
|
||||
use crate::parser::resolve::resolve_module;
|
||||
use crate::parser::{ImportRecord, Language, SourceLanguage, frontend_for};
|
||||
|
||||
/// Indexed source files with O(1) membership lookups.
|
||||
#[derive(Debug, Default)]
|
||||
|
|
@ -62,18 +66,185 @@ pub struct Index {
|
|||
|
||||
impl Index {
|
||||
/// Scan `root`, parse every supported source file and build the graph.
|
||||
/// Unparseable files are skipped, not fatal.
|
||||
/// Unreadable or unparseable files are skipped, not fatal.
|
||||
pub fn build(root: &Path) -> JmoveResult<Self> {
|
||||
let _ = root;
|
||||
todo!(
|
||||
"index agent: scan with `ignore`, parse via crate::parser, resolve via parser::resolve"
|
||||
)
|
||||
let root = root.canonicalize()?;
|
||||
let mut index = Self {
|
||||
root,
|
||||
files: FileSet::default(),
|
||||
imports: HashMap::new(),
|
||||
};
|
||||
index.scan()?;
|
||||
// Resolution needs the complete file set (extension/index guessing),
|
||||
// so it runs as a second pass over the staged records.
|
||||
for (importer, imports) in &mut index.imports {
|
||||
for resolved in imports {
|
||||
resolved.target =
|
||||
resolve_module(importer, &resolved.record.specifier, &index.files);
|
||||
}
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
// Walk the project and parse each supported source file, staging the
|
||||
// raw records with `target: None` for the resolution pass above.
|
||||
fn scan(&mut self) -> JmoveResult<()> {
|
||||
// Sorted map: deterministic discovery order.
|
||||
let mut found: BTreeMap<PathBuf, SourceLanguage> = BTreeMap::new();
|
||||
for entry in WalkBuilder::new(&self.root).require_git(false).build() {
|
||||
// Walker errors (unreadable dirs, etc.) simply skip the entry.
|
||||
let Ok(entry) = entry else { continue };
|
||||
if entry.path_is_symlink() || !entry.file_type().is_some_and(|t| t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let stripped = entry
|
||||
.path()
|
||||
.strip_prefix(&self.root)
|
||||
.unwrap_or(Path::new(""));
|
||||
let Some(rel) = normalize_rel_path(stripped) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(lang) = SourceLanguage::for_path(&rel) {
|
||||
found.insert(rel, lang);
|
||||
}
|
||||
}
|
||||
// One frontend per language, created lazily as languages appear.
|
||||
let mut frontends: Vec<(SourceLanguage, Box<dyn Language>)> = Vec::new();
|
||||
for (rel, lang) in found {
|
||||
let Ok(text) = fs::read_to_string(self.root.join(&rel)) else {
|
||||
continue; // non-UTF-8 or vanished between scan and read
|
||||
};
|
||||
if !frontends.iter().any(|(l, _)| *l == lang) {
|
||||
frontends.push((lang, frontend_for(lang)));
|
||||
}
|
||||
let frontend = &mut frontends
|
||||
.iter_mut()
|
||||
.find(|(l, _)| *l == lang)
|
||||
.expect("frontend was just ensured")
|
||||
.1;
|
||||
let records = frontend.extract_imports(&text);
|
||||
self.files.add(rel.clone());
|
||||
self.imports.insert(
|
||||
rel,
|
||||
records
|
||||
.into_iter()
|
||||
.map(|record| ResolvedImport {
|
||||
record,
|
||||
target: None,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reverse edge lookup: every indexed file that imports `target`.
|
||||
#[must_use]
|
||||
pub fn importers_of(&self, target: &Path) -> Vec<PathBuf> {
|
||||
let _ = target;
|
||||
todo!("index agent: reverse-edge lookup")
|
||||
let mut importers: Vec<PathBuf> = self
|
||||
.imports
|
||||
.iter()
|
||||
.filter(|(_, imports)| imports.iter().any(|r| r.target.as_deref() == Some(target)))
|
||||
.map(|(file, _)| file.clone())
|
||||
.collect();
|
||||
importers.sort();
|
||||
importers
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Index, ResolvedImport};
|
||||
use crate::core::JmoveResult;
|
||||
use crate::parser::ImportRecord;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// Write `rel` (creating parent dirs) inside `root`.
|
||||
fn write_file(root: &Path, rel: &str, body: &str) -> JmoveResult<()> {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap())?;
|
||||
fs::write(path, body)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Stub edge with resolved target `target` (parser-independent).
|
||||
fn edge(target: &str) -> ResolvedImport {
|
||||
let record = ImportRecord {
|
||||
specifier: format!("./{target}"),
|
||||
span: 0..0,
|
||||
is_dynamic: false,
|
||||
};
|
||||
ResolvedImport {
|
||||
record,
|
||||
target: Some(PathBuf::from(target)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_skips_ignored_and_unsupported_files() -> JmoveResult<()> {
|
||||
let dir = tempfile::TempDir::new()?;
|
||||
let root = dir.path();
|
||||
fs::create_dir(root.join(".git"))?;
|
||||
write_file(root, ".gitignore", "ignored/\n")?;
|
||||
write_file(root, "src/a.ts", "import { b } from './b';\n")?;
|
||||
write_file(root, "src/b.ts", "export const b = 1;\n")?;
|
||||
write_file(root, "src/legacy.js", "const a = require('./a');\n")?;
|
||||
write_file(root, "ignored/c.ts", "export const c = 1;\n")?;
|
||||
write_file(root, "docs/note.md", "not source\n")?;
|
||||
fs::write(root.join("src/binary.ts"), [0xff_u8, 0xfe, 0x00, 0x01])?;
|
||||
|
||||
let index = Index::build(root)?;
|
||||
assert!(index.files.contains(Path::new("src/a.ts")));
|
||||
assert!(index.files.contains(Path::new("src/b.ts")));
|
||||
assert!(index.files.contains(Path::new("src/legacy.js")));
|
||||
// gitignored, non-source and non-UTF-8 files must never be indexed.
|
||||
for skipped in ["ignored/c.ts", "docs/note.md", "src/binary.ts"] {
|
||||
assert!(!index.files.contains(Path::new(skipped)), "{skipped}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_resolves_relative_specifier_to_indexed_file() -> JmoveResult<()> {
|
||||
let dir = tempfile::TempDir::new()?;
|
||||
let root = dir.path();
|
||||
write_file(root, "src/a.ts", "import { b } from './b';\n")?;
|
||||
write_file(root, "src/b.ts", "export const b = 1;\n")?;
|
||||
|
||||
let index = Index::build(root)?;
|
||||
let imports = index
|
||||
.imports
|
||||
.get(Path::new("src/a.ts"))
|
||||
.expect("a.ts must be indexed");
|
||||
assert_eq!(imports.len(), 1);
|
||||
assert_eq!(imports[0].record.specifier, "./b");
|
||||
assert_eq!(imports[0].target.as_deref(), Some(Path::new("src/b.ts")));
|
||||
assert_eq!(index.root, fs::canonicalize(root)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importers_of_returns_sorted_reverse_edges() -> JmoveResult<()> {
|
||||
let dir = tempfile::TempDir::new()?;
|
||||
let mut index = Index::build(dir.path())?;
|
||||
// Stub the graph so reverse-edge logic is independent of the parser.
|
||||
index.imports.clear();
|
||||
let edges = [
|
||||
("z.ts", "shared.ts"),
|
||||
("a.ts", "shared.ts"),
|
||||
("m.ts", "other.ts"),
|
||||
];
|
||||
for (file, target) in edges {
|
||||
index
|
||||
.imports
|
||||
.insert(PathBuf::from(file), vec![edge(target)]);
|
||||
}
|
||||
assert_eq!(
|
||||
index.importers_of(Path::new("shared.ts")),
|
||||
vec![PathBuf::from("a.ts"), PathBuf::from("z.ts")]
|
||||
);
|
||||
assert!(index.importers_of(Path::new("missing.ts")).is_empty());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ pub type JmoveResult<T> = Result<T, JmoveError>;
|
|||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use std::path::Path;
|
||||
/// use std::path::{Path, PathBuf};
|
||||
/// use jmove::core::normalize_rel_path;
|
||||
///
|
||||
/// assert_eq!(
|
||||
|
|
@ -59,9 +59,8 @@ pub fn normalize_rel_path(path: &Path) -> Option<PathBuf> {
|
|||
match comp {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if stack.pop().is_none() {
|
||||
return None; // would escape the project root
|
||||
}
|
||||
// `?` on the popped Option: escaping the root yields None.
|
||||
stack.pop()?;
|
||||
}
|
||||
Component::Normal(piece) => stack.push(piece),
|
||||
// Absolute paths and Windows prefixes are not project-relative.
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
//! Move planning: decide which import specifiers must be rewritten.
|
||||
//!
|
||||
//! A plan is pure data (no disk writes), so dry-run and `--json` can render
|
||||
//! it without touching the filesystem.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::core::JmoveResult;
|
||||
use crate::core::index::Index;
|
||||
|
||||
/// One in-file replacement of an import specifier. Only the specifier text
|
||||
/// between the quotes is touched — the statement layout is never reformatted.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Rewrite {
|
||||
/// Project-relative file to modify.
|
||||
pub file: PathBuf,
|
||||
/// Byte range of the old specifier text (without quotes).
|
||||
pub span: Range<usize>,
|
||||
/// Specifier as currently written.
|
||||
pub old_text: String,
|
||||
/// Specifier after the move.
|
||||
pub new_text: String,
|
||||
}
|
||||
|
||||
/// Complete plan for moving `source` to `target`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MovePlan {
|
||||
/// Project-relative path being moved.
|
||||
pub source: PathBuf,
|
||||
/// Project-relative destination path.
|
||||
pub target: PathBuf,
|
||||
/// Specifier rewrites, grouped per importer file.
|
||||
pub rewrites: Vec<Rewrite>,
|
||||
}
|
||||
|
||||
/// Compute the rewrite plan for `source -> target`.
|
||||
///
|
||||
/// Every indexed import whose resolved target is `source` gets a new
|
||||
/// relative specifier computed from the *importer's* directory to `target`.
|
||||
/// Rewrites whose result equals the old specifier are dropped.
|
||||
pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<MovePlan> {
|
||||
let _ = (index, source, target);
|
||||
todo!("index agent: implement planner incl. relative-specifier math")
|
||||
}
|
||||
187
src/core/plan/mod.rs
Normal file
187
src/core/plan/mod.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
//! Move planning: decide which import specifiers must be rewritten.
|
||||
//!
|
||||
//! 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`].
|
||||
|
||||
mod specifier;
|
||||
|
||||
pub use specifier::relative_specifier;
|
||||
|
||||
use std::ops::Range;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::core::index::Index;
|
||||
use crate::core::{JmoveError, JmoveResult, normalize_rel_path};
|
||||
|
||||
/// One in-file replacement of an import specifier. Only the specifier text
|
||||
/// between the quotes is touched — the statement layout is never reformatted.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Rewrite {
|
||||
/// Project-relative file to modify.
|
||||
pub file: PathBuf,
|
||||
/// Byte range of the old specifier text (without quotes).
|
||||
pub span: Range<usize>,
|
||||
/// Specifier as currently written.
|
||||
pub old_text: String,
|
||||
/// Specifier after the move.
|
||||
pub new_text: String,
|
||||
}
|
||||
|
||||
/// Complete plan for moving `source` to `target`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MovePlan {
|
||||
/// Project-relative path 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`.
|
||||
///
|
||||
/// Every indexed import whose resolved target equals `source` gets a new
|
||||
/// relative specifier from the importer's directory to `target` (see
|
||||
/// [`relative_specifier`]). 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),
|
||||
None => Err(JmoveError::InvalidArgument(format!(
|
||||
"invalid {label} '{}'",
|
||||
p.display()
|
||||
))),
|
||||
};
|
||||
let (source, target) = (rel("source path", source)?, rel("target path", target)?);
|
||||
if !index.files.contains(&source) {
|
||||
let s = source.display();
|
||||
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 = target.display();
|
||||
return Err(JmoveError::PlanRejected(format!(
|
||||
"target '{t}' already exists"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut rewrites = Vec::new();
|
||||
for importer in index.importers_of(&source) {
|
||||
let edges = index.imports[&importer]
|
||||
.iter()
|
||||
.filter(|e| e.target.as_deref() == Some(source.as_path()));
|
||||
for edge in edges {
|
||||
let new_text = relative_specifier(&importer, &target);
|
||||
if new_text == edge.record.specifier {
|
||||
continue; // no-op rewrite, never reaches the plan
|
||||
}
|
||||
let record = &edge.record;
|
||||
rewrites.push(Rewrite {
|
||||
file: importer.clone(),
|
||||
span: record.span.clone(),
|
||||
old_text: record.specifier.clone(),
|
||||
new_text,
|
||||
});
|
||||
}
|
||||
}
|
||||
rewrites.sort_by_key(|r| (r.file.clone(), r.span.start));
|
||||
Ok(MovePlan {
|
||||
source,
|
||||
target,
|
||||
rewrites,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Rewrite, plan_move};
|
||||
use crate::core::JmoveError;
|
||||
use crate::core::index::{Index, ResolvedImport};
|
||||
use crate::parser::ImportRecord;
|
||||
use std::ops::Range;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// Hand-wired edges: planner tests never touch the parser.
|
||||
fn edge(spec: &str, span: Range<usize>, target: &str) -> ResolvedImport {
|
||||
let record = ImportRecord {
|
||||
specifier: spec.into(),
|
||||
span,
|
||||
is_dynamic: false,
|
||||
};
|
||||
ResolvedImport {
|
||||
record,
|
||||
target: Some(target.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn index_with(files: &[&str], imports: &[(&str, Vec<ResolvedImport>)]) -> Index {
|
||||
let mut ix = Index::default();
|
||||
for f in files {
|
||||
ix.files.add(PathBuf::from(f));
|
||||
}
|
||||
ix.imports
|
||||
.extend(imports.iter().map(|(f, r)| (PathBuf::from(*f), r.clone())));
|
||||
ix
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_rewrites_sorted_by_file_then_span() {
|
||||
let z = vec![edge("./s", 30..33, "s.ts"), edge("./s", 5..8, "s.ts")];
|
||||
let a = vec![edge("./s", 0..3, "s.ts")];
|
||||
let index = index_with(&["s.ts", "z.ts", "a.ts"], &[("z.ts", z), ("a.ts", a)]);
|
||||
let plan = plan_move(&index, Path::new("s.ts"), Path::new("sub/deep/s.ts")).unwrap();
|
||||
let keys: Vec<(String, usize)> = plan
|
||||
.rewrites
|
||||
.iter()
|
||||
.map(|r| (r.file.display().to_string(), r.span.start))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
("a.ts".to_string(), 0usize),
|
||||
("z.ts".into(), 5),
|
||||
("z.ts".into(), 30)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
plan.rewrites[0],
|
||||
Rewrite {
|
||||
file: "a.ts".into(),
|
||||
span: 0..3,
|
||||
old_text: "./s".into(),
|
||||
new_text: "./sub/deep/s".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_drops_noop_rewrites() {
|
||||
// fmt.ts -> fmt.js keeps `./lib/fmt` valid: no specifier edits.
|
||||
let edges = vec![edge("./lib/fmt", 0..0, "lib/fmt.ts")];
|
||||
let index = index_with(&["lib/fmt.ts", "app.ts"], &[("app.ts", edges)]);
|
||||
let plan = plan_move(&index, Path::new("lib/fmt.ts"), Path::new("lib/fmt.js")).unwrap();
|
||||
assert!(plan.rewrites.is_empty());
|
||||
assert_eq!(plan.target, PathBuf::from("lib/fmt.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_rejects_bad_source_and_target() {
|
||||
let edges = vec![edge("./old", 7..12, "src/old.ts")];
|
||||
let index = index_with(&["src/old.ts", "src/app.ts"], &[("src/app.ts", edges)]);
|
||||
// A missing source is an invalid argument...
|
||||
let err = plan_move(&index, Path::new("ghost.ts"), Path::new("x.ts")).unwrap_err();
|
||||
assert!(matches!(err, JmoveError::InvalidArgument(_)));
|
||||
// ...an existing or identical target is a plan rejection.
|
||||
for target in ["src/app.ts", "src/old.ts"] {
|
||||
let err = plan_move(&index, Path::new("src/old.ts"), Path::new(target)).unwrap_err();
|
||||
assert!(matches!(err, JmoveError::PlanRejected(_)), "{target}");
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/core/plan/specifier.rs
Normal file
93
src/core/plan/specifier.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// Pure specifier arithmetic: TS module-stem conventions and directory
|
||||
// component math. Separated from planner logic to keep plan/ files small.
|
||||
|
||||
use std::path::{Component, Path};
|
||||
|
||||
// Module stem of a file name: `x.d.ts` -> `x`, `x.ts` -> `x`, unknown or
|
||||
// missing extension kept. `index` is never stripped (stays conservative).
|
||||
fn module_stem(name: &str) -> &str {
|
||||
if let Some(s) = name.strip_suffix(".d.ts") {
|
||||
return s;
|
||||
}
|
||||
match name.rsplit_once('.') {
|
||||
Some((s, "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs")) => s,
|
||||
_ => name,
|
||||
}
|
||||
}
|
||||
|
||||
// Normal directory components of a project-relative path.
|
||||
fn dir_parts(path: &Path) -> Vec<String> {
|
||||
let parent = path.parent().unwrap_or(Path::new(""));
|
||||
parent
|
||||
.components()
|
||||
.filter_map(|c| match c {
|
||||
Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute the TS/JS relative specifier from `importer`'s directory to
|
||||
/// `target`, prefixed with `./` or `../` as needed. Extension stripping
|
||||
/// follows TS module-resolution convention: `.ts`/`.tsx`/`.js`/`.jsx`/
|
||||
/// `.mjs`/`.cjs` are removed from file targets (an importer that wrote the
|
||||
/// extension may keep doing so — the specifier stays valid).
|
||||
///
|
||||
/// ```
|
||||
/// # use std::path::Path;
|
||||
/// # use jmove::core::plan::relative_specifier;
|
||||
/// assert_eq!(relative_specifier(Path::new("src/services/a.ts"), Path::new("src/utils/fmt.ts")), "../utils/fmt");
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn relative_specifier(importer: &Path, target: &Path) -> String {
|
||||
// Component math: consume the common directory prefix, one `..` per
|
||||
// leftover importer dir; the stem joins last, never as a directory.
|
||||
let name = target
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let (stem, dirs, mdirs) = (module_stem(&name), dir_parts(importer), dir_parts(target));
|
||||
let common = dirs.iter().zip(&mdirs).take_while(|(a, b)| a == b).count();
|
||||
let up = dirs.len() - common;
|
||||
let mut parts = vec!["..".to_string(); up];
|
||||
parts.extend_from_slice(&mdirs[common..]);
|
||||
parts.push(stem.to_string());
|
||||
let joined = parts.join("/");
|
||||
if up > 0 {
|
||||
joined // already starts with `../`
|
||||
} else {
|
||||
format!("./{joined}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::relative_specifier;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn relative_specifier_table() {
|
||||
let cases = [
|
||||
("src/a.ts", "src/b.ts", "./b"), // sibling
|
||||
("src/a.ts", "src/lib/b.ts", "./lib/b"), // child
|
||||
("src/lib/a.ts", "src/b.ts", "../b"), // parent
|
||||
("a/b/c/x.ts", "root/file.ts", "../../../root/file"), // `..` chain
|
||||
("main.ts", "src/util.ts", "./src/util"), // root importer
|
||||
("src/main.ts", "util.js", "../util"), // root target
|
||||
("src/a.ts", "src/foo/index.ts", "./foo/index"), // index kept
|
||||
("src/a/deep.ts", "src/foo/index.ts", "../foo/index"),
|
||||
("src/a.ts", "src/shim.d.ts", "./shim"), // .d.ts dropped
|
||||
("src/a.ts", "types/global.d.ts", "../types/global"),
|
||||
("src/a.ts", "src/b.mts", "./b.mts"), // unknown ext kept
|
||||
("src/a.ts", "data/config.json", "../data/config.json"),
|
||||
];
|
||||
for (i, t, want) in cases {
|
||||
assert_eq!(
|
||||
relative_specifier(Path::new(i), Path::new(t)),
|
||||
want,
|
||||
"{i} -> {t}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue