#![allow(clippy::missing_errors_doc)]
use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use serde::Serialize;
use crate::commands::mutation;
use crate::output::{CommandOutcome, Format};
use hyalo_core::discovery;
use hyalo_core::index::SnapshotIndex;
use hyalo_core::link_rewrite::{self, Replacement, RewritePlan};
#[derive(Serialize)]
struct MvResult {
from: String,
to: String,
dry_run: bool,
updated_files: Vec<UpdatedFile>,
total_files_updated: usize,
total_links_updated: usize,
}
#[derive(Serialize)]
struct UpdatedFile {
file: String,
replacements: Vec<Replacement>,
}
#[allow(clippy::too_many_arguments)]
pub fn mv(
dir: &Path,
file_arg: &str,
to_arg: &str,
dry_run: bool,
format: Format,
site_prefix: Option<&str>,
snapshot_index: &mut Option<SnapshotIndex>,
index_path: Option<&Path>,
) -> Result<CommandOutcome> {
let (_src_full, old_rel) = match discovery::resolve_file(dir, file_arg) {
Ok(r) => r,
Err(e) => return Ok(crate::commands::resolve_error_to_outcome(e, format)),
};
let new_rel = match validate_target(dir, to_arg, &old_rel, format) {
Ok(rel) => rel,
Err(outcome) => return Ok(outcome),
};
let plans = link_rewrite::plan_mv(dir, &old_rel, &new_rel, site_prefix)?;
let updated_files: Vec<UpdatedFile> = plans
.iter()
.map(|p| UpdatedFile {
file: p.rel_path.clone(),
replacements: p.replacements.clone(),
})
.collect();
let total_links: usize = updated_files.iter().map(|f| f.replacements.len()).sum();
let result = MvResult {
from: old_rel.clone(),
to: new_rel.clone(),
dry_run,
updated_files,
total_files_updated: plans.len(),
total_links_updated: total_links,
};
if !dry_run {
execute_mv(dir, &old_rel, &new_rel, &plans)?;
let rewritten: Vec<&str> = plans.iter().map(|p| p.rel_path.as_str()).collect();
let mut index_dirty = false;
mutation::rename_index_entry(
snapshot_index,
dir,
&old_rel,
&new_rel,
&rewritten,
&mut index_dirty,
)?;
mutation::save_index_if_dirty(snapshot_index, index_path, index_dirty)?;
}
Ok(CommandOutcome::success(
serde_json::to_string_pretty(&result).context("failed to serialize")?,
))
}
fn validate_target(
dir: &Path,
to_arg: &str,
src_rel: &str,
format: Format,
) -> std::result::Result<String, CommandOutcome> {
let normalized = to_arg.replace('\\', "/");
let normalized = normalized
.strip_prefix("./")
.unwrap_or(&normalized)
.to_owned();
#[allow(clippy::case_sensitive_file_extension_comparisons)]
if !normalized.ends_with(".md") {
let out = crate::output::format_error(
format,
"target path must end with .md",
Some(&normalized),
Some(&format!("did you mean {normalized}.md?")),
None,
);
return Err(CommandOutcome::UserError(out));
}
let has_traversal = std::path::Path::new(&normalized).components().any(|c| {
matches!(
c,
std::path::Component::ParentDir | std::path::Component::RootDir
)
}) || std::path::Path::new(&normalized).is_absolute();
if has_traversal {
let out = crate::output::format_error(
format,
"target path must be relative and within the vault",
Some(&normalized),
None,
None,
);
return Err(CommandOutcome::UserError(out));
}
if normalized == src_rel {
let out = crate::output::format_error(
format,
"source and destination are the same path",
Some(&normalized),
Some("choose a different destination path"),
None,
);
return Err(CommandOutcome::UserError(out));
}
let target_path = dir.join(&normalized);
if target_path.exists() {
let out = crate::output::format_error(
format,
"target file already exists",
Some(&normalized),
None,
None,
);
return Err(CommandOutcome::UserError(out));
}
Ok(normalized)
}
fn execute_mv(dir: &Path, old_rel: &str, new_rel: &str, plans: &[RewritePlan]) -> Result<()> {
let src = dir.join(old_rel);
let dst = dir.join(new_rel);
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
fs::rename(&src, &dst)
.with_context(|| format!("failed to move {} to {}", src.display(), dst.display()))?;
link_rewrite::execute_plans(dir, plans)?;
Ok(())
}