diff --git a/.agents/skills/mdref-contribution-flow/SKILL.md b/.agents/skills/mdref-contribution-flow/SKILL.md index 337ea64..efe070f 100644 --- a/.agents/skills/mdref-contribution-flow/SKILL.md +++ b/.agents/skills/mdref-contribution-flow/SKILL.md @@ -110,11 +110,9 @@ Push more commits to the same branch. **No force-push after review starts** unle ```bash gh pr merge --squash --delete-branch -git checkout main && git pull --ff-only +git checkout main && git pull --ff-only && git fetch --prune ``` -The squash commit (= PR title) feeds `git-cliff` for the next CHANGELOG entry. - ## CI failures Required checks: `Precheck` and `Cross-platform build (macos-latest / windows-latest)`. Almost everything is reproducible via `./scripts/precheck.sh`. Inspect logs with `gh run view --log-failed`. diff --git a/src/core/mv.rs b/src/core/mv.rs deleted file mode 100644 index 36ba8ff..0000000 --- a/src/core/mv.rs +++ /dev/null @@ -1,1750 +0,0 @@ -use std::{ - collections::{HashMap, HashSet}, - fs, - path::{Path, PathBuf}, -}; - -use walkdir::WalkDir; - -use super::{ - find::find_references, - model::{LinkReplacement, MoveChange, MoveChangeKind, MovePreview, MoveTransaction}, - progress::ProgressReporter, - util::{ - 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}; - -type ReplacementPlan = HashMap>; -type SnapshotPaths = Vec; -type LineCache = HashMap>; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RegularFileMoveMethod { - Renamed, - CopyAndDelete, -} - -// LinkReplacement and MoveTransaction are now defined in the model module - -/// Execute a fallible closure within a transaction context. -/// If the closure returns an error, the transaction is rolled back automatically. -fn execute_with_rollback(transaction: &MoveTransaction, operation: F) -> Result<()> -where - F: FnOnce() -> Result<()>, -{ - match operation() { - Ok(()) => Ok(()), - Err(original_error) => { - let rollback_errors = transaction.rollback(); - if rollback_errors.is_empty() { - Err(original_error) - } else { - Err(MdrefError::RollbackFailed { - original_error: original_error.to_string(), - rollback_errors, - }) - } - } - } -} - -// ============= Path resolution ============= - -/// Resolve the destination path, handling the case where the destination is an existing directory. -fn resolve_destination(source: &Path, destination: &Path) -> Result { - if destination.is_dir() { - let filename = source - .file_name() - .ok_or_else(|| MdrefError::PathValidation { - path: source.to_path_buf(), - details: "source path has no filename".to_string(), - })?; - Ok(destination.join(filename)) - } else { - Ok(destination.to_path_buf()) - } -} - -/// Canonicalize a destination path, handling the case where it doesn't exist yet. -fn canonicalize_destination(destination: &Path) -> Result { - if destination.exists() { - return destination - .canonicalize() - .map_err(|e| MdrefError::PathValidation { - path: destination.to_path_buf(), - details: format!("cannot canonicalize destination path: {e}"), - }); - } - - let parent = destination - .parent() - .ok_or_else(|| MdrefError::PathValidation { - path: destination.to_path_buf(), - details: "destination path has no parent directory".to_string(), - })?; - - let parent_canonical = if parent.exists() { - parent - .canonicalize() - .map_err(|e| MdrefError::PathValidation { - path: parent.to_path_buf(), - details: format!("cannot canonicalize parent directory: {e}"), - })? - } else { - parent.to_path_buf() - }; - - let filename = destination - .file_name() - .ok_or_else(|| MdrefError::PathValidation { - path: destination.to_path_buf(), - details: "destination path has no filename".to_string(), - })?; - - Ok(parent_canonical.join(filename)) -} - -/// Validate that the move operation is valid: source exists, destination doesn't collide, etc. -/// Returns `(resolved_dest, source_canonical, dest_canonical)`. -fn validate_move_paths(source: &Path, destination: &Path) -> Result<(PathBuf, PathBuf, PathBuf)> { - if !source.exists() { - return Err(MdrefError::PathValidation { - path: source.to_path_buf(), - details: "source path does not exist".to_string(), - }); - } - - let source_canonical = source - .canonicalize() - .map_err(|e| MdrefError::PathValidation { - path: source.to_path_buf(), - details: format!("cannot canonicalize source path: {e}"), - })?; - - let resolved_dest = resolve_destination(source, destination)?; - let dest_canonical = canonicalize_destination(&resolved_dest)?; - - if source_canonical == dest_canonical { - return Err(MdrefError::PathValidation { - path: source.to_path_buf(), - details: "source and destination resolve to the same file".to_string(), - }); - } - - if resolved_dest.exists() { - return Err(MdrefError::PathValidation { - path: resolved_dest.clone(), - details: "destination path already exists".to_string(), - }); - } - - if source_canonical.is_dir() && dest_canonical.starts_with(&source_canonical) { - return Err(MdrefError::PathValidation { - path: source.to_path_buf(), - details: "cannot move directory into itself or one of its subdirectories".to_string(), - }); - } - - Ok((resolved_dest, source_canonical, dest_canonical)) -} - -fn resolve_case_only_destination(source: &Path, destination: &Path) -> Result> { - if !source.exists() { - return Ok(None); - } - - let source_canonical = source - .canonicalize() - .map_err(|e| MdrefError::PathValidation { - path: source.to_path_buf(), - details: format!("cannot canonicalize source path: {e}"), - })?; - let resolved_dest = resolve_destination(source, destination)?; - let dest_canonical = canonicalize_destination(&resolved_dest)?; - let same_parent = source.parent() == resolved_dest.parent(); - let case_only_name_change = source - .file_name() - .zip(resolved_dest.file_name()) - .map(|(source_name, dest_name)| { - let source_name = source_name.to_string_lossy(); - let dest_name = dest_name.to_string_lossy(); - source_name != dest_name && source_name.eq_ignore_ascii_case(&dest_name) - }) - .unwrap_or(false); - - if source_canonical == dest_canonical && same_parent && case_only_name_change { - Ok(Some(resolved_dest)) - } else { - Ok(None) - } -} - -// ============= Replacement planning ============= - -/// Collect all link replacements needed for external references (other files pointing to the moved file). -fn plan_external_replacements( - references: &[Reference], - resolved_dest: &Path, -) -> Result { - let mut replacements_by_file: ReplacementPlan = HashMap::new(); - let mut line_cache = LineCache::new(); - - for reference in references { - let (_link_path_only, anchor) = split_link_and_anchor(&reference.link_text); - let new_link_path = relative_path(&reference.path, resolved_dest)?; - - let new_link_with_anchor = match anchor { - Some(a) => format!("{}#{}", new_link_path.display(), a), - None => new_link_path.display().to_string(), - }; - - replacements_by_file - .entry(reference.path.clone()) - .or_default() - .push(build_replacement( - reference, - &new_link_with_anchor, - &mut line_cache, - )?); - } - - Ok(replacements_by_file) -} - -fn relative_path_preserving_filename_case(from: &Path, to: &Path) -> Result { - let from_parent = from.parent().ok_or_else(|| MdrefError::PathValidation { - path: from.to_path_buf(), - details: "no parent directory".to_string(), - })?; - let from_resolved = if from_parent.exists() { - from_parent.canonicalize()? - } else { - super::util::resolve_parent(from_parent)? - }; - - let to_parent = to.parent().ok_or_else(|| MdrefError::PathValidation { - path: to.to_path_buf(), - details: "no parent directory".to_string(), - })?; - let to_parent_resolved = if to_parent.exists() { - to_parent.canonicalize()? - } else { - super::util::resolve_parent(to_parent)? - }; - let filename = to.file_name().ok_or_else(|| MdrefError::PathValidation { - path: to.to_path_buf(), - details: "no file name".to_string(), - })?; - - Ok(diff_paths(to_parent_resolved.join(filename), from_resolved).unwrap_or_default()) -} - -fn plan_case_only_external_replacements( - references: &[Reference], - resolved_dest: &Path, -) -> Result { - let mut replacements_by_file: ReplacementPlan = HashMap::new(); - let mut line_cache = LineCache::new(); - - for reference in references { - let (_link_path_only, anchor) = split_link_and_anchor(&reference.link_text); - let new_link_path = relative_path_preserving_filename_case(&reference.path, resolved_dest)?; - - let new_link_with_anchor = match anchor { - Some(a) => format!("{}#{}", new_link_path.display(), a), - None => new_link_path.display().to_string(), - }; - - replacements_by_file - .entry(reference.path.clone()) - .or_default() - .push(build_replacement( - reference, - &new_link_with_anchor, - &mut line_cache, - )?); - } - - Ok(replacements_by_file) -} - -/// Collect all link replacements needed for internal links within the moved file itself. -fn plan_internal_replacements( - scan_path: &Path, - source: &Path, - resolved_dest: &Path, -) -> Result> { - let links = find_links(scan_path)?; - let mut replacements = Vec::new(); - let mut line_cache = LineCache::new(); - - for link in &links { - if let Some(replacement) = - build_link_replacement(link, source, resolved_dest, &mut line_cache)? - { - replacements.push(replacement); - } - } - - Ok(replacements) -} - -fn build_directory_path_mappings( - source_dir: &Path, - source_canonical: &Path, - dest_canonical: &Path, -) -> Result> { - let mut mappings = HashMap::new(); - mappings.insert(source_canonical.to_path_buf(), dest_canonical.to_path_buf()); - - for entry in WalkDir::new(source_dir) - .sort_by_file_name() - .into_iter() - .filter_map(|entry| entry.ok()) - .skip(1) - { - let relative = - entry - .path() - .strip_prefix(source_dir) - .map_err(|e| MdrefError::PathValidation { - path: entry.path().to_path_buf(), - details: format!( - "cannot compute relative path under '{}': {e}", - source_dir.display() - ), - })?; - let old_path = entry - .path() - .canonicalize() - .map_err(|e| MdrefError::PathValidation { - path: entry.path().to_path_buf(), - details: format!("cannot canonicalize directory entry: {e}"), - })?; - mappings.insert(old_path, dest_canonical.join(relative)); - } - - Ok(mappings) -} - -fn resolve_reference_target(base_file: &Path, link_path_only: &str) -> Option { - if link_path_only.is_empty() { - return None; - } - - let decoded_link = url_decode_link(link_path_only); - let decoded_path = Path::new(&decoded_link); - let resolved = if decoded_path.is_absolute() { - decoded_path.to_path_buf() - } else { - base_file.parent()?.join(decoded_path) - }; - - resolved.canonicalize().ok() -} - -fn remap_existing_path( - path: &Path, - source_canonical: &Path, - path_mappings: &HashMap, -) -> Result { - let canonical = path - .canonicalize() - .map_err(|e| MdrefError::PathValidation { - path: path.to_path_buf(), - details: format!("cannot canonicalize path: {e}"), - })?; - - if canonical.starts_with(source_canonical) { - path_mappings - .get(&canonical) - .cloned() - .ok_or_else(|| MdrefError::PathValidation { - path: path.to_path_buf(), - details: "cannot map moved path to its destination".to_string(), - }) - } else { - Ok(path.to_path_buf()) - } -} - -fn build_replacement_for_target( - reference: &Reference, - file_after_move: &Path, - new_target: &Path, - line_cache: &mut LineCache, -) -> Result { - let (_link_path_only, anchor) = split_link_and_anchor(&reference.link_text); - let new_link_path = relative_path(file_after_move, new_target)?; - - let new_link_with_anchor = match anchor { - Some(anchor) => format!("{}#{}", new_link_path.display(), anchor), - None => new_link_path.display().to_string(), - }; - - build_replacement(reference, &new_link_with_anchor, line_cache) -} - -fn plan_directory_replacements( - source_dir: &Path, - source_canonical: &Path, - dest_canonical: &Path, - root: &Path, - progress: &dyn ProgressReporter, -) -> Result<(ReplacementPlan, SnapshotPaths)> { - let path_mappings = - build_directory_path_mappings(source_dir, source_canonical, dest_canonical)?; - let mut replacements_by_file: ReplacementPlan = HashMap::new(); - let mut snapshot_paths: HashSet = HashSet::new(); - let mut line_cache = LineCache::new(); - - progress.set_message("Scanning references..."); - for reference in find_references(source_dir, root, progress)? { - let (link_path_only, _) = split_link_and_anchor(&reference.link_text); - let Some(old_target) = resolve_reference_target(&reference.path, link_path_only) else { - continue; - }; - let Some(new_target) = path_mappings.get(&old_target) else { - continue; - }; - - let file_after_move = - remap_existing_path(&reference.path, source_canonical, &path_mappings)?; - let replacement = build_replacement_for_target( - &reference, - &file_after_move, - new_target, - &mut line_cache, - )?; - - replacements_by_file - .entry(file_after_move) - .or_default() - .push(replacement); - snapshot_paths.insert(reference.path); - } - - for markdown_file in collect_markdown_files(source_dir) { - let file_after_move = - remap_existing_path(&markdown_file, source_canonical, &path_mappings)?; - let links = find_links(&markdown_file)?; - - for link in links { - let (link_path_only, _) = split_link_and_anchor(&link.link_text); - let Some(target_path) = resolve_reference_target(&markdown_file, link_path_only) else { - continue; - }; - - if target_path.starts_with(source_canonical) { - continue; - } - - let replacement = build_replacement_for_target( - &link, - &file_after_move, - &target_path, - &mut line_cache, - )?; - replacements_by_file - .entry(file_after_move.clone()) - .or_default() - .push(replacement); - snapshot_paths.insert(markdown_file.clone()); - } - } - - Ok((replacements_by_file, snapshot_paths.into_iter().collect())) -} - -fn try_rename_regular_file(source: &Path, dest: &Path) -> std::io::Result { - try_rename_regular_file_with(source, dest, |from, to| fs::rename(from, to)) -} - -fn try_rename_regular_file_with( - source: &Path, - dest: &Path, - rename: F, -) -> std::io::Result -where - F: FnOnce(&Path, &Path) -> std::io::Result<()>, -{ - match rename(source, dest) { - Ok(()) => Ok(RegularFileMoveMethod::Renamed), - Err(error) if error.kind() == std::io::ErrorKind::CrossesDevices => { - Ok(RegularFileMoveMethod::CopyAndDelete) - } - Err(error) => Err(error), - } -} - -fn extend_unique_replacements( - destination_replacements: &mut Vec, - replacements: Vec, -) { - for replacement in replacements { - let already_present = destination_replacements.iter().any(|existing| { - existing.line == replacement.line - && existing.column == replacement.column - && existing.old_pattern == replacement.old_pattern - && existing.new_pattern == replacement.new_pattern - }); - - if !already_present { - destination_replacements.push(replacement); - } - } -} - -fn move_source_replacements_to_destination( - replacements_by_file: &mut ReplacementPlan, - source: &Path, - destination: &Path, -) { - 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( - replacements_by_file: &mut ReplacementPlan, - destination: &Path, - replacements: Vec, -) { - if replacements.is_empty() { - return; - } - - let destination_replacements = replacements_by_file - .entry(destination.to_path_buf()) - .or_default(); - extend_unique_replacements(destination_replacements, replacements); -} - -// ============= Public API ============= - -/// Move a Markdown file or directory and atomically update all references across the project. -/// -/// This function finds all references to the source file and updates them to point to the -/// new location. It also updates links within the moved file itself to ensure they remain valid. -/// -/// **Atomicity guarantee**: all filesystem mutations are tracked in a transaction. If any step -/// fails, all changes are rolled back — modified files are restored to their original content, -/// the copied destination is removed, and the deleted source is recovered. -/// -/// When `dry_run` is `true`, no files are created, moved, or modified. Instead, the function -/// prints all changes that *would* be made, allowing the user to preview the operation. -/// -/// If the destination path is an existing directory, the source file will be moved into that -/// directory with its original filename preserved. -/// -/// # Progress -/// -/// Callers pass a [`ProgressReporter`] trait object to receive scanning progress. -/// Pass [`crate::NoopProgress`] (as `&NoopProgress`) when progress updates are not needed. -pub fn mv( - source: P, - dest: B, - root: D, - dry_run: bool, - progress: &dyn ProgressReporter, -) -> Result<()> -where - P: AsRef, - B: AsRef, - D: AsRef, -{ - let source = source.as_ref(); - let dest = dest.as_ref(); - let root = root.as_ref(); - - if source.is_dir() { - return mv_directory(source, dest, root, dry_run, progress); - } - - mv_regular_file(source, dest, root, dry_run, progress) -} - -/// Preview a Markdown move without mutating the filesystem. -/// -/// The returned preview contains the resolved destination path and all link -/// replacements that would be applied by the move. -/// -/// # Progress -/// -/// Callers pass a [`ProgressReporter`] trait object to receive scanning progress. -/// Pass [`crate::NoopProgress`] (as `&NoopProgress`) when progress updates are not needed. -pub fn preview_move( - source: P, - dest: B, - root: D, - progress: &dyn ProgressReporter, -) -> Result -where - P: AsRef, - B: AsRef, - D: AsRef, -{ - let source = source.as_ref(); - let dest = dest.as_ref(); - let root = root.as_ref(); - - if source.is_dir() { - return preview_directory_move(source, dest, root, progress); - } - - preview_regular_file_move(source, dest, root, progress) -} - -fn preview_regular_file_move( - source: &Path, - dest: &Path, - root: &Path, - progress: &dyn ProgressReporter, -) -> Result { - if let Some(case_only_dest) = resolve_case_only_destination(source, dest)? { - return preview_case_only_file_move(source, &case_only_dest, root, progress); - } - - let (resolved_dest, _source_canonical, _dest_canonical) = - match validate_move_paths(source, dest) { - Ok(paths) => paths, - Err(e) => { - if e.to_string().contains("resolve to the same file") { - return Ok(build_move_preview(source, source, HashMap::new())); - } - return Err(e); - } - }; - - progress.set_message("Scanning references..."); - let references = find_references(source, root, progress)?; - let mut replacements_by_file = plan_external_replacements(&references, &resolved_dest)?; - replacements_by_file.remove(source); - let internal_replacements = plan_internal_replacements(source, source, &resolved_dest)?; - add_destination_replacements( - &mut replacements_by_file, - &resolved_dest, - internal_replacements, - ); - - Ok(build_move_preview( - source, - &resolved_dest, - replacements_by_file, - )) -} - -fn preview_case_only_file_move( - source: &Path, - resolved_dest: &Path, - root: &Path, - progress: &dyn ProgressReporter, -) -> Result { - progress.set_message("Scanning references..."); - let references = find_references(source, root, progress)?; - let mut replacements_by_file = - plan_case_only_external_replacements(&references, resolved_dest)?; - move_source_replacements_to_destination(&mut replacements_by_file, source, resolved_dest); - - Ok(build_move_preview( - source, - resolved_dest, - replacements_by_file, - )) -} - -fn preview_directory_move( - source_dir: &Path, - new_path: &Path, - root: &Path, - progress: &dyn ProgressReporter, -) -> Result { - let (resolved_dest, source_canonical, dest_canonical) = - match validate_move_paths(source_dir, new_path) { - Ok(paths) => paths, - Err(e) => { - if e.to_string().contains("resolve to the same file") { - return Ok(build_move_preview(source_dir, source_dir, HashMap::new())); - } - return Err(e); - } - }; - - let (replacements_by_file, _snapshot_paths) = plan_directory_replacements( - source_dir, - &source_canonical, - &dest_canonical, - root, - progress, - )?; - - Ok(build_move_preview( - source_dir, - &resolved_dest, - replacements_by_file, - )) -} - -fn mv_regular_file( - source: &Path, - dest: &Path, - root: &Path, - dry_run: bool, - progress: &dyn ProgressReporter, -) -> Result<()> { - if let Some(case_only_dest) = resolve_case_only_destination(source, dest)? { - return mv_case_only_file(source, &case_only_dest, root, dry_run, progress); - } - - let (resolved_dest, _source_canonical, _dest_canonical) = - match validate_move_paths(source, dest) { - Ok(paths) => paths, - Err(e) => { - // Special case: source == destination is a no-op, not an error. - if e.to_string().contains("resolve to the same file") { - return Ok(()); - } - return Err(e); - } - }; - - // Phase 1: Plan — pure computation, no side effects. - progress.set_message("Scanning references..."); - let references = find_references(source, root, progress)?; - let mut replacements_by_file = plan_external_replacements(&references, &resolved_dest)?; - replacements_by_file.remove(source); - let internal_replacements = plan_internal_replacements(source, source, &resolved_dest)?; - - if dry_run { - add_destination_replacements( - &mut replacements_by_file, - &resolved_dest, - internal_replacements, - ); - let preview = build_move_preview(source, &resolved_dest, replacements_by_file); - print_dry_run_report(&preview); - return Ok(()); - } - - // Phase 2: Execute — all mutations are tracked for rollback. - let mut transaction = MoveTransaction::new(source.to_path_buf(), resolved_dest.clone()); - - // Snapshot all files that will be modified before touching anything. - for file_path in replacements_by_file.keys() { - transaction.snapshot_file(file_path)?; - } - - if !internal_replacements.is_empty() && !replacements_by_file.contains_key(source) { - transaction.snapshot_file(source)?; - } - - // Ensure the parent directory of the destination exists. - if let Some(parent) = resolved_dest.parent() { - fs::create_dir_all(parent)?; - } - - let move_method = try_rename_regular_file(source, &resolved_dest)?; - match move_method { - RegularFileMoveMethod::Renamed => { - transaction.mark_renamed(); - add_destination_replacements( - &mut replacements_by_file, - &resolved_dest, - internal_replacements, - ); - } - RegularFileMoveMethod::CopyAndDelete => { - fs::copy(source, &resolved_dest)?; - transaction.mark_copied(); - - if !internal_replacements.is_empty() { - transaction.snapshot_file(&resolved_dest)?; - add_destination_replacements( - &mut replacements_by_file, - &resolved_dest, - internal_replacements, - ); - } - } - } - - // Apply all replacements within a rollback-protected context. - execute_with_rollback(&transaction, || { - for (file_path, replacements) in &replacements_by_file { - apply_replacements(file_path, replacements)?; - } - Ok(()) - })?; - - if move_method == RegularFileMoveMethod::CopyAndDelete { - if let Err(original_error) = fs::remove_file(source) { - let rollback_errors = transaction.rollback(); - return if rollback_errors.is_empty() { - Err(original_error.into()) - } else { - Err(MdrefError::RollbackFailed { - original_error: original_error.to_string(), - rollback_errors, - }) - }; - } - - transaction.mark_source_removed(); - } - - Ok(()) -} - -fn mv_case_only_file( - source: &Path, - resolved_dest: &Path, - root: &Path, - dry_run: bool, - progress: &dyn ProgressReporter, -) -> Result<()> { - progress.set_message("Scanning references..."); - let references = find_references(source, root, progress)?; - let mut replacements_by_file = - plan_case_only_external_replacements(&references, resolved_dest)?; - move_source_replacements_to_destination(&mut replacements_by_file, source, resolved_dest); - - if dry_run { - let preview = build_move_preview(source, resolved_dest, replacements_by_file); - print_dry_run_report(&preview); - return Ok(()); - } - - let mut transaction = MoveTransaction::new(source.to_path_buf(), resolved_dest.to_path_buf()); - if replacements_by_file.contains_key(resolved_dest) { - transaction.snapshot_file(source)?; - } - for file_path in replacements_by_file - .keys() - .filter(|path| *path != resolved_dest) - { - transaction.snapshot_file(file_path)?; - } - - fs::rename(source, resolved_dest)?; - transaction.mark_renamed(); - - execute_with_rollback(&transaction, || { - for (file_path, replacements) in &replacements_by_file { - apply_replacements(file_path, replacements)?; - } - Ok(()) - }) -} - -fn mv_directory( - source_dir: &Path, - new_path: &Path, - root: &Path, - dry_run: bool, - progress: &dyn ProgressReporter, -) -> Result<()> { - let (resolved_dest, source_canonical, dest_canonical) = - match validate_move_paths(source_dir, new_path) { - Ok(paths) => paths, - Err(e) => { - if e.to_string().contains("resolve to the same file") { - return Ok(()); - } - return Err(e); - } - }; - - let (replacements_by_file, snapshot_paths) = plan_directory_replacements( - source_dir, - &source_canonical, - &dest_canonical, - root, - progress, - )?; - - if dry_run { - let preview = build_move_preview(source_dir, &resolved_dest, replacements_by_file); - print_dry_run_report(&preview); - return Ok(()); - } - - let mut transaction = MoveTransaction::new(source_dir.to_path_buf(), resolved_dest.clone()); - for snapshot_path in snapshot_paths { - transaction.snapshot_file(&snapshot_path)?; - } - - if let Some(parent) = resolved_dest.parent() { - fs::create_dir_all(parent)?; - } - - fs::rename(source_dir, &resolved_dest)?; - transaction.mark_renamed(); - - execute_with_rollback(&transaction, || { - for (file_path, replacements) in &replacements_by_file { - apply_replacements(file_path, replacements)?; - } - Ok(()) - }) -} - -/// Print a human-readable report of all changes that would be made during a move operation. -fn print_dry_run_report(preview: &MovePreview) { - println!( - "[dry-run] Would move: {} -> {}", - preview.source.display(), - preview.destination.display() - ); - - if preview.changes.is_empty() { - println!("[dry-run] No references to update."); - return; - } - - for change in &preview.changes { - let label = match change.kind { - MoveChangeKind::MovedFileUpdate => "Would update links in moved file", - MoveChangeKind::ReferenceUpdate => "Would update reference in", - }; - println!("[dry-run] {} {}:", label, change.path.display()); - for replacement in &change.replacements { - println!( - " Line {}: {} -> {}", - replacement.line, replacement.old_pattern, replacement.new_pattern - ); - } - } -} - -fn build_move_preview( - source: &Path, - destination: &Path, - replacements_by_file: ReplacementPlan, -) -> MovePreview { - let mut changes = replacements_by_file - .into_iter() - .map(|(path, mut replacements)| { - replacements.sort_by(|left, right| { - left.line - .cmp(&right.line) - .then(left.column.cmp(&right.column)) - .then(left.old_pattern.cmp(&right.old_pattern)) - .then(left.new_pattern.cmp(&right.new_pattern)) - }); - - let kind = if path == destination { - MoveChangeKind::MovedFileUpdate - } else { - MoveChangeKind::ReferenceUpdate - }; - - MoveChange { - path, - kind, - replacements, - } - }) - .collect::>(); - - changes.sort_by(|left, right| left.path.cmp(&right.path)); - - MovePreview { - source: source.to_path_buf(), - destination: destination.to_path_buf(), - changes, - } -} - -/// Split a link into the path part and the anchor (fragment) part. -/// Returns (path, Some(anchor)) if there's an anchor, or (path, None) if not. -/// Examples: -/// "file.md#section" -> ("file.md", Some("section")) -/// "file.md" -> ("file.md", None) -/// "#section" -> ("", Some("section")) (pure anchor link) -fn split_link_and_anchor(link: &str) -> (&str, Option<&str>) { - match link.find('#') { - Some(pos) => { - let (path, anchor) = link.split_at(pos); - // Remove the '#' prefix from anchor - (path, Some(&anchor[1..])) - } - None => (link, None), - } -} - -/// Build a LinkReplacement for an internal link in the moved file. -/// Returns `None` if the link is an external URL or a broken link that cannot be resolved. -fn build_link_replacement( - r: &Reference, - raw_filepath: &Path, - new_filepath: &Path, - line_cache: &mut LineCache, -) -> Result> { - // External URLs (https://, http://, etc.) are not local file paths - // and should not be rewritten during a file move. - if is_external_url(&r.link_text) { - return Ok(None); - } - - // Strip anchor from link text so canonicalize works on the file path only. - let (link_path_only, anchor) = split_link_and_anchor(&r.link_text); - - // Pure anchor links (e.g. "#section") are internal to the document - // and should not be rewritten during a file move. - if link_path_only.is_empty() { - return Ok(None); - } - - let parent = raw_filepath - .parent() - .ok_or_else(|| MdrefError::PathValidation { - path: raw_filepath.to_path_buf(), - details: "no parent directory".to_string(), - })?; - - // Resolve the link path; skip broken links that cannot be canonicalized. - let current_link_absolute_path = match parent.join(link_path_only).canonicalize() { - Ok(p) => p, - Err(_) => return Ok(None), - }; - let new_file_absolute_path = if new_filepath.exists() { - new_filepath.canonicalize()? - } else { - let parent = new_filepath - .parent() - .ok_or_else(|| MdrefError::PathValidation { - path: new_filepath.to_path_buf(), - details: "no parent directory".to_string(), - })?; - let parent_canonical = if parent.exists() { - parent.canonicalize()? - } else { - parent.to_path_buf() - }; - let filename = new_filepath - .file_name() - .ok_or_else(|| MdrefError::PathValidation { - path: new_filepath.to_path_buf(), - details: "no file name".to_string(), - })?; - parent_canonical.join(filename) - }; - let raw_file_canonical = raw_filepath.canonicalize()?; - - let new_link_path = if current_link_absolute_path == raw_file_canonical { - PathBuf::from(new_file_absolute_path.file_name().ok_or_else(|| { - MdrefError::PathValidation { - path: new_file_absolute_path.clone(), - details: "no file name".to_string(), - } - })?) - } else { - relative_path(&new_file_absolute_path, ¤t_link_absolute_path)? - }; - - // Reconstruct the new pattern with anchor preserved. - let new_link_with_anchor = match anchor { - Some(a) => format!("{}#{}", new_link_path.display(), a), - None => new_link_path.display().to_string(), - }; - - Ok(Some(build_replacement( - r, - &new_link_with_anchor, - line_cache, - )?)) -} - -fn build_replacement( - reference: &Reference, - new_url: &str, - line_cache: &mut LineCache, -) -> Result { - match reference.link_type { - LinkType::Inline => Ok(LinkReplacement { - line: reference.line, - column: reference.column, - old_pattern: format!("]({})", reference.link_text), - new_pattern: format!("]({})", new_url), - }), - LinkType::ReferenceDefinition => { - build_reference_definition_replacement(reference, new_url, line_cache) - } - } -} - -fn build_reference_definition_replacement( - reference: &Reference, - new_url: &str, - line_cache: &mut LineCache, -) -> Result { - let line = get_cached_line(&reference.path, reference.line, line_cache)?; - let (url_start, url_end) = - find_reference_definition_url_span(line).ok_or_else(|| MdrefError::PathValidation { - path: reference.path.clone(), - details: format!( - "could not parse reference definition in line {}", - reference.line - ), - })?; - - Ok(LinkReplacement { - line: reference.line, - column: url_start + 1, - old_pattern: line[url_start..url_end].to_string(), - new_pattern: new_url.to_string(), - }) -} - -fn get_cached_line<'a>( - path: &Path, - line_number: usize, - line_cache: &'a mut LineCache, -) -> Result<&'a str> { - let lines = match line_cache.entry(path.to_path_buf()) { - std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), - std::collections::hash_map::Entry::Vacant(entry) => { - let content = fs::read_to_string(path).map_err(|e| MdrefError::IoRead { - path: path.to_path_buf(), - source: e, - })?; - entry.insert(content.lines().map(|line| line.to_string()).collect()) - } - }; - - if line_number == 0 || line_number > lines.len() { - return Err(MdrefError::InvalidLineReference { - path: path.to_path_buf(), - line: line_number, - details: format!("line number out of range (file has {} lines)", lines.len()), - }); - } - - Ok(lines[line_number - 1].as_str()) -} - -#[derive(Clone, Copy)] -enum LineEnding { - None, - Lf, - CrLf, -} - -impl LineEnding { - fn as_str(self) -> &'static str { - match self { - Self::None => "", - Self::Lf => "\n", - Self::CrLf => "\r\n", - } - } -} - -fn split_lines_preserving_endings(content: &str) -> Vec<(String, LineEnding)> { - let mut lines = Vec::new(); - let mut start = 0; - let bytes = content.as_bytes(); - let mut index = 0; - - while index < bytes.len() { - if bytes[index] == b'\n' { - let is_crlf = index > 0 && bytes[index - 1] == b'\r'; - let line_end = if is_crlf { index - 1 } else { index }; - let line = content[start..line_end].to_string(); - let ending = if is_crlf { - LineEnding::CrLf - } else { - LineEnding::Lf - }; - lines.push((line, ending)); - start = index + 1; - } - index += 1; - } - - if start < content.len() { - lines.push((content[start..].to_string(), LineEnding::None)); - } - - lines -} - -fn find_reference_definition_url_span(line: &str) -> Option<(usize, usize)> { - let (line_without_bom, bom_offset) = strip_utf8_bom_prefix(line); - let trimmed = line_without_bom.trim_start(); - let leading_spaces = line_without_bom.len() - trimmed.len(); - if leading_spaces > 3 || !trimmed.starts_with('[') { - return None; - } - - let label_end = trimmed.find("]:")?; - if label_end == 0 { - return None; - } - - let after_colon_start = bom_offset + leading_spaces + label_end + 2; - let after_colon = &line[after_colon_start..]; - let trimmed_after_colon = after_colon.trim_start(); - if trimmed_after_colon.is_empty() { - return None; - } - - let leading_after_colon = after_colon.len() - trimmed_after_colon.len(); - let url_start = after_colon_start + leading_after_colon; - - if let Some(stripped) = trimmed_after_colon.strip_prefix('<') { - let end = stripped.find('>')?; - let inner_start = url_start + 1; - let inner_end = inner_start + end; - Some((inner_start, inner_end)) - } else { - let end = trimmed_after_colon - .find(char::is_whitespace) - .unwrap_or(trimmed_after_colon.len()); - Some((url_start, url_start + end)) - } -} - -/// Apply all pending replacements to a single file in one read-write cycle. -/// Replacements are sorted in reverse order (by line desc, then column desc) so that -/// earlier replacements do not shift the positions of later ones. -fn apply_replacements(file_path: &Path, replacements: &[LinkReplacement]) -> Result<()> { - let content = fs::read_to_string(file_path).map_err(|e| MdrefError::IoRead { - path: file_path.to_path_buf(), - source: e, - })?; - let mut lines = split_lines_preserving_endings(&content); - - // Sort replacements in reverse order (bottom-right to top-left) so that - // replacing one link does not invalidate the positions of subsequent ones. - let mut sorted_indices: Vec = (0..replacements.len()).collect(); - sorted_indices.sort_by(|&a, &b| { - replacements[b] - .line - .cmp(&replacements[a].line) - .then_with(|| replacements[b].column.cmp(&replacements[a].column)) - }); - - for &idx in &sorted_indices { - let replacement = &replacements[idx]; - - if replacement.line > lines.len() { - return Err(MdrefError::InvalidLineReference { - path: file_path.to_path_buf(), - line: replacement.line, - details: format!("line number out of range (file has {} lines)", lines.len()), - }); - } - - let line = &lines[replacement.line - 1].0; - let col = replacement.column.saturating_sub(1); // Convert to 0-based index - - // Search for the old_pattern starting from the column position. - // This ensures we replace the correct occurrence when multiple identical links exist. - if let Some(pos) = line[col..].find(&replacement.old_pattern) { - let actual_pos = col + pos; - let end_pos = actual_pos + replacement.old_pattern.len(); - let new_line = format!( - "{}{}{}", - &line[..actual_pos], - replacement.new_pattern, - &line[end_pos..] - ); - lines[replacement.line - 1].0 = new_line; - } else { - return Err(MdrefError::PathValidation { - path: file_path.to_path_buf(), - details: format!( - "could not find link '{}' in line {}", - replacement.old_pattern, replacement.line - ), - }); - } - } - - let new_content = lines - .into_iter() - .map(|(line, ending)| format!("{line}{}", ending.as_str())) - .collect::(); - fs::write(file_path, new_content).map_err(|e| MdrefError::IoWrite { - path: file_path.to_path_buf(), - source: e, - })?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use tempfile::TempDir; - - use super::*; - use crate::test_utils::write_file; - - // ============= apply_replacements tests ============= - - #[test] - #[allow(clippy::unwrap_used)] - fn test_apply_replacements_single_link_rewrites_target_path() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("doc.md"); - write_file(file_path.to_str().unwrap(), "[Link](old.md)"); - - let replacements = vec![LinkReplacement { - line: 1, - column: 1, - old_pattern: "](old.md)".to_string(), - new_pattern: "](new.md)".to_string(), - }]; - - apply_replacements(&file_path, &replacements).unwrap(); - - let content = fs::read_to_string(&file_path).unwrap(); - assert!(content.contains("](new.md)")); - assert!(!content.contains("](old.md)")); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_apply_replacements_preserves_other_content() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("doc.md"); - write_file( - file_path.to_str().unwrap(), - "# Title\n\nSome text [Link](old.md) more text.\n\nAnother paragraph.", - ); - - let replacements = vec![LinkReplacement { - line: 3, - column: 11, - old_pattern: "](old.md)".to_string(), - new_pattern: "](new.md)".to_string(), - }]; - - apply_replacements(&file_path, &replacements).unwrap(); - - let content = fs::read_to_string(&file_path).unwrap(); - assert!(content.contains("# Title")); - assert!(content.contains("Some text [Link](new.md) more text.")); - assert!(content.contains("Another paragraph.")); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_apply_replacements_crlf_input_preserves_crlf_line_endings() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("doc.md"); - write_file( - file_path.to_str().unwrap(), - "# Title\r\n\r\nSee [Link](old.md)\r\n", - ); - - let replacements = vec![LinkReplacement { - line: 3, - column: 5, - old_pattern: "](old.md)".to_string(), - new_pattern: "](new.md)".to_string(), - }]; - - apply_replacements(&file_path, &replacements).unwrap(); - - let content = fs::read_to_string(&file_path).unwrap(); - assert_eq!(content, "# Title\r\n\r\nSee [Link](new.md)\r\n"); - assert!(!content.contains('\n') || content.contains("\r\n")); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_apply_replacements_line_out_of_range_returns_invalid_line_error() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("doc.md"); - write_file(file_path.to_str().unwrap(), "Single line"); - - let replacements = vec![LinkReplacement { - line: 999, - column: 1, - old_pattern: "](link.md)".to_string(), - new_pattern: "](new.md)".to_string(), - }]; - - let result = apply_replacements(&file_path, &replacements); - match result { - Err(MdrefError::InvalidLineReference { path, line, .. }) => { - assert_eq!(line, 999); - assert!(path.ends_with("doc.md")); - } - other => panic!("expected invalid line error, got {other:?}"), - } - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_apply_replacements_with_subdirectory_path() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("doc.md"); - write_file(file_path.to_str().unwrap(), "[Link](sub/old.md)"); - - let replacements = vec![LinkReplacement { - line: 1, - column: 1, - old_pattern: "](sub/old.md)".to_string(), - new_pattern: "](other/new.md)".to_string(), - }]; - - apply_replacements(&file_path, &replacements).unwrap(); - - let content = fs::read_to_string(&file_path).unwrap(); - assert!(content.contains("](other/new.md)")); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_apply_replacements_only_replaces_target_link() { - // Verify that when two identical links exist on the same line, - // only the one at the specified column is replaced. - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("doc.md"); - write_file(file_path.to_str().unwrap(), "[A](doc.md) and [B](doc.md)"); - - let replacements = vec![LinkReplacement { - line: 1, - column: 1, - old_pattern: "](doc.md)".to_string(), - new_pattern: "](new.md)".to_string(), - }]; - - apply_replacements(&file_path, &replacements).unwrap(); - - let content = fs::read_to_string(&file_path).unwrap(); - assert!( - content.contains("[A](new.md)"), - "Expected [A](new.md) in content: {}", - content - ); - assert!( - content.contains("[B](doc.md)"), - "Bug: [B](doc.md) was incorrectly modified. Content: {}", - content - ); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_apply_replacements_multiple_in_same_file() { - // Verify that multiple replacements in the same file are applied correctly - // in a single read-write cycle. - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("doc.md"); - write_file( - file_path.to_str().unwrap(), - "[Link1](old.md)\n\n[Link2](old.md)\n\n[Link3](old.md)", - ); - - let replacements = vec![ - LinkReplacement { - line: 1, - column: 1, - old_pattern: "](old.md)".to_string(), - new_pattern: "](new.md)".to_string(), - }, - LinkReplacement { - line: 3, - column: 1, - old_pattern: "](old.md)".to_string(), - new_pattern: "](new.md)".to_string(), - }, - LinkReplacement { - line: 5, - column: 1, - old_pattern: "](old.md)".to_string(), - new_pattern: "](new.md)".to_string(), - }, - ]; - - apply_replacements(&file_path, &replacements).unwrap(); - - let content = fs::read_to_string(&file_path).unwrap(); - assert!(!content.contains("](old.md)")); - assert_eq!(content.matches("](new.md)").count(), 3); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_apply_replacements_preserves_trailing_newline() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("doc.md"); - write_file(file_path.to_str().unwrap(), "[Link](old.md)\n"); - - let replacements = vec![LinkReplacement { - line: 1, - column: 1, - old_pattern: "](old.md)".to_string(), - new_pattern: "](new.md)".to_string(), - }]; - - apply_replacements(&file_path, &replacements).unwrap(); - - let content = fs::read_to_string(&file_path).unwrap(); - assert!(content.contains("](new.md)")); - assert!(content.ends_with('\n'), "Trailing newline was lost"); - } - - // ============= update via relative_path + apply_replacements tests ============= - - #[test] - #[allow(clippy::unwrap_used)] - fn test_update_reference_same_directory() { - let temp_dir = TempDir::new().unwrap(); - let ref_file = temp_dir.path().join("ref.md"); - let new_target = temp_dir.path().join("new_target.md"); - write_file(ref_file.to_str().unwrap(), "[Link](old_target.md)"); - write_file(new_target.to_str().unwrap(), ""); - - let new_link_path = relative_path(&ref_file, &new_target).unwrap(); - let replacements = vec![LinkReplacement { - line: 1, - column: 1, - old_pattern: "](old_target.md)".to_string(), - new_pattern: format!("]({})", new_link_path.display()), - }]; - - apply_replacements(&ref_file, &replacements).unwrap(); - - let content = fs::read_to_string(&ref_file).unwrap(); - assert!(content.contains("new_target.md")); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_update_reference_cross_directory() { - let temp_dir = TempDir::new().unwrap(); - let ref_file = temp_dir.path().join("ref.md"); - let new_target = temp_dir.path().join("sub").join("new_target.md"); - write_file(ref_file.to_str().unwrap(), "[Link](old.md)"); - write_file(new_target.to_str().unwrap(), ""); - - let new_link_path = relative_path(&ref_file, &new_target).unwrap(); - let replacements = vec![LinkReplacement { - line: 1, - column: 1, - old_pattern: "](old.md)".to_string(), - new_pattern: format!("]({})", new_link_path.display()), - }]; - - apply_replacements(&ref_file, &replacements).unwrap(); - - let content = fs::read_to_string(&ref_file).unwrap(); - assert!(content.contains("sub/new_target.md")); - } - - // ============= build_link_replacement with external URL ============= - - // ============= build_link_replacement with anchored internal links ============= - - #[test] - #[allow(clippy::unwrap_used)] - fn test_build_link_replacement_preserves_anchor() { - let temp_dir = tempfile::TempDir::new().unwrap(); - let source = temp_dir.path().join("source.md"); - let other = temp_dir.path().join("other.md"); - let target = temp_dir.path().join("sub").join("target.md"); - write_file(source.to_str().unwrap(), "[Details](other.md#details)"); - write_file(other.to_str().unwrap(), "# Other"); - write_file(target.to_str().unwrap(), ""); - - let reference = Reference::new(target.clone(), 1, 1, "other.md#details".to_string()); - - let mut line_cache = LineCache::new(); - let result = build_link_replacement(&reference, &source, &target, &mut line_cache).unwrap(); - assert!( - result.is_some(), - "Should produce a replacement for anchored link" - ); - - let replacement = result.unwrap(); - assert!( - replacement.new_pattern.contains("#details"), - "Anchor should be preserved in new pattern. Got: {}", - replacement.new_pattern - ); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_build_link_replacement_skips_broken_link() { - let temp_dir = tempfile::TempDir::new().unwrap(); - let source = temp_dir.path().join("source.md"); - let target = temp_dir.path().join("target.md"); - write_file(source.to_str().unwrap(), "[Broken](nonexistent.md)"); - write_file(target.to_str().unwrap(), ""); - - let reference = Reference::new(target.clone(), 1, 1, "nonexistent.md".to_string()); - - // Should return Ok(None) for broken links, not Err - let mut line_cache = LineCache::new(); - let result = build_link_replacement(&reference, &source, &target, &mut line_cache); - assert!( - result.is_ok(), - "build_link_replacement should not error on broken links: {:?}", - result.err() - ); - assert!( - result.unwrap().is_none(), - "Broken links should be skipped (return None)" - ); - } - - // ============= build_link_replacement with pure anchor links ============= - - #[test] - #[allow(clippy::unwrap_used)] - fn test_build_link_replacement_skips_pure_anchor_link() { - let temp_dir = TempDir::new().unwrap(); - let source = temp_dir.path().join("source.md"); - let target = temp_dir.path().join("sub").join("target.md"); - write_file(source.to_str().unwrap(), "[Section](#section)"); - write_file(target.to_str().unwrap(), ""); - - let reference = Reference::new(target.clone(), 1, 1, "#section".to_string()); - - // Pure anchor links are internal to the file and should not be rewritten - let mut line_cache = LineCache::new(); - let result = build_link_replacement(&reference, &source, &target, &mut line_cache).unwrap(); - assert!( - result.is_none(), - "Pure anchor link (#section) should be skipped, but got: {:?}", - result.map(|r| r.new_pattern) - ); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_build_link_replacement_skips_pure_anchor_with_complex_fragment() { - let temp_dir = TempDir::new().unwrap(); - let source = temp_dir.path().join("source.md"); - let target = temp_dir.path().join("target.md"); - write_file(source.to_str().unwrap(), "[TOC](#table-of-contents)"); - write_file(target.to_str().unwrap(), ""); - - let reference = Reference::new(target.clone(), 1, 1, "#table-of-contents".to_string()); - - let mut line_cache = LineCache::new(); - let result = build_link_replacement(&reference, &source, &target, &mut line_cache).unwrap(); - assert!( - result.is_none(), - "Pure anchor link (#table-of-contents) should be skipped" - ); - } - - // ============= build_link_replacement with external URL ============= - - #[test] - #[allow(clippy::unwrap_used)] - fn test_build_link_replacement_skips_external_url() { - let temp_dir = TempDir::new().unwrap(); - let source = temp_dir.path().join("source.md"); - let target = temp_dir.path().join("target.md"); - write_file(source.to_str().unwrap(), "[Google](https://google.com)"); - write_file(target.to_str().unwrap(), "[Google](https://google.com)"); - - let reference = Reference::new(target.clone(), 1, 1, "https://google.com".to_string()); - - // Should return None — external URL is skipped - let mut line_cache = LineCache::new(); - let result = build_link_replacement(&reference, &source, &target, &mut line_cache).unwrap(); - assert!(result.is_none()); - - // Content should remain unchanged - let content = fs::read_to_string(&target).unwrap(); - assert!(content.contains("https://google.com")); - } - - #[test] - fn test_find_reference_definition_url_span_preserves_angle_brackets_and_spacing() { - let line = "[ref]: \"Title\""; - let span = find_reference_definition_url_span(line).unwrap(); - - assert_eq!(&line[span.0..span.1], "target.md"); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_execute_with_rollback_returns_rollback_failed_when_snapshot_restore_fails() { - let temp_dir = TempDir::new().unwrap(); - let snapshot_file = temp_dir.path().join("refs").join("index.md"); - write_file(snapshot_file.to_str().unwrap(), "[Doc](source.md)"); - - let mut transaction = MoveTransaction::new( - temp_dir.path().join("source.md"), - temp_dir.path().join("moved").join("source.md"), - ); - transaction.snapshot_file(&snapshot_file).unwrap(); - - fs::remove_dir_all(snapshot_file.parent().unwrap()).unwrap(); - - let result = execute_with_rollback(&transaction, || { - Err(MdrefError::PathValidation { - path: PathBuf::from("simulated"), - details: "simulated move failure".to_string(), - }) - }); - - match result { - Err(MdrefError::RollbackFailed { - original_error, - rollback_errors, - }) => { - assert_eq!( - original_error, - "Path error for 'simulated': simulated move failure" - ); - assert_eq!(rollback_errors.len(), 1); - assert!(rollback_errors[0].contains("Failed to restore")); - assert!(rollback_errors[0].contains("index.md")); - } - other => panic!("expected rollback failed error, got {other:?}"), - } - } - - #[test] - fn test_try_rename_regular_file_with_requests_copy_fallback_for_cross_device_error() { - let temp_dir = TempDir::new().unwrap(); - let source = temp_dir.path().join("source.md"); - let destination = temp_dir.path().join("dest.md"); - write_file(source.to_str().unwrap(), "# Source"); - - let result = try_rename_regular_file_with(&source, &destination, |_, _| { - Err(std::io::Error::from(std::io::ErrorKind::CrossesDevices)) - }) - .unwrap(); - - assert_eq!(result, RegularFileMoveMethod::CopyAndDelete); - assert!(source.exists()); - assert!(!destination.exists()); - } - - #[test] - fn test_try_rename_regular_file_with_propagates_non_cross_device_error() { - let temp_dir = TempDir::new().unwrap(); - let source = temp_dir.path().join("source.md"); - let destination = temp_dir.path().join("dest.md"); - write_file(source.to_str().unwrap(), "# Source"); - - let result = try_rename_regular_file_with(&source, &destination, |_, _| { - Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) - }); - - assert!(matches!( - result, - Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied - )); - assert!(source.exists()); - assert!(!destination.exists()); - } -} diff --git a/src/core/mv/apply.rs b/src/core/mv/apply.rs new file mode 100644 index 0000000..239479f --- /dev/null +++ b/src/core/mv/apply.rs @@ -0,0 +1,193 @@ +//! Filesystem-mutating phase of a move. +//! +//! Everything in this module either writes files or orchestrates rollback. +//! Callers are expected to have already built a [`super::plan::ReplacementPlan`] +//! and a `MoveTransaction` snapshotting every file that will be touched. +//! +//! The module covers three sub-concerns: +//! +//! - rollback orchestration: [`execute_with_rollback`] +//! - regular-file rename with cross-device fallback: [`RegularFileMoveMethod`], +//! [`try_rename_regular_file`] (and the injectable variant used in tests) +//! - in-place file rewriting that preserves original line endings: +//! [`apply_replacements`] plus the `LineEnding` helpers + +use std::{fs, path::Path}; + +use crate::{ + MdrefError, Result, + core::model::{LinkReplacement, MoveTransaction}, +}; + +// ============= Rollback orchestration ============= + +/// Execute a fallible closure within a transaction context. +/// If the closure returns an error, the transaction is rolled back automatically. +pub(super) fn execute_with_rollback(transaction: &MoveTransaction, operation: F) -> Result<()> +where + F: FnOnce() -> Result<()>, +{ + match operation() { + Ok(()) => Ok(()), + Err(original_error) => { + let rollback_errors = transaction.rollback(); + if rollback_errors.is_empty() { + Err(original_error) + } else { + Err(MdrefError::RollbackFailed { + original_error: original_error.to_string(), + rollback_errors, + }) + } + } + } +} + +// ============= Regular-file rename with fallback ============= + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RegularFileMoveMethod { + Renamed, + CopyAndDelete, +} + +pub(super) fn try_rename_regular_file( + source: &Path, + dest: &Path, +) -> std::io::Result { + try_rename_regular_file_with(source, dest, |from, to| fs::rename(from, to)) +} + +pub(super) fn try_rename_regular_file_with( + source: &Path, + dest: &Path, + rename: F, +) -> std::io::Result +where + F: FnOnce(&Path, &Path) -> std::io::Result<()>, +{ + match rename(source, dest) { + Ok(()) => Ok(RegularFileMoveMethod::Renamed), + Err(error) if error.kind() == std::io::ErrorKind::CrossesDevices => { + Ok(RegularFileMoveMethod::CopyAndDelete) + } + Err(error) => Err(error), + } +} + +// ============= File rewriting with line-ending preservation ============= + +#[derive(Clone, Copy)] +enum LineEnding { + None, + Lf, + CrLf, +} + +impl LineEnding { + fn as_str(self) -> &'static str { + match self { + Self::None => "", + Self::Lf => "\n", + Self::CrLf => "\r\n", + } + } +} + +fn split_lines_preserving_endings(content: &str) -> Vec<(String, LineEnding)> { + let mut lines = Vec::new(); + let mut start = 0; + let bytes = content.as_bytes(); + let mut index = 0; + + while index < bytes.len() { + if bytes[index] == b'\n' { + let is_crlf = index > 0 && bytes[index - 1] == b'\r'; + let line_end = if is_crlf { index - 1 } else { index }; + let line = content[start..line_end].to_string(); + let ending = if is_crlf { + LineEnding::CrLf + } else { + LineEnding::Lf + }; + lines.push((line, ending)); + start = index + 1; + } + index += 1; + } + + if start < content.len() { + lines.push((content[start..].to_string(), LineEnding::None)); + } + + lines +} + +/// Apply all pending replacements to a single file in one read-write cycle. +/// Replacements are sorted in reverse order (by line desc, then column desc) so that +/// earlier replacements do not shift the positions of later ones. +pub(super) fn apply_replacements(file_path: &Path, replacements: &[LinkReplacement]) -> Result<()> { + let content = fs::read_to_string(file_path).map_err(|e| MdrefError::IoRead { + path: file_path.to_path_buf(), + source: e, + })?; + let mut lines = split_lines_preserving_endings(&content); + + // Sort replacements in reverse order (bottom-right to top-left) so that + // replacing one link does not invalidate the positions of subsequent ones. + let mut sorted_indices: Vec = (0..replacements.len()).collect(); + sorted_indices.sort_by(|&a, &b| { + replacements[b] + .line + .cmp(&replacements[a].line) + .then_with(|| replacements[b].column.cmp(&replacements[a].column)) + }); + + for &idx in &sorted_indices { + let replacement = &replacements[idx]; + + if replacement.line > lines.len() { + return Err(MdrefError::InvalidLineReference { + path: file_path.to_path_buf(), + line: replacement.line, + details: format!("line number out of range (file has {} lines)", lines.len()), + }); + } + + let line = &lines[replacement.line - 1].0; + let col = replacement.column.saturating_sub(1); // Convert to 0-based index + + // Search for the old_pattern starting from the column position. + // This ensures we replace the correct occurrence when multiple identical links exist. + if let Some(pos) = line[col..].find(&replacement.old_pattern) { + let actual_pos = col + pos; + let end_pos = actual_pos + replacement.old_pattern.len(); + let new_line = format!( + "{}{}{}", + &line[..actual_pos], + replacement.new_pattern, + &line[end_pos..] + ); + lines[replacement.line - 1].0 = new_line; + } else { + return Err(MdrefError::PathValidation { + path: file_path.to_path_buf(), + details: format!( + "could not find link '{}' in line {}", + replacement.old_pattern, replacement.line + ), + }); + } + } + + let new_content = lines + .into_iter() + .map(|(line, ending)| format!("{line}{}", ending.as_str())) + .collect::(); + fs::write(file_path, new_content).map_err(|e| MdrefError::IoWrite { + path: file_path.to_path_buf(), + source: e, + })?; + + Ok(()) +} diff --git a/src/core/mv/case_only.rs b/src/core/mv/case_only.rs new file mode 100644 index 0000000..6365962 --- /dev/null +++ b/src/core/mv/case_only.rs @@ -0,0 +1,118 @@ +//! Case-only rename support (e.g. `Foo.md` → `foo.md` on the same parent). +//! +//! On case-insensitive filesystems (macOS default, Windows) a case-only rename +//! cannot be detected by comparing canonicalized paths — both sides canonicalize +//! to the same inode. These helpers detect that situation explicitly and plan +//! link rewrites while preserving the new filename case. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use super::{ + plan::{LineCache, ReplacementPlan, build_replacement, split_link_and_anchor}, + validate::{canonicalize_destination, resolve_destination}, +}; +use crate::{MdrefError, Reference, Result, core::pathdiff::diff_paths}; + +/// Detect a case-only rename within the same parent directory. +/// +/// Returns `Some(resolved_dest)` when `source` and `destination` canonicalize to +/// the same inode but the filename differs only in ASCII case; otherwise `None`. +pub(super) fn resolve_case_only_destination( + source: &Path, + destination: &Path, +) -> Result> { + if !source.exists() { + return Ok(None); + } + + let source_canonical = source + .canonicalize() + .map_err(|e| MdrefError::PathValidation { + path: source.to_path_buf(), + details: format!("cannot canonicalize source path: {e}"), + })?; + let resolved_dest = resolve_destination(source, destination)?; + let dest_canonical = canonicalize_destination(&resolved_dest)?; + let same_parent = source.parent() == resolved_dest.parent(); + let case_only_name_change = source + .file_name() + .zip(resolved_dest.file_name()) + .map(|(source_name, dest_name)| { + let source_name = source_name.to_string_lossy(); + let dest_name = dest_name.to_string_lossy(); + source_name != dest_name && source_name.eq_ignore_ascii_case(&dest_name) + }) + .unwrap_or(false); + + if source_canonical == dest_canonical && same_parent && case_only_name_change { + Ok(Some(resolved_dest)) + } else { + Ok(None) + } +} + +/// Compute the relative path from `from` to `to` while preserving `to`'s +/// filename case. +/// +/// `Path::canonicalize` would collapse the filename case on case-insensitive +/// filesystems, which defeats the whole point of a case-only rename. +fn relative_path_preserving_filename_case(from: &Path, to: &Path) -> Result { + let from_parent = from.parent().ok_or_else(|| MdrefError::PathValidation { + path: from.to_path_buf(), + details: "no parent directory".to_string(), + })?; + let from_resolved = if from_parent.exists() { + from_parent.canonicalize()? + } else { + crate::core::util::resolve_parent(from_parent)? + }; + + let to_parent = to.parent().ok_or_else(|| MdrefError::PathValidation { + path: to.to_path_buf(), + details: "no parent directory".to_string(), + })?; + let to_parent_resolved = if to_parent.exists() { + to_parent.canonicalize()? + } else { + crate::core::util::resolve_parent(to_parent)? + }; + let filename = to.file_name().ok_or_else(|| MdrefError::PathValidation { + path: to.to_path_buf(), + details: "no file name".to_string(), + })?; + + Ok(diff_paths(to_parent_resolved.join(filename), from_resolved).unwrap_or_default()) +} + +/// Plan replacements for external references when the move is a case-only rename. +pub(super) fn plan_case_only_external_replacements( + references: &[Reference], + resolved_dest: &Path, +) -> Result { + let mut replacements_by_file: ReplacementPlan = HashMap::new(); + let mut line_cache = LineCache::new(); + + for reference in references { + let (_link_path_only, anchor) = split_link_and_anchor(&reference.link_text); + let new_link_path = relative_path_preserving_filename_case(&reference.path, resolved_dest)?; + + let new_link_with_anchor = match anchor { + Some(a) => format!("{}#{}", new_link_path.display(), a), + None => new_link_path.display().to_string(), + }; + + replacements_by_file + .entry(reference.path.clone()) + .or_default() + .push(build_replacement( + reference, + &new_link_with_anchor, + &mut line_cache, + )?); + } + + Ok(replacements_by_file) +} diff --git a/src/core/mv/mod.rs b/src/core/mv/mod.rs new file mode 100644 index 0000000..1272d6b --- /dev/null +++ b/src/core/mv/mod.rs @@ -0,0 +1,895 @@ +//! Move a Markdown file or directory and atomically update all references. +//! +//! The heavy lifting is split across responsibility-oriented submodules: +//! +//! - [`validate`]: path existence / collision / self-move checks +//! - [`case_only`]: detect and plan case-only renames on case-insensitive FSes +//! - [`plan`]: turn a source→destination pair into a per-file replacement plan +//! - [`apply`]: mutate the filesystem (rename / copy+delete, rewrite files, +//! execute under a rollback-protected transaction) +//! - [`preview`]: render dry-run reports and the structured [`MovePreview`] +//! +//! This top-level file keeps only the public API (`mv`, `preview_move`) and the +//! three orchestration routines for regular files, case-only renames, and +//! directory moves. + +mod apply; +mod case_only; +mod plan; +mod preview; +mod validate; + +use std::{collections::HashMap, fs, path::Path}; + +use self::{ + apply::{ + RegularFileMoveMethod, apply_replacements, execute_with_rollback, try_rename_regular_file, + }, + case_only::{plan_case_only_external_replacements, resolve_case_only_destination}, + plan::{ + add_destination_replacements, move_source_replacements_to_destination, + plan_directory_replacements, plan_external_replacements, plan_internal_replacements, + }, + preview::{build_move_preview, print_dry_run_report}, + validate::validate_move_paths, +}; +// Re-export the structured preview shape so callers can match on it. +pub use crate::core::model::MovePreview; +use crate::{ + Result, + core::{find::find_references, model::MoveTransaction, progress::ProgressReporter}, +}; + +// ============= Public API ============= + +/// Move a Markdown file or directory and atomically update all references across the project. +/// +/// This function finds all references to the source file and updates them to point to the +/// new location. It also updates links within the moved file itself to ensure they remain valid. +/// +/// **Atomicity guarantee**: all filesystem mutations are tracked in a transaction. If any step +/// fails, all changes are rolled back — modified files are restored to their original content, +/// the copied destination is removed, and the deleted source is recovered. +/// +/// When `dry_run` is `true`, no files are created, moved, or modified. Instead, the function +/// prints all changes that *would* be made, allowing the user to preview the operation. +/// +/// If the destination path is an existing directory, the source file will be moved into that +/// directory with its original filename preserved. +/// +/// # Progress +/// +/// Callers pass a [`ProgressReporter`] trait object to receive scanning progress. +/// Pass [`crate::NoopProgress`] (as `&NoopProgress`) when progress updates are not needed. +pub fn mv( + source: P, + dest: B, + root: D, + dry_run: bool, + progress: &dyn ProgressReporter, +) -> Result<()> +where + P: AsRef, + B: AsRef, + D: AsRef, +{ + let source = source.as_ref(); + let dest = dest.as_ref(); + let root = root.as_ref(); + + if source.is_dir() { + return mv_directory(source, dest, root, dry_run, progress); + } + + mv_regular_file(source, dest, root, dry_run, progress) +} + +/// Preview a Markdown move without mutating the filesystem. +/// +/// The returned preview contains the resolved destination path and all link +/// replacements that would be applied by the move. +/// +/// # Progress +/// +/// Callers pass a [`ProgressReporter`] trait object to receive scanning progress. +/// Pass [`crate::NoopProgress`] (as `&NoopProgress`) when progress updates are not needed. +pub fn preview_move( + source: P, + dest: B, + root: D, + progress: &dyn ProgressReporter, +) -> Result +where + P: AsRef, + B: AsRef, + D: AsRef, +{ + let source = source.as_ref(); + let dest = dest.as_ref(); + let root = root.as_ref(); + + if source.is_dir() { + return preview_directory_move(source, dest, root, progress); + } + + preview_regular_file_move(source, dest, root, progress) +} + +// ============= Orchestration: preview ============= + +fn preview_regular_file_move( + source: &Path, + dest: &Path, + root: &Path, + progress: &dyn ProgressReporter, +) -> Result { + if let Some(case_only_dest) = resolve_case_only_destination(source, dest)? { + return preview_case_only_file_move(source, &case_only_dest, root, progress); + } + + let (resolved_dest, _source_canonical, _dest_canonical) = + match validate_move_paths(source, dest) { + Ok(paths) => paths, + Err(e) => { + if e.to_string().contains("resolve to the same file") { + return Ok(build_move_preview(source, source, HashMap::new())); + } + return Err(e); + } + }; + + progress.set_message("Scanning references..."); + let references = find_references(source, root, progress)?; + let mut replacements_by_file = plan_external_replacements(&references, &resolved_dest)?; + replacements_by_file.remove(source); + let internal_replacements = plan_internal_replacements(source, source, &resolved_dest)?; + add_destination_replacements( + &mut replacements_by_file, + &resolved_dest, + internal_replacements, + ); + + Ok(build_move_preview( + source, + &resolved_dest, + replacements_by_file, + )) +} + +fn preview_case_only_file_move( + source: &Path, + resolved_dest: &Path, + root: &Path, + progress: &dyn ProgressReporter, +) -> Result { + progress.set_message("Scanning references..."); + let references = find_references(source, root, progress)?; + let mut replacements_by_file = + plan_case_only_external_replacements(&references, resolved_dest)?; + move_source_replacements_to_destination(&mut replacements_by_file, source, resolved_dest); + + Ok(build_move_preview( + source, + resolved_dest, + replacements_by_file, + )) +} + +fn preview_directory_move( + source_dir: &Path, + new_path: &Path, + root: &Path, + progress: &dyn ProgressReporter, +) -> Result { + let (resolved_dest, source_canonical, dest_canonical) = + match validate_move_paths(source_dir, new_path) { + Ok(paths) => paths, + Err(e) => { + if e.to_string().contains("resolve to the same file") { + return Ok(build_move_preview(source_dir, source_dir, HashMap::new())); + } + return Err(e); + } + }; + + let (replacements_by_file, _snapshot_paths) = plan_directory_replacements( + source_dir, + &source_canonical, + &dest_canonical, + root, + progress, + )?; + + Ok(build_move_preview( + source_dir, + &resolved_dest, + replacements_by_file, + )) +} + +// ============= Orchestration: mutating move ============= + +fn mv_regular_file( + source: &Path, + dest: &Path, + root: &Path, + dry_run: bool, + progress: &dyn ProgressReporter, +) -> Result<()> { + if let Some(case_only_dest) = resolve_case_only_destination(source, dest)? { + return mv_case_only_file(source, &case_only_dest, root, dry_run, progress); + } + + let (resolved_dest, _source_canonical, _dest_canonical) = + match validate_move_paths(source, dest) { + Ok(paths) => paths, + Err(e) => { + // Special case: source == destination is a no-op, not an error. + if e.to_string().contains("resolve to the same file") { + return Ok(()); + } + return Err(e); + } + }; + + // Phase 1: Plan — pure computation, no side effects. + progress.set_message("Scanning references..."); + let references = find_references(source, root, progress)?; + let mut replacements_by_file = plan_external_replacements(&references, &resolved_dest)?; + replacements_by_file.remove(source); + let internal_replacements = plan_internal_replacements(source, source, &resolved_dest)?; + + if dry_run { + add_destination_replacements( + &mut replacements_by_file, + &resolved_dest, + internal_replacements, + ); + let preview = build_move_preview(source, &resolved_dest, replacements_by_file); + print_dry_run_report(&preview); + return Ok(()); + } + + // Phase 2: Execute — all mutations are tracked for rollback. + let mut transaction = MoveTransaction::new(source.to_path_buf(), resolved_dest.clone()); + + // Snapshot all files that will be modified before touching anything. + for file_path in replacements_by_file.keys() { + transaction.snapshot_file(file_path)?; + } + + if !internal_replacements.is_empty() && !replacements_by_file.contains_key(source) { + transaction.snapshot_file(source)?; + } + + // Ensure the parent directory of the destination exists. + if let Some(parent) = resolved_dest.parent() { + fs::create_dir_all(parent)?; + } + + let move_method = try_rename_regular_file(source, &resolved_dest)?; + match move_method { + RegularFileMoveMethod::Renamed => { + transaction.mark_renamed(); + add_destination_replacements( + &mut replacements_by_file, + &resolved_dest, + internal_replacements, + ); + } + RegularFileMoveMethod::CopyAndDelete => { + fs::copy(source, &resolved_dest)?; + transaction.mark_copied(); + + if !internal_replacements.is_empty() { + transaction.snapshot_file(&resolved_dest)?; + add_destination_replacements( + &mut replacements_by_file, + &resolved_dest, + internal_replacements, + ); + } + } + } + + // Apply all replacements within a rollback-protected context. + execute_with_rollback(&transaction, || { + for (file_path, replacements) in &replacements_by_file { + apply_replacements(file_path, replacements)?; + } + Ok(()) + })?; + + if move_method == RegularFileMoveMethod::CopyAndDelete { + if let Err(original_error) = fs::remove_file(source) { + let rollback_errors = transaction.rollback(); + return if rollback_errors.is_empty() { + Err(original_error.into()) + } else { + Err(crate::MdrefError::RollbackFailed { + original_error: original_error.to_string(), + rollback_errors, + }) + }; + } + + transaction.mark_source_removed(); + } + + Ok(()) +} + +fn mv_case_only_file( + source: &Path, + resolved_dest: &Path, + root: &Path, + dry_run: bool, + progress: &dyn ProgressReporter, +) -> Result<()> { + progress.set_message("Scanning references..."); + let references = find_references(source, root, progress)?; + let mut replacements_by_file = + plan_case_only_external_replacements(&references, resolved_dest)?; + move_source_replacements_to_destination(&mut replacements_by_file, source, resolved_dest); + + if dry_run { + let preview = build_move_preview(source, resolved_dest, replacements_by_file); + print_dry_run_report(&preview); + return Ok(()); + } + + let mut transaction = MoveTransaction::new(source.to_path_buf(), resolved_dest.to_path_buf()); + if replacements_by_file.contains_key(resolved_dest) { + transaction.snapshot_file(source)?; + } + for file_path in replacements_by_file + .keys() + .filter(|path| *path != resolved_dest) + { + transaction.snapshot_file(file_path)?; + } + + fs::rename(source, resolved_dest)?; + transaction.mark_renamed(); + + execute_with_rollback(&transaction, || { + for (file_path, replacements) in &replacements_by_file { + apply_replacements(file_path, replacements)?; + } + Ok(()) + }) +} + +fn mv_directory( + source_dir: &Path, + new_path: &Path, + root: &Path, + dry_run: bool, + progress: &dyn ProgressReporter, +) -> Result<()> { + let (resolved_dest, source_canonical, dest_canonical) = + match validate_move_paths(source_dir, new_path) { + Ok(paths) => paths, + Err(e) => { + if e.to_string().contains("resolve to the same file") { + return Ok(()); + } + return Err(e); + } + }; + + let (replacements_by_file, snapshot_paths) = plan_directory_replacements( + source_dir, + &source_canonical, + &dest_canonical, + root, + progress, + )?; + + if dry_run { + let preview = build_move_preview(source_dir, &resolved_dest, replacements_by_file); + print_dry_run_report(&preview); + return Ok(()); + } + + let mut transaction = MoveTransaction::new(source_dir.to_path_buf(), resolved_dest.clone()); + for snapshot_path in snapshot_paths { + transaction.snapshot_file(&snapshot_path)?; + } + + if let Some(parent) = resolved_dest.parent() { + fs::create_dir_all(parent)?; + } + + fs::rename(source_dir, &resolved_dest)?; + transaction.mark_renamed(); + + execute_with_rollback(&transaction, || { + for (file_path, replacements) in &replacements_by_file { + apply_replacements(file_path, replacements)?; + } + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use tempfile::TempDir; + + use super::{ + apply::{ + RegularFileMoveMethod, apply_replacements, execute_with_rollback, + try_rename_regular_file_with, + }, + plan::{LineCache, build_link_replacement, find_reference_definition_url_span}, + *, + }; + use crate::{ + MdrefError, Reference, + core::{model::LinkReplacement, util::relative_path}, + test_utils::write_file, + }; + + // ============= apply_replacements tests ============= + + #[test] + #[allow(clippy::unwrap_used)] + fn test_apply_replacements_single_link_rewrites_target_path() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("doc.md"); + write_file(file_path.to_str().unwrap(), "[Link](old.md)"); + + let replacements = vec![LinkReplacement { + line: 1, + column: 1, + old_pattern: "](old.md)".to_string(), + new_pattern: "](new.md)".to_string(), + }]; + + apply_replacements(&file_path, &replacements).unwrap(); + + let content = fs::read_to_string(&file_path).unwrap(); + assert!(content.contains("](new.md)")); + assert!(!content.contains("](old.md)")); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn test_apply_replacements_preserves_other_content() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("doc.md"); + write_file( + file_path.to_str().unwrap(), + "# Title\n\nSome text [Link](old.md) more text.\n\nAnother paragraph.", + ); + + let replacements = vec![LinkReplacement { + line: 3, + column: 11, + old_pattern: "](old.md)".to_string(), + new_pattern: "](new.md)".to_string(), + }]; + + apply_replacements(&file_path, &replacements).unwrap(); + + let content = fs::read_to_string(&file_path).unwrap(); + assert!(content.contains("# Title")); + assert!(content.contains("Some text [Link](new.md) more text.")); + assert!(content.contains("Another paragraph.")); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn test_apply_replacements_crlf_input_preserves_crlf_line_endings() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("doc.md"); + write_file( + file_path.to_str().unwrap(), + "# Title\r\n\r\nSee [Link](old.md)\r\n", + ); + + let replacements = vec![LinkReplacement { + line: 3, + column: 5, + old_pattern: "](old.md)".to_string(), + new_pattern: "](new.md)".to_string(), + }]; + + apply_replacements(&file_path, &replacements).unwrap(); + + let content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(content, "# Title\r\n\r\nSee [Link](new.md)\r\n"); + assert!(!content.contains('\n') || content.contains("\r\n")); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn test_apply_replacements_line_out_of_range_returns_invalid_line_error() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("doc.md"); + write_file(file_path.to_str().unwrap(), "Single line"); + + let replacements = vec![LinkReplacement { + line: 999, + column: 1, + old_pattern: "](link.md)".to_string(), + new_pattern: "](new.md)".to_string(), + }]; + + let result = apply_replacements(&file_path, &replacements); + match result { + Err(MdrefError::InvalidLineReference { path, line, .. }) => { + assert_eq!(line, 999); + assert!(path.ends_with("doc.md")); + } + other => panic!("expected invalid line error, got {other:?}"), + } + } + + #[test] + #[allow(clippy::unwrap_used)] + fn test_apply_replacements_with_subdirectory_path() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("doc.md"); + write_file(file_path.to_str().unwrap(), "[Link](sub/old.md)"); + + let replacements = vec![LinkReplacement { + line: 1, + column: 1, + old_pattern: "](sub/old.md)".to_string(), + new_pattern: "](other/new.md)".to_string(), + }]; + + apply_replacements(&file_path, &replacements).unwrap(); + + let content = fs::read_to_string(&file_path).unwrap(); + assert!(content.contains("](other/new.md)")); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn test_apply_replacements_only_replaces_target_link() { + // Verify that when two identical links exist on the same line, + // only the one at the specified column is replaced. + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("doc.md"); + write_file(file_path.to_str().unwrap(), "[A](doc.md) and [B](doc.md)"); + + let replacements = vec![LinkReplacement { + line: 1, + column: 1, + old_pattern: "](doc.md)".to_string(), + new_pattern: "](new.md)".to_string(), + }]; + + apply_replacements(&file_path, &replacements).unwrap(); + + let content = fs::read_to_string(&file_path).unwrap(); + assert!( + content.contains("[A](new.md)"), + "Expected [A](new.md) in content: {}", + content + ); + assert!( + content.contains("[B](doc.md)"), + "Bug: [B](doc.md) was incorrectly modified. Content: {}", + content + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn test_apply_replacements_multiple_in_same_file() { + // Verify that multiple replacements in the same file are applied correctly + // in a single read-write cycle. + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("doc.md"); + write_file( + file_path.to_str().unwrap(), + "[Link1](old.md)\n\n[Link2](old.md)\n\n[Link3](old.md)", + ); + + let replacements = vec![ + LinkReplacement { + line: 1, + column: 1, + old_pattern: "](old.md)".to_string(), + new_pattern: "](new.md)".to_string(), + }, + LinkReplacement { + line: 3, + column: 1, + old_pattern: "](old.md)".to_string(), + new_pattern: "](new.md)".to_string(), + }, + LinkReplacement { + line: 5, + column: 1, + old_pattern: "](old.md)".to_string(), + new_pattern: "](new.md)".to_string(), + }, + ]; + + apply_replacements(&file_path, &replacements).unwrap(); + + let content = fs::read_to_string(&file_path).unwrap(); + assert!(!content.contains("](old.md)")); + assert_eq!(content.matches("](new.md)").count(), 3); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn test_apply_replacements_preserves_trailing_newline() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("doc.md"); + write_file(file_path.to_str().unwrap(), "[Link](old.md)\n"); + + let replacements = vec![LinkReplacement { + line: 1, + column: 1, + old_pattern: "](old.md)".to_string(), + new_pattern: "](new.md)".to_string(), + }]; + + apply_replacements(&file_path, &replacements).unwrap(); + + let content = fs::read_to_string(&file_path).unwrap(); + assert!(content.contains("](new.md)")); + assert!(content.ends_with('\n'), "Trailing newline was lost"); + } + + // ============= update via relative_path + apply_replacements tests ============= + + #[test] + #[allow(clippy::unwrap_used)] + fn test_update_reference_same_directory() { + let temp_dir = TempDir::new().unwrap(); + let ref_file = temp_dir.path().join("ref.md"); + let new_target = temp_dir.path().join("new_target.md"); + write_file(ref_file.to_str().unwrap(), "[Link](old_target.md)"); + write_file(new_target.to_str().unwrap(), ""); + + let new_link_path = relative_path(&ref_file, &new_target).unwrap(); + let replacements = vec![LinkReplacement { + line: 1, + column: 1, + old_pattern: "](old_target.md)".to_string(), + new_pattern: format!("]({})", new_link_path.display()), + }]; + + apply_replacements(&ref_file, &replacements).unwrap(); + + let content = fs::read_to_string(&ref_file).unwrap(); + assert!(content.contains("new_target.md")); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn test_update_reference_cross_directory() { + let temp_dir = TempDir::new().unwrap(); + let ref_file = temp_dir.path().join("ref.md"); + let new_target = temp_dir.path().join("sub").join("new_target.md"); + write_file(ref_file.to_str().unwrap(), "[Link](old.md)"); + write_file(new_target.to_str().unwrap(), ""); + + let new_link_path = relative_path(&ref_file, &new_target).unwrap(); + let replacements = vec![LinkReplacement { + line: 1, + column: 1, + old_pattern: "](old.md)".to_string(), + new_pattern: format!("]({})", new_link_path.display()), + }]; + + apply_replacements(&ref_file, &replacements).unwrap(); + + let content = fs::read_to_string(&ref_file).unwrap(); + assert!(content.contains("sub/new_target.md")); + } + + // ============= build_link_replacement with external URL ============= + + // ============= build_link_replacement with anchored internal links ============= + + #[test] + #[allow(clippy::unwrap_used)] + fn test_build_link_replacement_preserves_anchor() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let source = temp_dir.path().join("source.md"); + let other = temp_dir.path().join("other.md"); + let target = temp_dir.path().join("sub").join("target.md"); + write_file(source.to_str().unwrap(), "[Details](other.md#details)"); + write_file(other.to_str().unwrap(), "# Other"); + write_file(target.to_str().unwrap(), ""); + + let reference = Reference::new(target.clone(), 1, 1, "other.md#details".to_string()); + + let mut line_cache = LineCache::new(); + let result = build_link_replacement(&reference, &source, &target, &mut line_cache).unwrap(); + assert!( + result.is_some(), + "Should produce a replacement for anchored link" + ); + + let replacement = result.unwrap(); + assert!( + replacement.new_pattern.contains("#details"), + "Anchor should be preserved in new pattern. Got: {}", + replacement.new_pattern + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn test_build_link_replacement_skips_broken_link() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let source = temp_dir.path().join("source.md"); + let target = temp_dir.path().join("target.md"); + write_file(source.to_str().unwrap(), "[Broken](nonexistent.md)"); + write_file(target.to_str().unwrap(), ""); + + let reference = Reference::new(target.clone(), 1, 1, "nonexistent.md".to_string()); + + // Should return Ok(None) for broken links, not Err + let mut line_cache = LineCache::new(); + let result = build_link_replacement(&reference, &source, &target, &mut line_cache); + assert!( + result.is_ok(), + "build_link_replacement should not error on broken links: {:?}", + result.err() + ); + assert!( + result.unwrap().is_none(), + "Broken links should be skipped (return None)" + ); + } + + // ============= build_link_replacement with pure anchor links ============= + + #[test] + #[allow(clippy::unwrap_used)] + fn test_build_link_replacement_skips_pure_anchor_link() { + let temp_dir = TempDir::new().unwrap(); + let source = temp_dir.path().join("source.md"); + let target = temp_dir.path().join("sub").join("target.md"); + write_file(source.to_str().unwrap(), "[Section](#section)"); + write_file(target.to_str().unwrap(), ""); + + let reference = Reference::new(target.clone(), 1, 1, "#section".to_string()); + + // Pure anchor links are internal to the file and should not be rewritten + let mut line_cache = LineCache::new(); + let result = build_link_replacement(&reference, &source, &target, &mut line_cache).unwrap(); + assert!( + result.is_none(), + "Pure anchor link (#section) should be skipped, but got: {:?}", + result.map(|r| r.new_pattern) + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn test_build_link_replacement_skips_pure_anchor_with_complex_fragment() { + let temp_dir = TempDir::new().unwrap(); + let source = temp_dir.path().join("source.md"); + let target = temp_dir.path().join("target.md"); + write_file(source.to_str().unwrap(), "[TOC](#table-of-contents)"); + write_file(target.to_str().unwrap(), ""); + + let reference = Reference::new(target.clone(), 1, 1, "#table-of-contents".to_string()); + + let mut line_cache = LineCache::new(); + let result = build_link_replacement(&reference, &source, &target, &mut line_cache).unwrap(); + assert!( + result.is_none(), + "Pure anchor link (#table-of-contents) should be skipped" + ); + } + + // ============= build_link_replacement with external URL ============= + + #[test] + #[allow(clippy::unwrap_used)] + fn test_build_link_replacement_skips_external_url() { + let temp_dir = TempDir::new().unwrap(); + let source = temp_dir.path().join("source.md"); + let target = temp_dir.path().join("target.md"); + write_file(source.to_str().unwrap(), "[Google](https://google.com)"); + write_file(target.to_str().unwrap(), "[Google](https://google.com)"); + + let reference = Reference::new(target.clone(), 1, 1, "https://google.com".to_string()); + + // Should return None — external URL is skipped + let mut line_cache = LineCache::new(); + let result = build_link_replacement(&reference, &source, &target, &mut line_cache).unwrap(); + assert!(result.is_none()); + + // Content should remain unchanged + let content = fs::read_to_string(&target).unwrap(); + assert!(content.contains("https://google.com")); + } + + #[test] + fn test_find_reference_definition_url_span_preserves_angle_brackets_and_spacing() { + let line = "[ref]: \"Title\""; + let span = find_reference_definition_url_span(line).unwrap(); + + assert_eq!(&line[span.0..span.1], "target.md"); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn test_execute_with_rollback_returns_rollback_failed_when_snapshot_restore_fails() { + let temp_dir = TempDir::new().unwrap(); + let snapshot_file = temp_dir.path().join("refs").join("index.md"); + write_file(snapshot_file.to_str().unwrap(), "[Doc](source.md)"); + + let mut transaction = MoveTransaction::new( + temp_dir.path().join("source.md"), + temp_dir.path().join("moved").join("source.md"), + ); + transaction.snapshot_file(&snapshot_file).unwrap(); + + fs::remove_dir_all(snapshot_file.parent().unwrap()).unwrap(); + + let result = execute_with_rollback(&transaction, || { + Err(MdrefError::PathValidation { + path: PathBuf::from("simulated"), + details: "simulated move failure".to_string(), + }) + }); + + match result { + Err(MdrefError::RollbackFailed { + original_error, + rollback_errors, + }) => { + assert_eq!( + original_error, + "Path error for 'simulated': simulated move failure" + ); + assert_eq!(rollback_errors.len(), 1); + assert!(rollback_errors[0].contains("Failed to restore")); + assert!(rollback_errors[0].contains("index.md")); + } + other => panic!("expected rollback failed error, got {other:?}"), + } + } + + #[test] + fn test_try_rename_regular_file_with_requests_copy_fallback_for_cross_device_error() { + let temp_dir = TempDir::new().unwrap(); + let source = temp_dir.path().join("source.md"); + let destination = temp_dir.path().join("dest.md"); + write_file(source.to_str().unwrap(), "# Source"); + + let result = try_rename_regular_file_with(&source, &destination, |_, _| { + Err(std::io::Error::from(std::io::ErrorKind::CrossesDevices)) + }) + .unwrap(); + + assert_eq!(result, RegularFileMoveMethod::CopyAndDelete); + assert!(source.exists()); + assert!(!destination.exists()); + } + + #[test] + fn test_try_rename_regular_file_with_propagates_non_cross_device_error() { + let temp_dir = TempDir::new().unwrap(); + let source = temp_dir.path().join("source.md"); + let destination = temp_dir.path().join("dest.md"); + write_file(source.to_str().unwrap(), "# Source"); + + let result = try_rename_regular_file_with(&source, &destination, |_, _| { + Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) + }); + + assert!(matches!( + result, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied + )); + assert!(source.exists()); + assert!(!destination.exists()); + } +} diff --git a/src/core/mv/plan.rs b/src/core/mv/plan.rs new file mode 100644 index 0000000..26d2c8d --- /dev/null +++ b/src/core/mv/plan.rs @@ -0,0 +1,535 @@ +//! Replacement planning: turn a source→destination move into a concrete set of +//! per-file link rewrites, without touching the filesystem. +//! +//! This is the "pure computation" phase. Its inputs are the list of `Reference`s +//! discovered by `find_references` plus the source/destination paths; its output +//! is a [`ReplacementPlan`] that the apply phase executes. +//! +//! The module is internally organized into three sub-groups: +//! +//! - top-level planners: `plan_external_replacements`, `plan_internal_replacements`, +//! `plan_directory_replacements` +//! - per-reference construction: `build_link_replacement`, `build_replacement`, +//! `build_reference_definition_replacement`, `split_link_and_anchor` +//! - `ReplacementPlan` bookkeeping: `extend_unique_replacements`, +//! `move_source_replacements_to_destination`, `add_destination_replacements` + +use std::{ + collections::{HashMap, HashSet}, + fs, + path::{Path, PathBuf}, +}; + +use walkdir::WalkDir; + +use crate::{ + LinkType, MdrefError, Reference, Result, + core::{ + find::find_references, + model::LinkReplacement, + progress::ProgressReporter, + util::{ + collect_markdown_files, is_external_url, relative_path, strip_utf8_bom_prefix, + url_decode_link, + }, + }, + find_links, +}; + +pub(super) type ReplacementPlan = HashMap>; +pub(super) type SnapshotPaths = Vec; +pub(super) type LineCache = HashMap>; + +// ============= Top-level planners ============= + +/// Collect all link replacements needed for external references (other files pointing to the moved file). +pub(super) fn plan_external_replacements( + references: &[Reference], + resolved_dest: &Path, +) -> Result { + let mut replacements_by_file: ReplacementPlan = HashMap::new(); + let mut line_cache = LineCache::new(); + + for reference in references { + let (_link_path_only, anchor) = split_link_and_anchor(&reference.link_text); + let new_link_path = relative_path(&reference.path, resolved_dest)?; + + let new_link_with_anchor = match anchor { + Some(a) => format!("{}#{}", new_link_path.display(), a), + None => new_link_path.display().to_string(), + }; + + replacements_by_file + .entry(reference.path.clone()) + .or_default() + .push(build_replacement( + reference, + &new_link_with_anchor, + &mut line_cache, + )?); + } + + Ok(replacements_by_file) +} + +/// Collect all link replacements needed for internal links within the moved file itself. +pub(super) fn plan_internal_replacements( + scan_path: &Path, + source: &Path, + resolved_dest: &Path, +) -> Result> { + let links = find_links(scan_path)?; + let mut replacements = Vec::new(); + let mut line_cache = LineCache::new(); + + for link in &links { + if let Some(replacement) = + build_link_replacement(link, source, resolved_dest, &mut line_cache)? + { + replacements.push(replacement); + } + } + + Ok(replacements) +} + +/// Plan replacements for a whole-directory move. +/// +/// Returns `(plan, snapshot_paths)`: the plan is keyed by the files' +/// **post-move** paths, while `snapshot_paths` lists the **pre-move** paths +/// that need to be snapshotted for rollback. +pub(super) fn plan_directory_replacements( + source_dir: &Path, + source_canonical: &Path, + dest_canonical: &Path, + root: &Path, + progress: &dyn ProgressReporter, +) -> Result<(ReplacementPlan, SnapshotPaths)> { + let path_mappings = + build_directory_path_mappings(source_dir, source_canonical, dest_canonical)?; + let mut replacements_by_file: ReplacementPlan = HashMap::new(); + let mut snapshot_paths: HashSet = HashSet::new(); + let mut line_cache = LineCache::new(); + + progress.set_message("Scanning references..."); + for reference in find_references(source_dir, root, progress)? { + let (link_path_only, _) = split_link_and_anchor(&reference.link_text); + let Some(old_target) = resolve_reference_target(&reference.path, link_path_only) else { + continue; + }; + let Some(new_target) = path_mappings.get(&old_target) else { + continue; + }; + + let file_after_move = + remap_existing_path(&reference.path, source_canonical, &path_mappings)?; + let replacement = build_replacement_for_target( + &reference, + &file_after_move, + new_target, + &mut line_cache, + )?; + + replacements_by_file + .entry(file_after_move) + .or_default() + .push(replacement); + snapshot_paths.insert(reference.path); + } + + for markdown_file in collect_markdown_files(source_dir) { + let file_after_move = + remap_existing_path(&markdown_file, source_canonical, &path_mappings)?; + let links = find_links(&markdown_file)?; + + for link in links { + let (link_path_only, _) = split_link_and_anchor(&link.link_text); + let Some(target_path) = resolve_reference_target(&markdown_file, link_path_only) else { + continue; + }; + + if target_path.starts_with(source_canonical) { + continue; + } + + let replacement = build_replacement_for_target( + &link, + &file_after_move, + &target_path, + &mut line_cache, + )?; + replacements_by_file + .entry(file_after_move.clone()) + .or_default() + .push(replacement); + snapshot_paths.insert(markdown_file.clone()); + } + } + + Ok((replacements_by_file, snapshot_paths.into_iter().collect())) +} + +// ============= Directory-move internals ============= + +fn build_directory_path_mappings( + source_dir: &Path, + source_canonical: &Path, + dest_canonical: &Path, +) -> Result> { + let mut mappings = HashMap::new(); + mappings.insert(source_canonical.to_path_buf(), dest_canonical.to_path_buf()); + + for entry in WalkDir::new(source_dir) + .sort_by_file_name() + .into_iter() + .filter_map(|entry| entry.ok()) + .skip(1) + { + let relative = + entry + .path() + .strip_prefix(source_dir) + .map_err(|e| MdrefError::PathValidation { + path: entry.path().to_path_buf(), + details: format!( + "cannot compute relative path under '{}': {e}", + source_dir.display() + ), + })?; + let old_path = entry + .path() + .canonicalize() + .map_err(|e| MdrefError::PathValidation { + path: entry.path().to_path_buf(), + details: format!("cannot canonicalize directory entry: {e}"), + })?; + mappings.insert(old_path, dest_canonical.join(relative)); + } + + Ok(mappings) +} + +fn resolve_reference_target(base_file: &Path, link_path_only: &str) -> Option { + if link_path_only.is_empty() { + return None; + } + + let decoded_link = url_decode_link(link_path_only); + let decoded_path = Path::new(&decoded_link); + let resolved = if decoded_path.is_absolute() { + decoded_path.to_path_buf() + } else { + base_file.parent()?.join(decoded_path) + }; + + resolved.canonicalize().ok() +} + +fn remap_existing_path( + path: &Path, + source_canonical: &Path, + path_mappings: &HashMap, +) -> Result { + let canonical = path + .canonicalize() + .map_err(|e| MdrefError::PathValidation { + path: path.to_path_buf(), + details: format!("cannot canonicalize path: {e}"), + })?; + + if canonical.starts_with(source_canonical) { + path_mappings + .get(&canonical) + .cloned() + .ok_or_else(|| MdrefError::PathValidation { + path: path.to_path_buf(), + details: "cannot map moved path to its destination".to_string(), + }) + } else { + Ok(path.to_path_buf()) + } +} + +fn build_replacement_for_target( + reference: &Reference, + file_after_move: &Path, + new_target: &Path, + line_cache: &mut LineCache, +) -> Result { + let (_link_path_only, anchor) = split_link_and_anchor(&reference.link_text); + let new_link_path = relative_path(file_after_move, new_target)?; + + let new_link_with_anchor = match anchor { + Some(anchor) => format!("{}#{}", new_link_path.display(), anchor), + None => new_link_path.display().to_string(), + }; + + build_replacement(reference, &new_link_with_anchor, line_cache) +} + +// ============= Per-reference replacement construction ============= + +/// Split a link into the path part and the anchor (fragment) part. +/// Returns (path, Some(anchor)) if there's an anchor, or (path, None) if not. +/// Examples: +/// "file.md#section" -> ("file.md", Some("section")) +/// "file.md" -> ("file.md", None) +/// "#section" -> ("", Some("section")) (pure anchor link) +pub(super) fn split_link_and_anchor(link: &str) -> (&str, Option<&str>) { + match link.find('#') { + Some(pos) => { + let (path, anchor) = link.split_at(pos); + // Remove the '#' prefix from anchor + (path, Some(&anchor[1..])) + } + None => (link, None), + } +} + +/// Build a LinkReplacement for an internal link in the moved file. +/// Returns `None` if the link is an external URL or a broken link that cannot be resolved. +pub(super) fn build_link_replacement( + r: &Reference, + raw_filepath: &Path, + new_filepath: &Path, + line_cache: &mut LineCache, +) -> Result> { + // External URLs (https://, http://, etc.) are not local file paths + // and should not be rewritten during a file move. + if is_external_url(&r.link_text) { + return Ok(None); + } + + // Strip anchor from link text so canonicalize works on the file path only. + let (link_path_only, anchor) = split_link_and_anchor(&r.link_text); + + // Pure anchor links (e.g. "#section") are internal to the document + // and should not be rewritten during a file move. + if link_path_only.is_empty() { + return Ok(None); + } + + let parent = raw_filepath + .parent() + .ok_or_else(|| MdrefError::PathValidation { + path: raw_filepath.to_path_buf(), + details: "no parent directory".to_string(), + })?; + + // Resolve the link path; skip broken links that cannot be canonicalized. + let current_link_absolute_path = match parent.join(link_path_only).canonicalize() { + Ok(p) => p, + Err(_) => return Ok(None), + }; + let new_file_absolute_path = if new_filepath.exists() { + new_filepath.canonicalize()? + } else { + let parent = new_filepath + .parent() + .ok_or_else(|| MdrefError::PathValidation { + path: new_filepath.to_path_buf(), + details: "no parent directory".to_string(), + })?; + let parent_canonical = if parent.exists() { + parent.canonicalize()? + } else { + parent.to_path_buf() + }; + let filename = new_filepath + .file_name() + .ok_or_else(|| MdrefError::PathValidation { + path: new_filepath.to_path_buf(), + details: "no file name".to_string(), + })?; + parent_canonical.join(filename) + }; + let raw_file_canonical = raw_filepath.canonicalize()?; + + let new_link_path = if current_link_absolute_path == raw_file_canonical { + PathBuf::from(new_file_absolute_path.file_name().ok_or_else(|| { + MdrefError::PathValidation { + path: new_file_absolute_path.clone(), + details: "no file name".to_string(), + } + })?) + } else { + relative_path(&new_file_absolute_path, ¤t_link_absolute_path)? + }; + + // Reconstruct the new pattern with anchor preserved. + let new_link_with_anchor = match anchor { + Some(a) => format!("{}#{}", new_link_path.display(), a), + None => new_link_path.display().to_string(), + }; + + Ok(Some(build_replacement( + r, + &new_link_with_anchor, + line_cache, + )?)) +} + +pub(super) fn build_replacement( + reference: &Reference, + new_url: &str, + line_cache: &mut LineCache, +) -> Result { + match reference.link_type { + LinkType::Inline => Ok(LinkReplacement { + line: reference.line, + column: reference.column, + old_pattern: format!("]({})", reference.link_text), + new_pattern: format!("]({})", new_url), + }), + LinkType::ReferenceDefinition => { + build_reference_definition_replacement(reference, new_url, line_cache) + } + } +} + +fn build_reference_definition_replacement( + reference: &Reference, + new_url: &str, + line_cache: &mut LineCache, +) -> Result { + let line = get_cached_line(&reference.path, reference.line, line_cache)?; + let (url_start, url_end) = + find_reference_definition_url_span(line).ok_or_else(|| MdrefError::PathValidation { + path: reference.path.clone(), + details: format!( + "could not parse reference definition in line {}", + reference.line + ), + })?; + + Ok(LinkReplacement { + line: reference.line, + column: url_start + 1, + old_pattern: line[url_start..url_end].to_string(), + new_pattern: new_url.to_string(), + }) +} + +fn get_cached_line<'a>( + path: &Path, + line_number: usize, + line_cache: &'a mut LineCache, +) -> Result<&'a str> { + let lines = match line_cache.entry(path.to_path_buf()) { + std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::hash_map::Entry::Vacant(entry) => { + let content = fs::read_to_string(path).map_err(|e| MdrefError::IoRead { + path: path.to_path_buf(), + source: e, + })?; + entry.insert(content.lines().map(|line| line.to_string()).collect()) + } + }; + + if line_number == 0 || line_number > lines.len() { + return Err(MdrefError::InvalidLineReference { + path: path.to_path_buf(), + line: line_number, + details: format!("line number out of range (file has {} lines)", lines.len()), + }); + } + + Ok(lines[line_number - 1].as_str()) +} + +/// Locate the URL span inside a Markdown reference definition line. +/// +/// Returns `(url_start, url_end)` as byte offsets into `line`, or `None` if the +/// line is not a valid reference definition (more than 3 leading spaces, no +/// `[label]:` prefix, empty URL, etc.). Angle-bracket-wrapped URLs have the +/// brackets excluded from the span. +pub(super) fn find_reference_definition_url_span(line: &str) -> Option<(usize, usize)> { + let (line_without_bom, bom_offset) = strip_utf8_bom_prefix(line); + let trimmed = line_without_bom.trim_start(); + let leading_spaces = line_without_bom.len() - trimmed.len(); + if leading_spaces > 3 || !trimmed.starts_with('[') { + return None; + } + + let label_end = trimmed.find("]:")?; + if label_end == 0 { + return None; + } + + let after_colon_start = bom_offset + leading_spaces + label_end + 2; + let after_colon = &line[after_colon_start..]; + let trimmed_after_colon = after_colon.trim_start(); + if trimmed_after_colon.is_empty() { + return None; + } + + let leading_after_colon = after_colon.len() - trimmed_after_colon.len(); + let url_start = after_colon_start + leading_after_colon; + + if let Some(stripped) = trimmed_after_colon.strip_prefix('<') { + let end = stripped.find('>')?; + let inner_start = url_start + 1; + let inner_end = inner_start + end; + Some((inner_start, inner_end)) + } else { + let end = trimmed_after_colon + .find(char::is_whitespace) + .unwrap_or(trimmed_after_colon.len()); + Some((url_start, url_start + end)) + } +} + +// ============= ReplacementPlan bookkeeping ============= + +fn extend_unique_replacements( + destination_replacements: &mut Vec, + replacements: Vec, +) { + for replacement in replacements { + let already_present = destination_replacements.iter().any(|existing| { + existing.line == replacement.line + && existing.column == replacement.column + && existing.old_pattern == replacement.old_pattern + && existing.new_pattern == replacement.new_pattern + }); + + if !already_present { + destination_replacements.push(replacement); + } + } +} + +/// Move all replacements keyed on `source` onto the `destination` bucket. +/// +/// Used during case-only renames, where references originally keyed by the +/// pre-rename path must follow the file to its new (post-rename) path so the +/// apply phase only writes to the moved file once. +pub(super) fn move_source_replacements_to_destination( + replacements_by_file: &mut ReplacementPlan, + source: &Path, + destination: &Path, +) { + 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); + } +} + +/// Append replacements for the moved file itself (its internal links) onto the +/// destination bucket, deduplicating against anything already planned there. +pub(super) fn add_destination_replacements( + replacements_by_file: &mut ReplacementPlan, + destination: &Path, + replacements: Vec, +) { + if replacements.is_empty() { + return; + } + + let destination_replacements = replacements_by_file + .entry(destination.to_path_buf()) + .or_default(); + extend_unique_replacements(destination_replacements, replacements); +} diff --git a/src/core/mv/preview.rs b/src/core/mv/preview.rs new file mode 100644 index 0000000..d588983 --- /dev/null +++ b/src/core/mv/preview.rs @@ -0,0 +1,80 @@ +//! Preview / dry-run rendering. +//! +//! Pure transformations from a [`ReplacementPlan`] into user-facing shapes: +//! +//! - [`build_move_preview`]: assemble the structured [`MovePreview`] returned by +//! the public `preview_move` API. +//! - [`print_dry_run_report`]: human-readable stdout report used when the CLI +//! runs with `--dry-run`. + +use std::path::Path; + +use super::plan::ReplacementPlan; +use crate::core::model::{MoveChange, MoveChangeKind, MovePreview}; + +pub(super) fn build_move_preview( + source: &Path, + destination: &Path, + replacements_by_file: ReplacementPlan, +) -> MovePreview { + let mut changes = replacements_by_file + .into_iter() + .map(|(path, mut replacements)| { + replacements.sort_by(|left, right| { + left.line + .cmp(&right.line) + .then(left.column.cmp(&right.column)) + .then(left.old_pattern.cmp(&right.old_pattern)) + .then(left.new_pattern.cmp(&right.new_pattern)) + }); + + let kind = if path == destination { + MoveChangeKind::MovedFileUpdate + } else { + MoveChangeKind::ReferenceUpdate + }; + + MoveChange { + path, + kind, + replacements, + } + }) + .collect::>(); + + changes.sort_by(|left, right| left.path.cmp(&right.path)); + + MovePreview { + source: source.to_path_buf(), + destination: destination.to_path_buf(), + changes, + } +} + +/// Print a human-readable report of all changes that would be made during a move operation. +pub(super) fn print_dry_run_report(preview: &MovePreview) { + println!( + "[dry-run] Would move: {} -> {}", + preview.source.display(), + preview.destination.display() + ); + + if preview.changes.is_empty() { + println!("[dry-run] No references to update."); + return; + } + + for change in &preview.changes { + let label = match change.kind { + MoveChangeKind::MovedFileUpdate => "Would update links in moved file", + MoveChangeKind::ReferenceUpdate => "Would update reference in", + }; + println!("[dry-run] {} {}:", label, change.path.display()); + for replacement in &change.replacements { + println!( + " Line {}: {} -> {}", + replacement.line, replacement.old_pattern, replacement.new_pattern + ); + } + } +} diff --git a/src/core/mv/validate.rs b/src/core/mv/validate.rs new file mode 100644 index 0000000..9450bc5 --- /dev/null +++ b/src/core/mv/validate.rs @@ -0,0 +1,110 @@ +//! Path resolution and validation for move operations. +//! +//! These helpers normalize destination paths (including the "move into existing +//! directory" case) and reject invalid moves early, before any planning or +//! filesystem mutation happens. + +use std::path::{Path, PathBuf}; + +use crate::{MdrefError, Result}; + +/// Resolve the destination path, handling the case where the destination is an existing directory. +pub(super) fn resolve_destination(source: &Path, destination: &Path) -> Result { + if destination.is_dir() { + let filename = source + .file_name() + .ok_or_else(|| MdrefError::PathValidation { + path: source.to_path_buf(), + details: "source path has no filename".to_string(), + })?; + Ok(destination.join(filename)) + } else { + Ok(destination.to_path_buf()) + } +} + +/// Canonicalize a destination path, handling the case where it doesn't exist yet. +pub(super) fn canonicalize_destination(destination: &Path) -> Result { + if destination.exists() { + return destination + .canonicalize() + .map_err(|e| MdrefError::PathValidation { + path: destination.to_path_buf(), + details: format!("cannot canonicalize destination path: {e}"), + }); + } + + let parent = destination + .parent() + .ok_or_else(|| MdrefError::PathValidation { + path: destination.to_path_buf(), + details: "destination path has no parent directory".to_string(), + })?; + + let parent_canonical = if parent.exists() { + parent + .canonicalize() + .map_err(|e| MdrefError::PathValidation { + path: parent.to_path_buf(), + details: format!("cannot canonicalize parent directory: {e}"), + })? + } else { + parent.to_path_buf() + }; + + let filename = destination + .file_name() + .ok_or_else(|| MdrefError::PathValidation { + path: destination.to_path_buf(), + details: "destination path has no filename".to_string(), + })?; + + Ok(parent_canonical.join(filename)) +} + +/// Validate that the move operation is valid: source exists, destination doesn't collide, etc. +/// Returns `(resolved_dest, source_canonical, dest_canonical)`. +pub(super) fn validate_move_paths( + source: &Path, + destination: &Path, +) -> Result<(PathBuf, PathBuf, PathBuf)> { + if !source.exists() { + return Err(MdrefError::PathValidation { + path: source.to_path_buf(), + details: "source path does not exist".to_string(), + }); + } + + let source_canonical = source + .canonicalize() + .map_err(|e| MdrefError::PathValidation { + path: source.to_path_buf(), + details: format!("cannot canonicalize source path: {e}"), + })?; + + let resolved_dest = resolve_destination(source, destination)?; + let dest_canonical = canonicalize_destination(&resolved_dest)?; + + if source_canonical == dest_canonical { + return Err(MdrefError::PathValidation { + path: source.to_path_buf(), + details: "source and destination resolve to the same file".to_string(), + }); + } + + if resolved_dest.exists() { + return Err(MdrefError::PathValidation { + path: resolved_dest.clone(), + details: "destination path already exists".to_string(), + }); + } + + if source_canonical.is_dir() && dest_canonical.starts_with(&source_canonical) { + return Err(MdrefError::PathValidation { + path: source.to_path_buf(), + details: "cannot move directory into itself or one of its subdirectories".to_string(), + }); + } + + Ok((resolved_dest, source_canonical, dest_canonical)) +}