use std::path::{Path, PathBuf};
use crate::cli::RenameArgs;
use crate::cmd::write::{
core_err, enforce_frozen, index_on_rename, index_on_write, open_store, policy_frozen_error,
to_store_relative,
};
use crate::context::Context;
use crate::error::{CliError, CliResult, ExitCode};
pub fn run(ctx: &Context, args: &RenameArgs) -> CliResult {
let store = open_store(&args.dir)?;
let old_rel = to_store_relative(&store, &args.old);
let new_rel = to_store_relative(&store, &args.new);
let old_abs = store.abs_path(&old_rel);
let new_abs = store.abs_path(&new_rel);
if !old_abs.exists() {
return Err(missing_old_error(&old_rel));
}
if new_abs.exists() {
return Err(dest_exists_error(&new_rel));
}
enforce_frozen(&store, &old_rel)?;
if let Some(frozen) = store.config.frozen_match(&new_rel) {
return Err(policy_frozen_error(&frozen));
}
let linkers = store.find_links_to(&old_rel).map_err(core_err)?;
if let Some(parent) = new_abs.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| CliError::runtime(format!("cannot create destination folder: {e}")))?;
}
std::fs::rename(&old_abs, &new_abs)
.map_err(|e| CliError::runtime(format!("cannot move file: {e}")))?;
let mut rewritten = 0usize;
let mut rewritten_linkers: Vec<PathBuf> = Vec::new();
for linker_rel in &linkers {
let linker_rel_now = if linker_rel == &old_rel {
new_rel.clone()
} else {
linker_rel.clone()
};
let linker_abs = store.abs_path(&linker_rel_now);
if rewrite_links_in_file(&linker_abs, &old_rel, &new_rel)? {
rewritten += 1;
if !is_index_artifact(&linker_rel_now) {
rewritten_linkers.push(linker_rel_now);
}
}
}
let mut index_warning = index_on_rename(&store, &old_rel, &new_rel);
for linker in &rewritten_linkers {
if linker == &new_rel {
continue;
}
if let Some(w) = index_on_write(&store, linker) {
index_warning.get_or_insert(w);
}
}
emit_result(
ctx,
&path_to_unix(&old_rel),
&path_to_unix(&new_rel),
rewritten,
&index_warning,
);
Ok(())
}
fn rewrite_links_in_file(abs: &Path, old_rel: &Path, new_rel: &Path) -> Result<bool, CliError> {
let text = std::fs::read_to_string(abs)
.map_err(|e| CliError::runtime(format!("cannot read linker {}: {e}", abs.display())))?;
let rewritten = dbmd_core::graph::rewrite_links_to(&text, old_rel, new_rel);
if rewritten == text {
return Ok(false);
}
write_atomic(abs, &rewritten)?;
Ok(true)
}
fn missing_old_error(old: &Path) -> CliError {
CliError::runtime(format!(
"cannot rename `{}`: file does not exist",
path_to_unix(old)
))
}
fn dest_exists_error(new: &Path) -> CliError {
CliError::new(
ExitCode::Collision,
"PATH_COLLISION",
format!("destination `{}` already exists", path_to_unix(new)),
)
.with_hint("choose a destination that does not exist, or remove/merge the existing file first")
}
fn emit_result(
ctx: &Context,
old: &str,
new: &str,
rewritten: usize,
index_warning: &Option<String>,
) {
if let Some(w) = index_warning {
eprintln!("dbmd: warning: {w}");
}
if ctx.json {
let out = serde_json::json!({
"renamed": { "from": old, "to": new },
"links_rewritten": rewritten,
});
println!("{out}");
} else {
let files = if rewritten == 1 { "file" } else { "files" };
println!("renamed {old} -> {new} ({rewritten} {files} rewritten)");
}
}
fn write_atomic(path: &Path, contents: &str) -> Result<(), CliError> {
use std::io::Write;
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("dbmd-rename");
let tmp = parent.join(format!(".{name}.tmp.{}", std::process::id()));
{
let mut f = std::fs::File::create(&tmp)
.map_err(|e| CliError::runtime(format!("cannot write rewrite: {e}")))?;
f.write_all(contents.as_bytes())
.map_err(|e| CliError::runtime(format!("cannot write rewrite: {e}")))?;
f.sync_all().ok();
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(CliError::runtime(format!("cannot finalize rewrite: {e}")));
}
Ok(())
}
fn is_index_artifact(p: &Path) -> bool {
matches!(
p.file_name().and_then(|n| n.to_str()),
Some("index.md") | Some("index.jsonl")
)
}
fn path_to_unix(p: &Path) -> String {
p.components()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rewrite_links_in_file_retargets_and_persists_via_core() {
let tmp = std::env::temp_dir().join(format!("dbmd-rename-test-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let f = tmp.join("linker.md");
std::fs::write(
&f,
"Met [[records/contacts/sarah.md|Sarah]] and [[records/contacts/sarah-2]].",
)
.unwrap();
let changed = rewrite_links_in_file(
&f,
Path::new("records/contacts/sarah"),
Path::new("records/contacts/sarah-chen"),
)
.unwrap();
assert!(changed, "a matching link must report a change");
let after = std::fs::read_to_string(&f).unwrap();
assert_eq!(
after,
"Met [[records/contacts/sarah-chen|Sarah]] and [[records/contacts/sarah-2]]."
);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn rewrite_links_in_file_is_a_no_op_when_no_link_matches() {
let tmp = std::env::temp_dir().join(format!("dbmd-rename-noop-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let f = tmp.join("linker.md");
let original = "Only [[wiki/topics/elsewhere]] here.";
std::fs::write(&f, original).unwrap();
let changed = rewrite_links_in_file(
&f,
Path::new("records/contacts/sarah"),
Path::new("records/contacts/sarah-chen"),
)
.unwrap();
assert!(!changed, "no matching link → no change reported");
assert_eq!(
std::fs::read_to_string(&f).unwrap(),
original,
"a no-op must leave the file byte-for-byte unchanged"
);
std::fs::remove_dir_all(&tmp).ok();
}
}