feat: warn about non-import references to moved files

The import graph only rewrites import/export-from/require. Files are
also named from markdown links, package.json fields, jest.mock strings
and tsconfig file lists, and a move that dangles those must not pass
silently. mv now scans text formats plus source strings for the moved
paths, module names and old specifier forms (word-boundary matched,
lockfiles/hidden dirs skipped) and reports each occurrence once per
line on stderr and as non_import_refs[] in --json (dry-run included).
References are never edited; exit codes are unchanged.

Also moves normalize_scope into Index (its only consumer) and the
Change/ChangedFile JSON structs into output where they are built, to
stay inside the 250-line-per-file budget.
This commit is contained in:
loki5512344 2026-09-15 19:48:18 +02:00
parent b1af375763
commit 1ddf79b508
Signed by: boba
GPG key ID: 253067914055423B
21 changed files with 557 additions and 81 deletions

View file

@ -79,7 +79,7 @@ pub fn fix(
json: bool,
) -> Flow<i32> {
let root = flow(json, "fix", root.canonicalize().map_err(JmoveError::from))?;
let source_root = flow(json, "fix", super::normalize_scope(&root, source_root))?;
let source_root = flow(json, "fix", Index::normalize_scope(&root, source_root))?;
if let Some(rejected) = fix_reject(rule) {
return Err(fail(json, "fix", rejected));
}

View file

@ -8,9 +8,11 @@
use serde::Serialize;
use crate::core::plan::MovePlan;
use crate::core::refs::NonImportRef;
use crate::core::{JmoveError, rel_str};
use super::output;
use super::output::ChangedFile;
use super::output::{self};
/// Top-level envelope for every `--json` response.
#[derive(Debug, Serialize)]
@ -107,26 +109,6 @@ impl ErrorData {
}
}
/// 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 {
@ -142,6 +124,9 @@ pub struct MvData {
pub updated_imports: usize,
/// Rename backend: `"git"` (every rename staged in the index) or `"fs"`.
pub moved_via: &'static str,
/// Non-import textual references left unfixed (omitted when none).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub non_import_refs: Vec<NonImportRef>,
/// Directory moves only: each `(from, to)` relocation (omitted for file moves).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub moved_files: Vec<FileMoveData>,
@ -180,7 +165,12 @@ fn dir_moves(plan: &MovePlan) -> Vec<FileMoveData> {
impl MvData {
/// Assemble the payload from an applied plan and its change details.
#[must_use]
pub fn new(plan: &MovePlan, changed_files: Vec<ChangedFile>, via_git: bool) -> Self {
pub fn new(
plan: &MovePlan,
changed_files: Vec<ChangedFile>,
via_git: bool,
non_import_refs: Vec<NonImportRef>,
) -> Self {
Self {
source: rel_str(&plan.source),
target: rel_str(&plan.target),
@ -188,6 +178,7 @@ impl MvData {
moved: plan.moves.len(),
updated_imports: plan.rewrites.len(),
moved_via: if via_git { "git" } else { "fs" },
non_import_refs,
// A single-file move keeps the old contract: no extra fields.
moved_files: dir_moves(plan),
left_behind: plan.left_behind.iter().map(|p| rel_str(p)).collect(),
@ -211,6 +202,9 @@ pub struct MvDryRunData {
pub diff: String,
/// Rename backend a real run would use: `"git"` or `"fs"`.
pub would_move_via: &'static str,
/// Non-import references a real run would leave unfixed.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub non_import_refs: Vec<NonImportRef>,
/// Directory moves only: every relocation that would happen.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub would_move_files: Vec<FileMoveData>,
@ -219,7 +213,12 @@ pub struct MvDryRunData {
impl MvDryRunData {
/// Assemble the preview payload from a plan and its rendered diff.
#[must_use]
pub fn new(plan: &MovePlan, diff: String, via_git: bool) -> Self {
pub fn new(
plan: &MovePlan,
diff: String,
via_git: bool,
non_import_refs: Vec<NonImportRef>,
) -> Self {
Self {
would_move: rel_str(&plan.source),
target: rel_str(&plan.target),
@ -231,6 +230,7 @@ impl MvDryRunData {
diff,
would_move_via: if via_git { "git" } else { "fs" },
would_move_files: dir_moves(plan),
non_import_refs,
}
}
}

View file

@ -15,6 +15,7 @@ use clap::{Parser, Subcommand};
use crate::core::apply;
use crate::core::index::Index;
use crate::core::plan::{self, MovePlan};
use crate::core::refs;
use crate::core::{self, JmoveError, JmoveResult};
use json::{Envelope, ErrorData, MvData, MvDryRunData};
@ -106,7 +107,7 @@ pub fn run() -> anyhow::Result<i32> {
&target,
dry_run,
json,
no_git,
apply::GitMode::from_no_git(no_git),
),
Command::Check { json } => check(&args.root, args.source_root.as_deref(), json),
Command::Fix {
@ -133,11 +134,10 @@ fn mv(
target: &Path,
dry_run: bool,
json: bool,
no_git: bool,
git: apply::GitMode,
) -> Flow<i32> {
let git = apply::GitMode::from_no_git(no_git);
let root = flow(json, "mv", root.canonicalize().map_err(JmoveError::from))?;
let source_root = flow(json, "mv", normalize_scope(&root, source_root))?;
let source_root = flow(json, "mv", Index::normalize_scope(&root, source_root))?;
let source = flow(json, "mv", core::rel_from_root(&root, source))?;
let target = flow(json, "mv", core::rel_from_root(&root, target))?;
if let Some(rejected) = output::mv_reject(&root, &source, &target) {
@ -147,11 +147,11 @@ fn mv(
let scope = source_root.as_deref();
let index = flow(json, "mv", Index::build_scoped(&root, scope))?;
let plan = flow(json, "mv", plan::plan_move(&index, &source, &target))?;
let hidden = refs::scan(&root, &index, &plan);
if dry_run {
return mv_dry_run(&root, json, &plan, git);
return mv_dry_run(&root, json, &plan, git, hidden);
}
// Line numbers use spans against the *original* contents, so the JSON
// payload is assembled before the rewrites hit the disk.
// Line numbers use spans against the *original* contents.
let changed = if json {
flow(json, "mv", output::changed_files(&root, &plan))?
} else {
@ -160,27 +160,31 @@ fn mv(
let applied = flow(json, "mv", apply::apply(&root, &plan, git))?;
if json {
json::print(&Envelope::ok(
"mv",
MvData::new(&plan, changed, applied.via_git),
));
let data = MvData::new(&plan, changed, applied.via_git, hidden);
json::print(&Envelope::ok("mv", data));
} else {
println!("{}", output::mv_summary(&plan, applied.via_git));
output::report_refs(&hidden);
}
Ok(exit::OK)
}
/// Dry-run branch: unified diff for humans, structured preview for agents.
fn mv_dry_run(root: &Path, json: bool, plan: &MovePlan, git: apply::GitMode) -> Flow<i32> {
fn mv_dry_run(
root: &Path,
json: bool,
plan: &MovePlan,
git: apply::GitMode,
hidden: Vec<refs::NonImportRef>,
) -> Flow<i32> {
let diff = flow(json, "mv", apply::render_diff(root, plan))?;
let via_git = apply::would_use_git(root, &plan.source, git);
if json {
json::print(&Envelope::dry_run(
"mv",
MvDryRunData::new(plan, diff, via_git),
));
let data = MvDryRunData::new(plan, diff, via_git, hidden);
json::print(&Envelope::dry_run("mv", data));
} else {
print!("{diff}");
output::report_refs(&hidden);
}
Ok(exit::OK)
}
@ -192,7 +196,7 @@ fn mv_dry_run(root: &Path, json: bool, plan: &MovePlan, git: apply::GitMode) ->
/// command itself succeeded; agents read `total` or the exit code).
fn check(root: &Path, source_root: Option<&Path>, json: bool) -> Flow<i32> {
let root = flow(json, "check", root.canonicalize().map_err(JmoveError::from))?;
let source_root = flow(json, "check", normalize_scope(&root, source_root))?;
let source_root = flow(json, "check", Index::normalize_scope(&root, source_root))?;
let scope = source_root.as_deref();
let index = flow(json, "check", Index::build_scoped(&root, scope))?;
let broken = flow(json, "check", output::broken_imports(&root, &index))?;
@ -217,21 +221,6 @@ fn check(root: &Path, source_root: Option<&Path>, json: bool) -> Flow<i32> {
Ok(code)
}
/// Validate the global `--source-root`: project-relative, existing dir.
fn normalize_scope(root: &Path, scope: Option<&Path>) -> JmoveResult<Option<PathBuf>> {
let Some(scope) = scope else {
return Ok(None);
};
let rel = core::rel_from_root(root, scope)?;
if !root.join(&rel).is_dir() {
return Err(JmoveError::InvalidArgument(format!(
"--source-root '{}' is not a directory",
core::rel_str(&rel)
)));
}
Ok(Some(rel))
}
/// 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)))

View file

@ -13,7 +13,7 @@ use crate::core::{JmoveResult, rel_str};
mod check;
use super::json::{Change, ChangedFile, ErrorData};
use super::json::ErrorData;
pub use check::{
BrokenImport, CheckData, NameMismatch, broken_imports, name_mismatches, report_check,
@ -24,15 +24,27 @@ pub(crate) 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
/// Re-exported so `output::line_of` call sites stay stable.
pub use crate::core::line_of;
/// One rewritten import inside a changed file.
#[derive(Debug, serde::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, serde::Serialize)]
pub struct ChangedFile {
/// Project-relative path of the importer.
pub path: String,
/// Rewritten specifiers, in source order.
pub changes: Vec<Change>,
}
/// Group rewrites by importer file; files in sorted order, rewrites of one
@ -148,6 +160,32 @@ pub fn print_error(message: &str, hint: Option<&str>) {
}
}
/// Human warning block for non-import references (stderr, after the mv
/// result, so stdout stays clean for scripts). Silent when there are none.
pub fn report_refs(refs: &[crate::core::refs::NonImportRef]) {
if refs.is_empty() {
return;
}
eprintln!(
"warning: {} non-import reference {} may need manual fixing:",
refs.len(),
if refs.len() == 1 {
"to the moved file"
} else {
"s to moved files"
}
);
for r in refs.iter().take(10) {
eprintln!(" {}:{}", r.file, r.line);
}
if refs.len() > 10 {
eprintln!(
" ... and {} more (see --json non_import_refs)",
refs.len() - 10
);
}
}
/// `N noun` with a naive English plural.
pub(crate) fn plural(count: usize, noun: &str) -> String {
if count == 1 {

View file

@ -19,7 +19,7 @@ use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use crate::core::{JmoveResult, normalize_rel_path};
use crate::core::{JmoveError, JmoveResult, normalize_rel_path};
use crate::parser::java::JavaClassIndex;
use crate::parser::resolve::resolve_module;
use crate::parser::{ImportRecord, Language, PackageDecl, SourceLanguage, frontend_for};
@ -55,6 +55,25 @@ pub struct Index {
pub aliases: PathAliases,
}
/// Validate the global `--source-root`: project-relative, existing dir.
/// Kept next to [`Index::build_scoped`], its only consumer.
impl Index {
/// Resolve `--source-root` against the project root.
pub fn normalize_scope(root: &Path, scope: Option<&Path>) -> JmoveResult<Option<PathBuf>> {
let Some(scope) = scope else {
return Ok(None);
};
let rel = crate::core::rel_from_root(root, scope)?;
if !root.join(&rel).is_dir() {
return Err(JmoveError::InvalidArgument(format!(
"--source-root '{}' is not a directory",
crate::core::rel_str(&rel)
)));
}
Ok(Some(rel))
}
}
impl Index {
/// Scan `root`, parse every supported source file and build the graph.
/// Unreadable or unparseable files are skipped, not fatal.

View file

@ -10,6 +10,7 @@ pub mod apply;
pub mod fix;
pub mod index;
pub mod plan;
pub mod refs;
use std::ffi::OsStr;
use std::io;
@ -123,6 +124,17 @@ pub fn rel_str(path: &Path) -> String {
.join("/")
}
/// 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
}
/// Convert a user-supplied path to a normalized project-relative path.
/// Relative paths are taken against `root`; absolute ones must live
/// underneath it. Shared by CLI commands that accept paths.

205
src/core/refs/mod.rs Normal file
View file

@ -0,0 +1,205 @@
//! Non-import textual references to planned moves: report only.
//!
//! The import graph understands `import`/`export from`/`require` and
//! `import()` — but projects also reference files from markdown links,
//! `package.json` fields, `jest.mock("./x")` strings, tsconfig `files`
//! arrays and friends. Auto-rewriting those would require understanding
//! each format's semantics, so `jmove` never edits them; what it must not
//! do is move a file and stay silent while half a readme keeps pointing at
//! the old path. The scan runs before any write, on the same plan the
//! apply step consumes, and returns every suspicious occurrence with its
//! file and line.
//!
//! Matching is boundary-checked substring search over plain tokens (the
//! moved path, its extension-less module form, the exact specifier strings
//! importers used, and the directory prefix for batch moves). Tokens are
//! deliberately conservative: `lib/sum` matches `./lib/sum.ts` but not
//! `lib/summary`; occurrences inside the plan's own rewrite spans are the
//! import statements and are excluded.
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::core::index::Index;
use crate::core::plan::MovePlan;
use crate::core::{line_of, normalize_rel_path, rel_str};
/// One non-import occurrence of a moved path or specifier.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct NonImportRef {
/// Project-relative file containing the reference.
pub file: String,
/// 1-based line of the occurrence.
pub line: usize,
/// The matched token.
pub token: String,
/// Category of the token: `path`, `module`, `specifier` or `dir`.
/// Patterns are ranked in this order; one entry is kept per line.
pub kind: &'static str,
/// The matched line, trimmed and capped (context for humans/agents).
pub text: String,
}
/// Text formats beyond source code that may name project files.
const TEXT_EXTS: &[&str] = &[
"md", "mdx", "json", "json5", "yaml", "yml", "html", "htm", "xml", "txt", "pro", "gradle",
];
/// Lockfiles never reference project-local paths and are huge: skipped.
const LOCKFILES: &[&str] = &["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
const MAX_TEXT_BYTES: u64 = 512 * 1024;
/// Scan the project for references to `plan`'s moves outside the import
/// statements the plan already rewrites. Infallible by design: unreadable
/// files are skipped (the scanner already ignored what it could not read).
pub fn scan(root: &Path, index: &Index, plan: &MovePlan) -> Vec<NonImportRef> {
let patterns = patterns(root, plan);
if patterns.is_empty() {
return Vec::new();
}
let mut refs = Vec::new();
for file in text_files(root, index) {
let Ok(source) = std::fs::read_to_string(root.join(&file)) else {
continue; // binary or vanished between walk and read
};
let skip = skip_spans(plan, &file);
for (token, kind) in &patterns {
for at in token_occurrences(&source, token) {
if skip.iter().any(|s| s.contains(&at)) {
continue; // the import statements themselves
}
refs.push(NonImportRef {
file: rel_str(&file),
line: line_of(&source, at),
token: token.clone(),
kind,
text: excerpt(&source, at),
});
}
}
}
// Stable sort keeps the first-inserted (highest-ranked) token per line:
// one warning per reference site, not one per matching pattern.
refs.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line)));
refs.dedup_by(|a, b| a.file == b.file && a.line == b.line);
refs
}
// The search tokens of a plan. `source_root` scoping needs no special
// treatment: every token is already project-relative.
fn patterns(root: &Path, plan: &MovePlan) -> Vec<(String, &'static str)> {
let mut out: Vec<(String, &'static str)> = Vec::new();
let mut push = |token: String, kind: &'static str| {
if token.len() > 1 && !out.iter().any(|(t, _)| t == &token) {
out.push((token, kind));
}
};
for m in &plan.moves {
let path = rel_str(&m.source);
push(path.clone(), "path");
if let Some((stem, _)) = path.rsplit_once('.') {
push(stem.to_owned(), "module");
}
}
for rewrite in &plan.rewrites {
push(rewrite.old_text.clone(), "specifier");
}
if plan.moves.len() > 1 && root.join(&plan.source).is_dir() {
push(format!("{}/", rel_str(&plan.source)), "dir");
}
out
}
// Files whose rewrite the plan already performs legitimately: their spans.
fn skip_spans(plan: &MovePlan, file: &Path) -> Vec<std::ops::Range<usize>> {
plan.rewrites
.iter()
.filter(|r| r.file == file)
.map(|r| r.span.clone())
.collect()
}
/// Code files from the index plus non-hidden text files on disk.
fn text_files(root: &Path, index: &Index) -> Vec<PathBuf> {
let mut out = index.files.sorted();
let walker = ignore::WalkBuilder::new(root).require_git(false).build();
for entry in walker.flatten() {
let path = entry.path();
if entry.path_is_symlink() || !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if LOCKFILES.contains(&name) {
continue;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.unwrap_or_default();
if !TEXT_EXTS.contains(&ext.as_str()) {
continue;
}
let Ok(stripped) = path.strip_prefix(root) else {
continue;
};
let Some(rel) = normalize_rel_path(stripped) else {
continue;
};
let hidden = rel
.components()
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'));
let small = path
.metadata()
.map(|m| m.len() <= MAX_TEXT_BYTES)
.unwrap_or(false);
if !hidden && small && !out.contains(&rel) {
out.push(rel);
}
}
out.sort();
out
}
/// Every offset where `token` occurs with a word-free byte before it and
/// no word/joiner byte after: `lib/sum` matches `./lib/sum.ts` but never
/// `lib/summary` or `lib/sum-2`.
fn token_occurrences(source: &str, token: &str) -> Vec<usize> {
let bytes = source.as_bytes();
let mut hits = Vec::new();
let mut from = 0;
while let Some(found) = source[from..].find(token) {
let at = from + found;
let end = at + token.len();
let before_ok = at == 0 || !is_word_byte(bytes[at - 1]);
let after_ok = !bytes
.get(end)
.is_some_and(|c| is_word_byte(*c) || matches!(c, b'-' | b'_'));
if before_ok && after_ok {
hits.push(at);
}
from = end;
}
hits
}
fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b >= 0x80
}
/// The matched line, trimmed, with a hard cap.
fn excerpt(source: &str, at: usize) -> String {
let line_start = source[..at].rfind('\n').map_or(0, |i| i + 1);
let line_end = source[at..].find('\n').map_or(source.len(), |i| at + i);
let line = source[line_start..line_end].trim();
let cut = line.char_indices().nth(100).map_or(line.len(), |(i, _)| i);
line[..cut].to_owned()
}
#[cfg(test)]
mod tests;

123
src/core/refs/tests.rs Normal file
View file

@ -0,0 +1,123 @@
//! Scanner tests: what may be referenced, what must never match.
use std::path::Path;
use crate::core::JmoveResult;
use crate::core::index::Index;
use crate::core::plan::plan_move;
use tempfile::TempDir;
use super::{NonImportRef, scan, token_occurrences};
fn fixture() -> JmoveResult<TempDir> {
let dir = TempDir::new()?;
let root = dir.path();
let write = |rel: &str, body: &str| -> JmoveResult<()> {
fs_create(rel, body, root)?;
Ok(())
};
write("lib/sum.ts", "export const sum = 3;\n")?;
write("lib/summary.ts", "export const summary = 's';\n")?;
write("app.ts", "import { sum } from './lib/sum';\n")?;
write(
"README.md",
"Use [sum](./lib/sum.ts) via `lib/sum`.\nSee also lib/summary for text.\nLayout note: lib/ holds helpers.\n",
)?;
write(
"package.json",
"{\"name\": \"refs\", \"main\": \"./lib/sum.ts\"}\n",
)?;
write("__tests__/sum.test.ts", "jest.mock('../lib/sum');\n")?;
write("package-lock.json", "{\"x\": \"./lib/sum\"}\n")?;
write(".notes/refs.md", "stale: ./lib/sum\n")?;
Ok(dir)
}
fn fs_create(rel: &str, body: &str, root: &Path) -> std::io::Result<()> {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap())?;
std::fs::write(p, body)
}
fn files_of(refs: &[NonImportRef]) -> Vec<String> {
refs.iter().map(|r| r.file.clone()).collect()
}
fn scan_move(src: &str, dst: &str) -> JmoveResult<(TempDir, Vec<NonImportRef>)> {
let dir = fixture()?;
let index = Index::build(dir.path())?;
let plan = plan_move(&index, Path::new(src), Path::new(dst))?;
let refs = scan(dir.path(), &index, &plan);
Ok((dir, refs))
}
#[test]
fn doc_config_and_mock_references_are_reported() -> JmoveResult<()> {
let (_dir, refs) = scan_move("lib/sum.ts", "lib/total.ts")?;
let files = files_of(&refs);
for expected in ["README.md", "package.json", "__tests__/sum.test.ts"] {
assert!(
files.contains(&expected.to_string()),
"{expected} missing: {refs:?}"
);
}
Ok(())
}
#[test]
fn the_import_statement_itself_is_never_reported() -> JmoveResult<()> {
let (_dir, refs) = scan_move("lib/sum.ts", "lib/total.ts")?;
assert!(!files_of(&refs).contains(&"app.ts".to_string()), "{refs:?}");
assert!(!files_of(&refs).contains(&"lib/sum.ts".to_string()));
Ok(())
}
#[test]
fn decoys_lockfiles_and_hidden_dirs_are_not_reported() -> JmoveResult<()> {
let (_dir, refs) = scan_move("lib/sum.ts", "lib/total.ts")?;
let files = files_of(&refs);
for absent in ["lib/summary.ts", "package-lock.json", ".notes/refs.md"] {
assert!(
!files.contains(&absent.to_string()),
"{absent} leaked: {refs:?}"
);
}
// README line 2 mentions only `lib/summary`: no ref may point there.
assert!(
refs.iter().all(|r| r.file != "README.md" || r.line != 2),
"{refs:?}"
);
Ok(())
}
#[test]
fn one_entry_per_line_keeps_the_highest_ranked_token() -> JmoveResult<()> {
let (_dir, refs) = scan_move("lib/sum.ts", "lib/total.ts")?;
let readme = refs.iter().filter(|r| r.file == "README.md").count();
// Line 1 (three matching tokens) and line 3 (`lib/` dir note is only a
// file-move token set away) collapse to one entry per line.
assert_eq!(readme, 1, "{refs:?}");
assert_eq!(refs.iter().find(|r| r.file == "README.md").unwrap().line, 1);
Ok(())
}
#[test]
fn dir_move_reports_the_directory_prefix() -> JmoveResult<()> {
let (_dir, refs) = scan_move("lib", "pkg/lib")?;
assert!(
refs.iter()
.any(|r| r.file == "README.md" && r.line == 3 && r.kind == "dir"),
"{refs:?}"
);
Ok(())
}
#[test]
fn boundary_rules_reject_longer_joined_and_prefixed_names() {
let none: Vec<usize> = Vec::new();
assert_eq!(token_occurrences("lib/summary", "lib/sum"), none);
assert_eq!(token_occurrences("lib/sum-2", "lib/sum"), none);
assert_eq!(token_occurrences("mylib/sum", "lib/sum"), none);
assert_eq!(token_occurrences("./lib/sum.ts", "lib/sum"), vec![2]);
assert_eq!(token_occurrences("x/lib/sum", "lib/sum"), vec![2]);
}