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,
path_escapes_store_error, policy_frozen_error, require_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 = require_store_relative(&store, &args.old)?;
let new_rel = require_store_relative(&store, &args.new)?;
let old_abs = store.abs_path(&old_rel);
let new_abs = store.abs_path(&new_rel);
if let Err(e) = dbmd_core::store::ensure_path_within_store(&store.root, &new_abs) {
return Err(path_escapes_store_error(&path_to_unix(&new_rel), &e));
}
if let Err(e) = dbmd_core::store::ensure_path_within_store(&store.root, &old_abs) {
return Err(path_escapes_store_error(&path_to_unix(&old_rel), &e));
}
if !old_abs.exists() {
return Err(missing_old_error(&old_rel));
}
if new_abs.exists() {
return Err(dest_exists_error(&new_rel));
}
if old_abs.is_dir() {
return Err(rename_directory_error(&old_rel));
}
if let Some(name) = reserved_meta_name(&old_rel) {
return Err(reserved_meta_source_error(&old_rel, name));
}
if let Some(name) = reserved_meta_name(&new_rel) {
return Err(reserved_meta_dest_error(&new_rel, name));
}
enforce_frozen(&store, &old_rel)?;
if let Some(frozen) = store.config.frozen_match(&new_rel) {
return Err(policy_frozen_error(&frozen));
}
if let Some(parent) = new_abs.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| CliError::runtime(format!("cannot create destination folder: {e}")))?;
}
let linkers = store.find_links_to(&old_rel).map_err(core_err)?;
let mut rewritten = 0usize;
let mut rewritten_linkers: Vec<PathBuf> = Vec::new();
let mut skip_warnings: Vec<String> = Vec::new();
for linker_rel in &linkers {
let linker_abs = store.abs_path(linker_rel);
if dbmd_core::store::ensure_path_within_store(&store.root, &linker_abs).is_err() {
skip_warnings.push(format!(
"skipped out-of-store linker {} (reached via an in-store symlink; its `[[{}]]` link was not rewritten)",
path_to_unix(linker_rel),
path_to_unix(&old_rel)
));
continue;
}
match rewrite_links_in_file(&linker_abs, &old_rel, &new_rel) {
Ok(true) => {
if !is_index_artifact(linker_rel) {
rewritten += 1;
}
if linker_rel != &old_rel && !is_index_artifact(linker_rel) {
rewritten_linkers.push(linker_rel.clone());
}
}
Ok(false) => {}
Err(RewriteError::NotUtf8) => {
skip_warnings.push(format!(
"skipped non-UTF8 linker {} (its `[[{}]]` link was not rewritten)",
path_to_unix(linker_rel),
path_to_unix(&old_rel)
));
}
Err(RewriteError::Io(e)) => return Err(e),
}
}
std::fs::rename(&old_abs, &new_abs)
.map_err(|e| CliError::runtime(format!("cannot move file: {e}")))?;
if let Ok((mut fm, body)) = dbmd_core::parser::read_file(&new_abs) {
fm.updated = Some(dbmd_core::now());
dbmd_core::parser::write_file(&new_abs, &fm, &body).map_err(core_err)?;
}
let mut index_warning = index_on_rename(&store, &old_rel, &new_rel);
if let Some(w) = skip_warnings.into_iter().next() {
index_warning.get_or_insert(w);
}
for linker in &rewritten_linkers {
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(())
}
#[derive(Debug)]
enum RewriteError {
NotUtf8,
Io(CliError),
}
fn rewrite_links_in_file(abs: &Path, old_rel: &Path, new_rel: &Path) -> Result<bool, RewriteError> {
let text = match std::fs::read_to_string(abs) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
return Err(RewriteError::NotUtf8);
}
Err(e) => {
return Err(RewriteError::Io(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).map_err(RewriteError::Io)?;
Ok(true)
}
const RESERVED_META_BASENAMES: [&str; 4] = ["DB.md", "log.md", "index.md", "index.jsonl"];
fn reserved_meta_name(rel: &Path) -> Option<&'static str> {
let name = rel.file_name().and_then(|n| n.to_str())?;
RESERVED_META_BASENAMES
.into_iter()
.find(|reserved| *reserved == name)
}
fn rename_directory_error(old: &Path) -> CliError {
CliError::new(
ExitCode::Policy,
"RENAME_NOT_A_FILE",
format!(
"rename refused: `{}` is a directory; `dbmd rename` moves one content file at a time",
path_to_unix(old)
),
)
.with_hint("rename the individual files inside it, or move the folder with your shell + run `dbmd index rebuild`")
}
fn reserved_meta_source_error(old: &Path, name: &str) -> CliError {
CliError::new(
ExitCode::Policy,
"RENAME_RESERVED_META",
format!(
"rename refused: `{}` is a reserved db.md meta file ({name}) and cannot be renamed",
path_to_unix(old)
),
)
.with_hint(
"`DB.md`/`log.md`/`index.md`/`index.jsonl` are managed by db.md; never move them by hand",
)
}
fn reserved_meta_dest_error(new: &Path, name: &str) -> CliError {
CliError::new(
ExitCode::Policy,
"RENAME_RESERVED_META",
format!(
"rename refused: destination `{}` uses the reserved db.md meta-file name `{name}`",
path_to_unix(new)
),
)
.with_hint("choose a destination filename that is not a db.md meta file")
}
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> {
dbmd_core::write_atomic(path, contents.as_bytes())
.map_err(|e| CliError::runtime(format!("cannot finalize rewrite: {e}")))
}
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();
}
fn make_store() -> tempfile::TempDir {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join("DB.md"),
"---\ntype: db-md\nscope: company\nowner: T\n---\n\n# Store\n",
)
.unwrap();
dir
}
fn rename_args(old: &str, new: &str, dir: &Path) -> RenameArgs {
RenameArgs {
old: old.to_string(),
new: new.to_string(),
dir: dir.to_str().unwrap().to_string(),
}
}
fn ctx() -> Context {
Context {
json: false,
color: crate::context::ColorChoice::default(),
}
}
#[test]
fn reserved_meta_name_matches_only_root_meta_files() {
assert_eq!(reserved_meta_name(Path::new("DB.md")), Some("DB.md"));
assert_eq!(reserved_meta_name(Path::new("log.md")), Some("log.md"));
assert_eq!(
reserved_meta_name(Path::new("records/notes/index.md")),
Some("index.md")
);
assert_eq!(
reserved_meta_name(Path::new("records/notes/index.jsonl")),
Some("index.jsonl")
);
assert_eq!(reserved_meta_name(Path::new("records/notes/n.md")), None);
assert_eq!(
reserved_meta_name(Path::new("records/notes/DB-old.md")),
None
);
assert_eq!(reserved_meta_name(Path::new("records/db.md")), None); }
#[test]
fn rename_refuses_to_move_db_md_meta_marker() {
let dir = make_store();
let args = rename_args("DB.md", "records/notes/moved.md", dir.path());
let err = run(&ctx(), &args).unwrap_err();
assert_eq!(err.exit, ExitCode::Policy);
assert_eq!(err.code, "RENAME_RESERVED_META");
assert!(
dir.path().join("DB.md").exists(),
"the store marker must not be moved"
);
assert!(
!dir.path().join("records/notes/moved.md").exists(),
"nothing must be written to the destination"
);
}
#[test]
fn rename_refuses_a_directory_source() {
let dir = make_store();
let vendors = dir.path().join("records/vendors");
std::fs::create_dir_all(&vendors).unwrap();
std::fs::write(
vendors.join("v1.md"),
"---\ntype: vendor\nsummary: V\n---\n# V\n",
)
.unwrap();
let args = rename_args("records/vendors", "records/suppliers", dir.path());
let err = run(&ctx(), &args).unwrap_err();
assert_eq!(err.exit, ExitCode::Policy);
assert_eq!(err.code, "RENAME_NOT_A_FILE");
assert!(
vendors.join("v1.md").exists(),
"the directory must be untouched"
);
assert!(
!dir.path().join("records/suppliers").exists(),
"no destination directory must be created"
);
}
#[test]
fn rename_refuses_landing_on_a_reserved_meta_name() {
let dir = make_store();
let src = dir.path().join("records/notes/n.md");
std::fs::create_dir_all(src.parent().unwrap()).unwrap();
std::fs::write(&src, "---\ntype: note\nsummary: N\n---\n# N\n").unwrap();
let args = rename_args("records/notes/n.md", "records/notes/index.md", dir.path());
let err = run(&ctx(), &args).unwrap_err();
assert_eq!(err.exit, ExitCode::Policy);
assert_eq!(err.code, "RENAME_RESERVED_META");
assert!(src.exists(), "the source content file must not be moved");
}
#[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 [[records/concepts/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();
}
}