admin: add bulk-rename command, remove # from all keys

Add `poc-memory admin bulk-rename FROM TO [--apply]` for bulk key
character replacement. Uses rename_node() per key for proper capnp
log persistence. Collision detection, progress reporting, auto-fsck.

Applied: renamed 13,042 keys from # to - separator. This fixes the
Claude Bash tool's inability to pass # in command arguments (the
model confabulates that quoting doesn't work and gives up).

7 collision pairs resolved by deleting the # version before rename.
209 orphan edges pruned by fsck.

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
ProofOfConcept 2026-03-14 13:11:38 -04:00
parent e74f403192
commit f8221286da

View file

@ -608,6 +608,17 @@ enum AdminCmd {
#[arg(long)] #[arg(long)]
apply: bool, apply: bool,
}, },
/// Bulk rename: replace a character in all keys
#[command(name = "bulk-rename")]
BulkRename {
/// Character to replace
from: String,
/// Replacement character
to: String,
/// Apply changes (default: dry run)
#[arg(long)]
apply: bool,
},
/// Brief metrics check (for cron/notifications) /// Brief metrics check (for cron/notifications)
#[command(name = "daily-check")] #[command(name = "daily-check")]
DailyCheck, DailyCheck,
@ -809,6 +820,7 @@ fn main() {
AdminCmd::Health => cmd_health(), AdminCmd::Health => cmd_health(),
AdminCmd::Fsck => cmd_fsck(), AdminCmd::Fsck => cmd_fsck(),
AdminCmd::Dedup { apply } => cmd_dedup(apply), AdminCmd::Dedup { apply } => cmd_dedup(apply),
AdminCmd::BulkRename { from, to, apply } => cmd_bulk_rename(&from, &to, apply),
AdminCmd::DailyCheck => cmd_daily_check(), AdminCmd::DailyCheck => cmd_daily_check(),
AdminCmd::Import { files } => cmd_import(&files), AdminCmd::Import { files } => cmd_import(&files),
AdminCmd::Export { files, all } => cmd_export(&files, all), AdminCmd::Export { files, all } => cmd_export(&files, all),
@ -1014,6 +1026,73 @@ fn cmd_init() -> Result<(), String> {
Ok(()) Ok(())
} }
fn cmd_bulk_rename(from: &str, to: &str, apply: bool) -> Result<(), String> {
let mut store = store::Store::load()?;
// Find all keys that need renaming
let renames: Vec<(String, String)> = store.nodes.keys()
.filter(|k| k.contains(from))
.map(|k| (k.clone(), k.replace(from, to)))
.collect();
// Check for collisions
let existing: std::collections::HashSet<&String> = store.nodes.keys().collect();
let mut collisions = 0;
for (old, new) in &renames {
if existing.contains(new) && old != new {
eprintln!("COLLISION: {} -> {} (target exists)", old, new);
collisions += 1;
}
}
println!("Bulk rename '{}' -> '{}'", from, to);
println!(" Keys to rename: {}", renames.len());
println!(" Collisions: {}", collisions);
if collisions > 0 {
return Err(format!("{} collisions — aborting", collisions));
}
if !apply {
// Show a sample
for (old, new) in renames.iter().take(10) {
println!(" {} -> {}", old, new);
}
if renames.len() > 10 {
println!(" ... and {} more", renames.len() - 10);
}
println!("\nDry run. Use --apply to execute.");
return Ok(());
}
// Apply renames using rename_node() which properly appends to capnp logs.
// Process in batches to avoid holding the lock too long.
let mut renamed_count = 0;
let mut errors = 0;
let total = renames.len();
for (i, (old_key, new_key)) in renames.iter().enumerate() {
match store.rename_node(old_key, new_key) {
Ok(()) => renamed_count += 1,
Err(e) => {
eprintln!(" RENAME ERROR: {} -> {}: {}", old_key, new_key, e);
errors += 1;
}
}
if (i + 1) % 1000 == 0 {
println!(" {}/{} ({} errors)", i + 1, total, errors);
}
}
store.save()?;
println!("Renamed {} nodes ({} errors).", renamed_count, errors);
// Run fsck to verify
println!("\nRunning fsck...");
drop(store);
cmd_fsck()?;
Ok(())
}
fn install_default_file(data_dir: &std::path::Path, name: &str, content: &str) -> Result<(), String> { fn install_default_file(data_dir: &std::path::Path, name: &str, content: &str) -> Result<(), String> {
let path = data_dir.join(name); let path = data_dir.join(name);
if !path.exists() { if !path.exists() {