forked from
atscan.net/plcbundle-rs
High-performance implementation of plcbundle written in Rust
1use super::utils;
2use anyhow::{Result, bail};
3use clap::Args;
4use plcbundle::BundleManager;
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7
8#[derive(Args)]
9#[command(
10 about = "Rollback repository to earlier state",
11 long_about = "Remove bundles from the end of the chain to restore the repository to an
12earlier state. This is useful for undoing recent syncs, removing corrupted
13bundles, or reverting to a known-good state.
14
15You can rollback to a specific bundle number using --to (keeps that bundle
16and all earlier ones), or remove the last N bundles using --last. The command
17shows a detailed plan before execution, including which bundles will be deleted,
18how much data will be removed, and what additional impacts there are (like
19mempool clearing or DID index invalidation).
20
21By default, bundle files are permanently deleted. Use --keep-files to update
22the index only while leaving bundle files on disk. Use --rebuild-did-index
23to automatically rebuild the DID index after rollback.
24
25This operation cannot be undone, so use with caution. The command requires
26explicit confirmation by typing 'rollback' unless --force is used.",
27 help_template = crate::clap_help!(
28 examples: " # Rollback TO bundle 100 (keeps 1-100, removes 101+)\n \
29 {bin} rollback --to 100\n\n \
30 # Remove last 5 bundles\n \
31 {bin} rollback --last 5\n\n \
32 # Rollback without confirmation\n \
33 {bin} rollback --to 50 -f\n\n \
34 # Rollback and rebuild DID index\n \
35 {bin} rollback --to 100 --rebuild-did-index\n\n \
36 # Rollback but keep bundle files (index-only)\n \
37 {bin} rollback --to 100 --keep-files"
38 )
39)]
40pub struct RollbackCommand {
41 /// Rollback TO this bundle (keeps it)
42 #[arg(long)]
43 pub to: Option<u32>,
44
45 /// Rollback last N bundles
46 #[arg(long)]
47 pub last: Option<u32>,
48
49 /// Skip confirmation prompt
50 #[arg(short, long)]
51 pub force: bool,
52
53 /// Rebuild DID index after rollback
54 #[arg(long)]
55 pub rebuild_did_index: bool,
56
57 /// Update index only (don't delete bundle files)
58 #[arg(long)]
59 pub keep_files: bool,
60}
61
62#[derive(Debug)]
63struct RollbackPlan {
64 target_bundle: u32,
65 bundles_to_keep: usize,
66 bundles_to_delete: Vec<u32>,
67 deleted_ops: usize,
68 deleted_size: u64,
69 has_mempool: bool,
70 has_did_index: bool,
71 affected_period: Option<(String, String)>,
72}
73
74pub fn run(cmd: RollbackCommand, dir: PathBuf, global_verbose: bool) -> Result<()> {
75 // Step 1: Validate options and calculate plan
76 let mut manager = super::utils::create_manager(dir.clone(), global_verbose, false, false)?;
77 let plan = calculate_rollback_plan(&manager, &cmd)?;
78
79 // Step 2: Display plan and get confirmation
80 display_rollback_plan(&dir, &plan)?;
81
82 if !cmd.force {
83 if !confirm_rollback(cmd.keep_files)? {
84 println!("Cancelled");
85 return Ok(());
86 }
87 println!();
88 }
89
90 // Step 3: Execute rollback
91 perform_rollback(&mut manager, &dir, &plan, &cmd, global_verbose)?;
92
93 // Step 4: Display success summary
94 display_rollback_success(&plan, &cmd)?;
95
96 Ok(())
97}
98
99fn calculate_rollback_plan(manager: &BundleManager, cmd: &RollbackCommand) -> Result<RollbackPlan> {
100 // Validate options
101 if cmd.to.is_none() && cmd.last.is_none() {
102 bail!("either --to or --last must be specified");
103 }
104
105 if cmd.to.is_some() && cmd.last.is_some() {
106 bail!("cannot use both --to and --last together");
107 }
108
109 if super::utils::is_repository_empty(manager) {
110 bail!("no bundles to rollback");
111 }
112
113 let last_bundle = manager.get_last_bundle();
114
115 // Calculate target bundle
116 let target_bundle = if let Some(to) = cmd.to {
117 to
118 } else if let Some(last) = cmd.last {
119 if last >= last_bundle {
120 bail!(
121 "cannot rollback {} bundles, only {} exist",
122 last,
123 last_bundle
124 );
125 }
126 let calculated = last_bundle - last;
127
128 // Prevent accidental deletion of all bundles via --last
129 if calculated == 0 {
130 bail!("invalid rollback: would delete all bundles (use --to 0 explicitly if intended)");
131 }
132
133 calculated
134 } else {
135 unreachable!()
136 };
137
138 // Validate target
139 if target_bundle >= last_bundle {
140 bail!("already at bundle {} (nothing to rollback)", last_bundle);
141 }
142
143 // Build list of bundles to delete
144 let bundles_to_delete: Vec<u32> = (target_bundle + 1..=last_bundle).collect();
145
146 if bundles_to_delete.is_empty() {
147 bail!("already at bundle {} (nothing to rollback)", target_bundle);
148 }
149
150 // Calculate statistics
151 let mut deleted_ops = 0;
152 let mut deleted_size = 0u64;
153 let mut start_time = None;
154 let mut end_time = None;
155
156 for bundle_num in &bundles_to_delete {
157 if let Some(meta) = manager.get_bundle_metadata(*bundle_num)? {
158 deleted_ops += meta.operation_count as usize;
159 deleted_size += meta.compressed_size;
160
161 if start_time.is_none() {
162 start_time = Some(meta.start_time.clone());
163 }
164 end_time = Some(meta.end_time.clone());
165 }
166 }
167
168 let affected_period = if let (Some(start), Some(end)) = (start_time, end_time) {
169 Some((start, end))
170 } else {
171 None
172 };
173
174 // Check mempool and DID index
175 let mempool_stats = manager.get_mempool_stats()?;
176 let has_mempool = mempool_stats.count > 0;
177
178 let did_stats = manager.get_did_index_stats();
179 let has_did_index = did_stats
180 .get("total_dids")
181 .and_then(|v| v.as_i64())
182 .unwrap_or(0)
183 > 0;
184
185 Ok(RollbackPlan {
186 target_bundle,
187 bundles_to_keep: target_bundle as usize,
188 bundles_to_delete,
189 deleted_ops,
190 deleted_size,
191 has_mempool,
192 has_did_index,
193 affected_period,
194 })
195}
196
197fn display_rollback_plan(dir: &Path, plan: &RollbackPlan) -> Result<()> {
198 println!("╔════════════════════════════════════════════════════════════════╗");
199 println!("║ ROLLBACK PLAN ║");
200 println!("╚════════════════════════════════════════════════════════════════╝\n");
201
202 println!("📁 Repository");
203 println!(" Directory: {}", utils::display_path(dir).display());
204
205 let current_bundles = plan.bundles_to_keep + plan.bundles_to_delete.len();
206 if current_bundles > 0 {
207 let last = plan.bundles_to_delete.last().unwrap();
208 println!(
209 " Current state: {} bundles ({} → {})",
210 current_bundles, 1, last
211 );
212 }
213 println!(" Target: bundle {}\n", plan.target_bundle);
214
215 println!("🗑️ Will Delete");
216 println!(" Bundles: {}", plan.bundles_to_delete.len());
217 println!(
218 " Operations: {}",
219 super::utils::format_number(plan.deleted_ops as u64)
220 );
221 println!(
222 " Data size: {}",
223 super::utils::format_bytes(plan.deleted_size)
224 );
225
226 if let Some((start, end)) = &plan.affected_period {
227 let start_dt = chrono::DateTime::parse_from_rfc3339(start)
228 .unwrap_or_else(|_| chrono::Utc::now().into());
229 let end_dt =
230 chrono::DateTime::parse_from_rfc3339(end).unwrap_or_else(|_| chrono::Utc::now().into());
231 println!(
232 " Time period: {} to {}",
233 start_dt.format("%Y-%m-%d %H:%M"),
234 end_dt.format("%Y-%m-%d %H:%M")
235 );
236 }
237 println!();
238
239 // Show sample of deleted bundles
240 if !plan.bundles_to_delete.is_empty() {
241 println!(" Bundles to delete:");
242 let display_count = std::cmp::min(10, plan.bundles_to_delete.len());
243 for &bundle_num in &plan.bundles_to_delete[..display_count] {
244 println!(" • {}", bundle_num);
245 }
246 if plan.bundles_to_delete.len() > display_count {
247 println!(
248 " ... and {} more",
249 plan.bundles_to_delete.len() - display_count
250 );
251 }
252 println!();
253 }
254
255 // Show impacts
256 println!("⚠️ Additional Impacts");
257 if plan.has_mempool {
258 println!(" • Mempool will be cleared");
259 }
260 if plan.has_did_index {
261 println!(" • DID index will need rebuilding");
262 }
263 if plan.bundles_to_keep == 0 {
264 println!(" • Repository will be EMPTY after rollback");
265 }
266 println!();
267
268 Ok(())
269}
270
271fn confirm_rollback(keep_files: bool) -> Result<bool> {
272 if keep_files {
273 print!("Type 'rollback-index' to confirm (index-only mode): ");
274 } else {
275 println!("⚠️ This will permanently DELETE data!");
276 print!("Type 'rollback' to confirm: ");
277 }
278
279 io::stdout().flush()?;
280
281 let mut response = String::new();
282 io::stdin().read_line(&mut response)?;
283
284 let expected = if keep_files {
285 "rollback-index"
286 } else {
287 "rollback"
288 };
289
290 Ok(response.trim() == expected)
291}
292
293fn perform_rollback(
294 manager: &mut BundleManager,
295 _dir: &PathBuf,
296 plan: &RollbackPlan,
297 cmd: &RollbackCommand,
298 _verbose: bool,
299) -> Result<()> {
300 let total_steps = 4;
301 let mut current_step = 0;
302
303 // Step 1: Delete bundle files (or skip if keep_files)
304 current_step += 1;
305 if !cmd.keep_files {
306 println!(
307 "[{}/{}] Deleting bundle files...",
308 current_step, total_steps
309 );
310 delete_bundle_files(manager, &plan.bundles_to_delete)?;
311 println!(" ✓ Deleted {} file(s)\n", plan.bundles_to_delete.len());
312 } else {
313 println!(
314 "[{}/{}] Skipping file deletion (--keep-files)...",
315 current_step, total_steps
316 );
317 println!(" ℹ Bundle files remain on disk\n");
318 }
319
320 // Step 2: Clear mempool
321 current_step += 1;
322 println!("[{}/{}] Clearing mempool...", current_step, total_steps);
323 if plan.has_mempool {
324 manager.clear_mempool()?;
325 println!(" ✓ Mempool cleared\n");
326 } else {
327 println!(" (no mempool data)\n");
328 }
329
330 // Step 3: Update index
331 current_step += 1;
332 println!(
333 "[{}/{}] Updating bundle index...",
334 current_step, total_steps
335 );
336 manager.rollback_to_bundle(plan.target_bundle)?;
337 println!(" ✓ Index updated ({} bundles)\n", plan.bundles_to_keep);
338
339 // Step 4: Handle DID index
340 current_step += 1;
341 println!("[{}/{}] DID index...", current_step, total_steps);
342 handle_did_index(manager, plan, cmd)?;
343
344 Ok(())
345}
346
347fn delete_bundle_files(manager: &BundleManager, bundles: &[u32]) -> Result<()> {
348 let stats = manager.delete_bundle_files(bundles)?;
349
350 if stats.failed > 0 {
351 eprintln!(" ⚠️ Failed to delete {} file(s)", stats.failed);
352 bail!("Failed to delete {} bundle files", stats.failed);
353 }
354
355 Ok(())
356}
357
358fn handle_did_index(
359 manager: &mut BundleManager,
360 plan: &RollbackPlan,
361 cmd: &RollbackCommand,
362) -> Result<()> {
363 if !plan.has_did_index {
364 println!(" (no DID index)");
365 return Ok(());
366 }
367
368 if cmd.rebuild_did_index {
369 println!(" Rebuilding DID index...");
370
371 if plan.bundles_to_keep == 0 {
372 println!(" ℹ No bundles to index");
373 return Ok(());
374 }
375
376 // Use default flush interval for rollback
377 let _stats = manager.build_did_index(
378 crate::constants::DID_INDEX_FLUSH_INTERVAL,
379 None::<fn(u32, u32, u64, u64)>,
380 None,
381 None,
382 )?;
383 println!(
384 " ✓ DID index rebuilt ({} bundles)",
385 plan.bundles_to_keep
386 );
387 } else {
388 let did_stats = manager.get_did_index_stats();
389 println!(" ⚠️ DID index is out of date");
390 if did_stats
391 .get("total_entries")
392 .and_then(|v| v.as_i64())
393 .unwrap_or(0)
394 > 0
395 {
396 println!(
397 " Run: {} index rebuild",
398 crate::constants::BINARY_NAME
399 );
400 }
401 }
402
403 Ok(())
404}
405
406fn display_rollback_success(plan: &RollbackPlan, cmd: &RollbackCommand) -> Result<()> {
407 println!();
408 println!("╔════════════════════════════════════════════════════════════════╗");
409 println!("║ ROLLBACK COMPLETE ║");
410 println!("╚════════════════════════════════════════════════════════════════╝\n");
411
412 if plan.bundles_to_keep > 0 {
413 println!("📦 New State");
414 println!(
415 " Bundles: {} ({} → {})",
416 plan.bundles_to_keep, 1, plan.target_bundle
417 );
418 } else {
419 println!("📦 New State");
420 println!(" Repository: EMPTY (all bundles removed)");
421 if cmd.keep_files {
422 println!(" Note: Bundle files remain on disk");
423 }
424 }
425
426 println!();
427
428 // Show what was removed
429 println!("🗑️ Removed");
430 println!(" Bundles: {}", plan.bundles_to_delete.len());
431 println!(
432 " Operations: {}",
433 super::utils::format_number(plan.deleted_ops as u64)
434 );
435 println!(
436 " Data freed: {}",
437 super::utils::format_bytes(plan.deleted_size)
438 );
439
440 if cmd.keep_files {
441 println!(" Files: kept on disk");
442 }
443
444 println!();
445
446 // Next steps
447 if !cmd.rebuild_did_index && plan.has_did_index {
448 println!("💡 Next Steps");
449 println!(" DID index is out of date. Rebuild with:");
450 println!(" {} index rebuild\n", crate::constants::BINARY_NAME);
451 }
452
453 Ok(())
454}