From f8221286da1a7fe788b3fd4e4a02e1e67b6cace9 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Sat, 14 Mar 2026 13:11:38 -0400 Subject: [PATCH] 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 --- poc-memory/src/main.rs | 79 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/poc-memory/src/main.rs b/poc-memory/src/main.rs index 43bdd17..c0ddde1 100644 --- a/poc-memory/src/main.rs +++ b/poc-memory/src/main.rs @@ -608,6 +608,17 @@ enum AdminCmd { #[arg(long)] 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) #[command(name = "daily-check")] DailyCheck, @@ -809,6 +820,7 @@ fn main() { AdminCmd::Health => cmd_health(), AdminCmd::Fsck => cmd_fsck(), AdminCmd::Dedup { apply } => cmd_dedup(apply), + AdminCmd::BulkRename { from, to, apply } => cmd_bulk_rename(&from, &to, apply), AdminCmd::DailyCheck => cmd_daily_check(), AdminCmd::Import { files } => cmd_import(&files), AdminCmd::Export { files, all } => cmd_export(&files, all), @@ -1014,6 +1026,73 @@ fn cmd_init() -> Result<(), String> { 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> { let path = data_dir.join(name); if !path.exists() {