use std::path::{Path, PathBuf};
use crate::cli::RmArgs;
use crate::cmd::rename::reserved_meta_name;
use crate::cmd::write::{
core_err, enforce_frozen, index_on_remove, open_store, require_store_relative,
};
use crate::context::Context;
use crate::error::{CliError, CliResult, ExitCode};
use crate::sanitize::sanitize_single_line;
pub fn run(ctx: &Context, args: &RmArgs) -> CliResult {
let store = open_store(&args.dir)?;
let _transaction = store.transaction().map_err(CliError::from)?;
let rel = require_store_relative(&store, &args.path)?;
if let Some(name) = reserved_meta_name(&rel) {
return Err(reserved_meta_error(&rel, name));
}
if store
.directory_exists(&rel)
.map_err(|error| path_probe_error(&rel, &error))?
{
return Err(directory_error(&rel));
}
if !dbmd_core::store::is_content_path(&rel) {
return Err(CliError::new(
ExitCode::Policy,
"RM_NOT_CONTENT",
format!(
"rm refused: `{}` is not a content file under sources/ or records/",
path_to_unix(&rel)
),
)
.with_hint("`dbmd rm` deletes content records; remove other files with your shell"));
}
if !store
.regular_file_exists(&rel)
.map_err(|error| path_probe_error(&rel, &error))?
{
return Err(CliError::runtime(format!(
"cannot rm `{}`: file does not exist",
path_to_unix(&rel)
)));
}
enforce_frozen(&store, &rel)?;
let backlinks: Vec<PathBuf> = store
.find_links_to(&rel)
.map_err(core_err)?
.into_iter()
.filter(|linker| linker != &rel && dbmd_core::store::is_content_path(linker))
.collect();
if !backlinks.is_empty() && !args.force {
let listed: Vec<String> = backlinks.iter().map(|p| path_to_unix(p)).collect();
let links = if backlinks.len() == 1 {
"link"
} else {
"links"
};
return Err(CliError::new(
ExitCode::Collision,
"RM_LINKED",
format!(
"rm refused: {} incoming wiki-{links} still target `{}`",
backlinks.len(),
path_to_unix(&rel)
),
)
.with_details(serde_json::json!({ "backlinks": listed }))
.with_hint(
"rewrite or remove those links first (`dbmd rename` keeps links valid when moving), \
or pass `--force` to delete anyway — `dbmd validate --all` will then report \
WIKI_LINK_BROKEN on each linker",
));
}
store.remove_file(&rel).map_err(|error| {
CliError::runtime(format!("cannot rm `{}`: {error}", path_to_unix(&rel)))
})?;
let index_warning = index_on_remove(&store, &rel);
emit_result(ctx, &rel, &backlinks, args.force, &index_warning);
Ok(())
}
fn emit_result(
ctx: &Context,
rel: &Path,
backlinks: &[PathBuf],
forced: bool,
index_warning: &Option<String>,
) {
if let Some(w) = index_warning {
eprintln!("dbmd: warning: {}", sanitize_single_line(w));
}
let listed: Vec<String> = backlinks.iter().map(|p| path_to_unix(p)).collect();
if ctx.json {
let out = serde_json::json!({
"removed": path_to_unix(rel),
"backlinks": listed,
"forced": forced,
});
println!("{out}");
} else if listed.is_empty() {
println!("removed {}", sanitize_single_line(&path_to_unix(rel)));
} else {
let links = if listed.len() == 1 { "link" } else { "links" };
println!(
"removed {} ({} incoming {links} now broken)",
sanitize_single_line(&path_to_unix(rel)),
listed.len()
);
for linker in &listed {
eprintln!(" broken link in {}", sanitize_single_line(linker));
}
}
}
fn reserved_meta_error(rel: &Path, name: &str) -> CliError {
CliError::new(
ExitCode::Policy,
"RM_RESERVED_META",
format!(
"rm refused: `{}` is a reserved db.md meta file ({name}) and cannot be deleted",
path_to_unix(rel)
),
)
.with_hint(
"`DB.md`/`log.md`/`index.md`/`index.jsonl` are managed by db.md; never delete them by hand",
)
}
fn directory_error(rel: &Path) -> CliError {
CliError::new(
ExitCode::Policy,
"RM_NOT_A_FILE",
format!(
"rm refused: `{}` is a directory; `dbmd rm` deletes one content file at a time",
path_to_unix(rel)
),
)
.with_hint("rm the individual files inside it, or remove the folder with your shell + run `dbmd index rebuild`")
}
fn path_probe_error(rel: &Path, error: &std::io::Error) -> CliError {
CliError::runtime(format!("cannot probe `{}`: {error}", path_to_unix(rel)))
}
fn path_to_unix(p: &Path) -> String {
p.components()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/")
}