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:
loki5512344 2026-09-10 11:41:03 +02:00
parent 9d8f1e0bba
commit 55670030c3
Signed by: boba
GPG key ID: 253067914055423B
37 changed files with 1851 additions and 139 deletions

View file

@ -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}"),
}
}

View file

@ -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
View 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")
}
}

View file

@ -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
View 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
View 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
View 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(())
}
}

View file

@ -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(())
}
}

View file

@ -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.

View file

@ -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
View 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}");
}
}
}

View 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}"
);
}
}
}