Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 16 additions & 58 deletions src/core/mv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ use super::{
model::{LinkReplacement, MoveChange, MoveChangeKind, MovePreview, MoveTransaction},
progress::ProgressReporter,
util::{
collect_markdown_files, is_external_url, relative_path, resolve_path,
strip_utf8_bom_prefix, url_decode_link,
collect_markdown_files, is_external_url, relative_path, strip_utf8_bom_prefix,
url_decode_link,
},
};
use crate::{LinkType, MdrefError, Reference, Result, core::pathdiff::diff_paths, find_links};
Expand All @@ -29,37 +29,6 @@ enum RegularFileMoveMethod {

// LinkReplacement and MoveTransaction are now defined in the model module

/// Resolve a path to a canonical form suitable for **semantic comparison**
/// between `ReplacementPlan` keys.
///
/// Paths enter the plan from several sources — user-supplied `source`/`dest`
/// arguments, `WalkBuilder` output via `collect_markdown_files`, and
/// `validate_move_paths` results — each with a different textual shape for the
/// same filesystem entry (`examples/main.md` vs `./examples/main.md` vs a fully
/// canonicalised absolute path). Direct `PathBuf` equality misses these, which
/// is how #6 sneaked in.
///
/// We deliberately **do not** rewrite the plan's keys with the canonical form,
/// because those keys are surfaced downstream (e.g. as `MoveChange.path` in the
/// dry-run JSON) and should preserve the caller-supplied shape. Use this helper
/// only for transient comparisons via [`find_matching_plan_key`].
fn canonical_plan_key(path: &Path) -> PathBuf {
path.canonicalize()
.or_else(|_| resolve_path(path))
.unwrap_or_else(|_| path.to_path_buf())
}

/// Find the existing `ReplacementPlan` key that refers to the same filesystem
/// entry as `target`, comparing via [`canonical_plan_key`]. Returns a cloned
/// `PathBuf` so callers can pass it to `HashMap::remove` / `contains_key`
/// without fighting the borrow checker.
fn find_matching_plan_key(plan: &ReplacementPlan, target: &Path) -> Option<PathBuf> {
let target_key = canonical_plan_key(target);
plan.keys()
.find(|key| canonical_plan_key(key) == target_key)
.cloned()
}

/// Execute a fallible closure within a transaction context.
/// If the closure returns an error, the transaction is rolled back automatically.
fn execute_with_rollback<F>(transaction: &MoveTransaction, operation: F) -> Result<()>
Expand Down Expand Up @@ -534,16 +503,12 @@ fn move_source_replacements_to_destination(
source: &Path,
destination: &Path,
) {
let Some(existing_source_key) = find_matching_plan_key(replacements_by_file, source) else {
return;
};
let Some(source_replacements) = replacements_by_file.remove(&existing_source_key) else {
return;
};
let destination_key = find_matching_plan_key(replacements_by_file, destination)
.unwrap_or_else(|| destination.to_path_buf());
let destination_replacements = replacements_by_file.entry(destination_key).or_default();
extend_unique_replacements(destination_replacements, source_replacements);
if let Some(source_replacements) = replacements_by_file.remove(source) {
let destination_replacements = replacements_by_file
.entry(destination.to_path_buf())
.or_default();
extend_unique_replacements(destination_replacements, source_replacements);
}
}

fn add_destination_replacements(
Expand All @@ -555,9 +520,9 @@ fn add_destination_replacements(
return;
}

let destination_key = find_matching_plan_key(replacements_by_file, destination)
.unwrap_or_else(|| destination.to_path_buf());
let destination_replacements = replacements_by_file.entry(destination_key).or_default();
let destination_replacements = replacements_by_file
.entry(destination.to_path_buf())
.or_default();
extend_unique_replacements(destination_replacements, replacements);
}

Expand Down Expand Up @@ -660,9 +625,7 @@ fn preview_regular_file_move(
progress.set_message("Scanning references...");
let references = find_references(source, root, progress)?;
let mut replacements_by_file = plan_external_replacements(&references, &resolved_dest)?;
if let Some(source_key) = find_matching_plan_key(&replacements_by_file, source) {
replacements_by_file.remove(&source_key);
}
replacements_by_file.remove(source);
let internal_replacements = plan_internal_replacements(source, source, &resolved_dest)?;
add_destination_replacements(
&mut replacements_by_file,
Expand Down Expand Up @@ -755,9 +718,7 @@ fn mv_regular_file(
progress.set_message("Scanning references...");
let references = find_references(source, root, progress)?;
let mut replacements_by_file = plan_external_replacements(&references, &resolved_dest)?;
if let Some(source_key) = find_matching_plan_key(&replacements_by_file, source) {
replacements_by_file.remove(&source_key);
}
replacements_by_file.remove(source);
let internal_replacements = plan_internal_replacements(source, source, &resolved_dest)?;

if dry_run {
Expand All @@ -779,9 +740,7 @@ fn mv_regular_file(
transaction.snapshot_file(file_path)?;
}

if !internal_replacements.is_empty()
&& find_matching_plan_key(&replacements_by_file, source).is_none()
{
if !internal_replacements.is_empty() && !replacements_by_file.contains_key(source) {
transaction.snapshot_file(source)?;
}

Expand Down Expand Up @@ -862,13 +821,12 @@ fn mv_case_only_file(
}

let mut transaction = MoveTransaction::new(source.to_path_buf(), resolved_dest.to_path_buf());
let dest_key_in_plan = find_matching_plan_key(&replacements_by_file, resolved_dest);
if dest_key_in_plan.is_some() {
if replacements_by_file.contains_key(resolved_dest) {
transaction.snapshot_file(source)?;
}
for file_path in replacements_by_file
.keys()
.filter(|path| Some(*path) != dest_key_in_plan.as_ref())
.filter(|path| *path != resolved_dest)
{
transaction.snapshot_file(file_path)?;
}
Expand Down
9 changes: 9 additions & 0 deletions src/core/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ use ignore::WalkBuilder;
use crate::{MdrefError, Result, core::pathdiff::diff_paths};

/// Collect markdown files while respecting ignore files such as `.gitignore`.
///
/// Returned paths are normalised: a leading `./` prefix (produced by
/// `WalkBuilder` when `root` is `"."`) is stripped so that path shapes
/// match user-supplied relative paths.
pub fn collect_markdown_files(root: &Path) -> Vec<PathBuf> {
let mut builder = WalkBuilder::new(root);
builder.standard_filters(true).require_git(false);
Expand All @@ -18,6 +22,11 @@ pub fn collect_markdown_files(root: &Path) -> Vec<PathBuf> {
.is_some_and(|file_type| file_type.is_file())
})
.map(|entry| entry.into_path())
.map(|path| {
path.strip_prefix("./")
.map(|stripped| stripped.to_path_buf())
.unwrap_or(path)
})
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
.collect()
}
Expand Down
39 changes: 39 additions & 0 deletions tests/lib_find_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,3 +563,42 @@ fn test_find_references_handles_unicode_paths(
assert_eq!(result.len(), 1, "Should find one Unicode reference");
assert_eq!(result[0].link_text, expected_link_text);
}

// ============= Path normalization tests =============

/// Regression test for #8.
///
/// When `root` is `"."`, `find_references` should return `Reference.path` values
/// without a `./` prefix — they should match the shape a user would type on the
/// command line (e.g. `sub/ref.md`, not `./sub/ref.md`).
#[test]
#[allow(clippy::unwrap_used)]
fn test_find_references_paths_have_no_dot_slash_prefix() {
let temp_dir = TempDir::new().unwrap();
let target = temp_dir.path().join("target.md");
write_file(&target, "# Target");

let sub_dir = temp_dir.path().join("sub");
fs::create_dir_all(&sub_dir).unwrap();
let ref_file = sub_dir.join("ref.md");
write_file(&ref_file, "[Link](../target.md)");

// Use "." as root — this is the shape that triggers WalkBuilder's ./ prefix.
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(temp_dir.path()).unwrap();

let result = find_references("target.md", ".", &NoopProgress).unwrap();

std::env::set_current_dir(&original_dir).unwrap();

assert_eq!(result.len(), 1, "Should find exactly one reference");
let ref_path = result[0].path.to_string_lossy();
assert!(
!ref_path.starts_with("./"),
"Reference path should not have ./ prefix, got: {ref_path}"
);
assert_eq!(
ref_path, "sub/ref.md",
"Reference path should be a clean relative path"
);
}