forked from
atscan.net/plcbundle-rs
High-performance implementation of plcbundle written in Rust
1// Compare command - compare repositories
2use anyhow::{Context, Result, bail};
3use clap::{Args, ValueHint};
4use plcbundle::constants::bundle_position_to_global;
5use plcbundle::{BundleManager, constants, remote};
6use sonic_rs::JsonValueTrait;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use tokio::runtime::Runtime;
10
11use super::utils::colors;
12
13#[derive(Args)]
14#[command(
15 alias = "diff",
16 about = "Compare bundle repositories",
17 long_about = "Compare your local repository against a remote instance or another local
18repository to identify differences, missing bundles, or integrity issues.
19
20Performs a comprehensive comparison of bundle indexes, detecting missing bundles
21(in target but not local), extra bundles (in local but not target), hash
22mismatches (indicating different content), and content hash differences.
23
24The command automatically detects when repositories are from different origins
25(PLC directory sources) and warns you that differences are expected in such cases.
26When origins match, hash mismatches indicate potential data corruption or
27synchronization issues that require investigation.
28
29Use --bundles with a specific bundle number for deep analysis that compares
30metadata and operations in detail. Use --show-operations to see operation-level
31differences and --sample to control how many operations are displayed.
32
33The target can be a remote HTTP URL (e.g., https://plc.example.com), a remote
34index URL, or a local file path to another repository's index file.",
35 help_template = crate::clap_help!(
36 examples: " # High-level comparison\n \
37 {bin} compare https://plc.example.com\n\n \
38 # Show all differences (verbose)\n \
39 {bin} compare https://plc.example.com -v\n\n \
40 # Deep dive into specific bundle\n \
41 {bin} compare https://plc.example.com --bundles 23\n\n \
42 # Compare bundle with operation samples\n \
43 {bin} compare https://plc.example.com --bundles 23 --show-operations\n\n \
44 # Show first 50 operations\n \
45 {bin} compare https://plc.example.com --bundles 23 --sample 50"
46 )
47)]
48pub struct CompareCommand {
49 /// Target to compare against (URL or local path)
50 #[arg(value_hint = ValueHint::AnyPath)]
51 pub target: String,
52
53 /// Deep comparison of specific bundle (alias: --bundles)
54 #[arg(long)]
55 pub bundles: Option<String>,
56
57 /// Show operation differences (use with --bundles)
58 #[arg(long)]
59 pub show_operations: bool,
60
61 /// Number of sample operations to show (use with --bundles)
62 #[arg(long, default_value = "10")]
63 pub sample: usize,
64}
65
66pub fn run(cmd: CompareCommand, dir: PathBuf, global_verbose: bool) -> Result<()> {
67 let manager = super::utils::create_manager(dir.clone(), global_verbose, false, false)?;
68
69 let rt = Runtime::new()?;
70
71 // If specific bundle requested, do detailed comparison
72 if let Some(bundles_str) = cmd.bundles {
73 let last_bundle = manager.get_last_bundle();
74 let bundle_nums = super::utils::parse_bundle_spec(Some(bundles_str), last_bundle)?;
75 if bundle_nums.len() != 1 {
76 anyhow::bail!(
77 "--bundles must specify a single bundle number for comparison (e.g., \"23\")"
78 );
79 }
80 let bundle_num = bundle_nums[0];
81 rt.block_on(diff_specific_bundle(
82 &manager,
83 &cmd.target,
84 bundle_num,
85 cmd.show_operations,
86 cmd.sample,
87 ))
88 } else {
89 // Otherwise, do high-level index comparison
90 rt.block_on(diff_indexes(
91 &manager,
92 &dir,
93 &cmd.target,
94 global_verbose,
95 constants::BINARY_NAME,
96 ))
97 }
98}
99
100async fn diff_indexes(
101 manager: &BundleManager,
102 dir: &Path,
103 target: &str,
104 verbose: bool,
105 binary_name: &str,
106) -> Result<()> {
107 use super::utils::display_path;
108
109 // Resolve "." to actual path (rule: always resolve dot to full path)
110 let local_path = display_path(dir);
111
112 // Resolve target to full path if it's a local path (not URL)
113 let target_display = if target.starts_with("http://") || target.starts_with("https://") {
114 target.to_string()
115 } else {
116 // Local path - resolve to full path
117 display_path(&PathBuf::from(target)).display().to_string()
118 };
119
120 eprintln!("\n🔍 Comparing repositories");
121 eprintln!(" Local: {}", local_path.display());
122 eprintln!(" Target: {}\n", target_display);
123
124 // Load local index
125 let local_index = manager.get_index();
126
127 // Load target index
128 eprintln!("📥 Loading target index...");
129 let target_index = if target.starts_with("http://") || target.starts_with("https://") {
130 let client = remote::RemoteClient::new(target)?;
131 client
132 .fetch_index()
133 .await
134 .context("Failed to load target index")?
135 } else {
136 remote::load_local_index(target).context("Failed to load target index")?
137 };
138
139 // Check origins - CRITICAL: must match for valid comparison
140 let local_origin = &local_index.origin;
141 let target_origin = &target_index.origin;
142 let origins_match = local_origin == target_origin;
143
144 eprintln!("\n🌐 Origin Check");
145 eprintln!("═══════════════");
146 eprintln!(" Local Origin: {}", local_origin);
147 eprintln!(" Target Origin: {}", target_origin);
148
149 if !origins_match {
150 eprintln!("\n⚠️ ⚠️ ⚠️ WARNING: DIFFERENT ORIGINS ⚠️ ⚠️ ⚠️");
151 eprintln!("═══════════════════════════════════════════════════════════════");
152 eprintln!(" The repositories are from DIFFERENT PLC directory sources!");
153 eprintln!(" Local: {}", local_origin);
154 eprintln!(" Target: {}", target_origin);
155 eprintln!("\n ⚠️ Comparing bundles from different origins may show");
156 eprintln!(" differences that are expected and not errors.");
157 eprintln!(" ⚠️ Bundle numbers may overlap but contain different data.");
158 eprintln!(" ⚠️ Hash mismatches are expected when origins differ.");
159 eprintln!("═══════════════════════════════════════════════════════════════\n");
160 } else {
161 eprintln!(" ✅ Origins match - comparing same source\n");
162 }
163
164 // Perform comparison
165 let comparison = compare_indexes(&local_index, &target_index, origins_match);
166
167 // Display results
168 display_comparison(&comparison, verbose, origins_match);
169
170 // If there are hash mismatches, suggest deep dive
171 if !comparison.hash_mismatches.is_empty() {
172 eprintln!("\n💡 Tip: Investigate specific mismatches with:");
173 eprintln!(
174 " {} compare {} --bundles {} --show-operations",
175 binary_name, target, comparison.hash_mismatches[0].bundle_number
176 );
177 }
178
179 // Only fail if there are critical hash mismatches AND origins match
180 // Missing/extra bundles are not errors, just informational
181 // Hash mismatches are expected when origins differ
182 if origins_match
183 && (!comparison.hash_mismatches.is_empty() || !comparison.content_mismatches.is_empty())
184 {
185 bail!("indexes have critical differences (hash mismatches)");
186 }
187
188 Ok(())
189}
190
191async fn diff_specific_bundle(
192 manager: &BundleManager,
193 target: &str,
194 bundle_num: u32,
195 show_ops: bool,
196 sample_size: usize,
197) -> Result<()> {
198 // Store bundle_num for use in hints
199 eprintln!("\n🔬 Deep Comparison: Bundle {}", bundle_num);
200 eprintln!("═══════════════════════════════════════════════════════════════\n");
201
202 // Get local index and check origin
203 let local_index = manager.get_index();
204 let local_origin = &local_index.origin;
205
206 // Load remote index to get metadata and origin
207 eprintln!("📥 Loading remote index...");
208 let remote_index = if target.starts_with("http://") || target.starts_with("https://") {
209 let client = remote::RemoteClient::new(target)?;
210 client
211 .fetch_index()
212 .await
213 .context("Failed to load remote index")?
214 } else {
215 remote::load_local_index(target).context("Failed to load remote index")?
216 };
217 let target_origin = &remote_index.origin;
218
219 // Check origins - CRITICAL: must match for valid comparison
220 let origins_match = local_origin == target_origin;
221
222 eprintln!("\n🌐 Origin Check");
223 eprintln!("═══════════════");
224 eprintln!(" Local Origin: {}", local_origin);
225 eprintln!(" Target Origin: {}", target_origin);
226
227 if !origins_match {
228 eprintln!("\n⚠️ ⚠️ ⚠️ WARNING: DIFFERENT ORIGINS ⚠️ ⚠️ ⚠️");
229 eprintln!("═══════════════════════════════════════════════════════════════");
230 eprintln!(" The repositories are from DIFFERENT PLC directory sources!");
231 eprintln!(" Local: {}", local_origin);
232 eprintln!(" Target: {}", target_origin);
233 eprintln!(
234 "\n ⚠️ Bundle {} may have the same number but contain",
235 bundle_num
236 );
237 eprintln!(" completely different data from different sources.");
238 eprintln!(" ⚠️ All differences shown below are EXPECTED when origins differ.");
239 eprintln!(" ⚠️ Do not treat hash/content mismatches as errors.");
240 eprintln!("═══════════════════════════════════════════════════════════════\n");
241 } else {
242 eprintln!(" ✅ Origins match - comparing same source\n");
243 }
244
245 // Get local metadata
246 let local_meta = manager
247 .get_bundle_metadata(bundle_num)?
248 .ok_or_else(|| anyhow::anyhow!("Bundle {} not found in local index", bundle_num))?;
249
250 // Load local bundle
251 eprintln!("📦 Loading local bundle {}...", bundle_num);
252 let local_result = manager.load_bundle(bundle_num, plcbundle::LoadOptions::default())?;
253 let local_ops = local_result.operations;
254
255 let remote_meta = remote_index
256 .get_bundle(bundle_num)
257 .ok_or_else(|| anyhow::anyhow!("Bundle {} not found in remote index", bundle_num))?;
258
259 // Load remote bundle
260 eprintln!("📦 Loading remote bundle {}...\n", bundle_num);
261 let remote_ops = if target.starts_with("http://") || target.starts_with("https://") {
262 let client = remote::RemoteClient::new(target)?;
263 client
264 .fetch_bundle_operations(bundle_num)
265 .await
266 .context("Failed to load remote bundle")?
267 } else {
268 // Local path - try to load from directory
269 use std::path::Path;
270 let target_path = Path::new(target);
271 let target_dir = if target_path.is_file() {
272 target_path.parent().unwrap_or(Path::new("."))
273 } else {
274 target_path
275 };
276
277 // Try to load bundle from target directory
278 let target_manager =
279 super::utils::create_manager(target_dir.to_path_buf(), false, false, false)?;
280 let target_result = target_manager
281 .load_bundle(bundle_num, plcbundle::LoadOptions::default())
282 .context("Failed to load bundle from target directory")?;
283 target_result.operations
284 };
285
286 // Compare metadata
287 display_bundle_metadata_comparison_full(
288 &local_ops,
289 &local_meta,
290 remote_meta,
291 bundle_num,
292 origins_match,
293 );
294
295 // Compare operations
296 if show_ops {
297 eprintln!();
298 display_operation_comparison(
299 &local_ops,
300 &remote_ops,
301 sample_size,
302 origins_match,
303 bundle_num,
304 target,
305 );
306 }
307
308 // Compare hashes in detail
309 eprintln!();
310 display_hash_analysis_full(&local_meta, remote_meta, bundle_num, origins_match);
311
312 Ok(())
313}
314
315// Comparison structures
316#[derive(Debug, Default)]
317struct IndexComparison {
318 local_count: usize,
319 target_count: usize,
320 common_count: usize,
321 missing_bundles: Vec<u32>,
322 extra_bundles: Vec<u32>,
323 hash_mismatches: Vec<HashMismatch>,
324 content_mismatches: Vec<HashMismatch>,
325 cursor_mismatches: Vec<HashMismatch>,
326 local_range: Option<(u32, u32)>,
327 target_range: Option<(u32, u32)>,
328 local_total_size: u64,
329 target_total_size: u64,
330 local_updated: String,
331 target_updated: String,
332 #[allow(dead_code)]
333 origins_match: bool,
334}
335
336#[derive(Debug)]
337struct HashMismatch {
338 bundle_number: u32,
339 local_hash: String,
340 target_hash: String,
341 local_content_hash: String,
342 target_content_hash: String,
343 local_cursor: String,
344 target_cursor: String,
345}
346
347impl IndexComparison {
348 fn has_differences(&self) -> bool {
349 !self.missing_bundles.is_empty()
350 || !self.extra_bundles.is_empty()
351 || !self.hash_mismatches.is_empty()
352 || !self.content_mismatches.is_empty()
353 || !self.cursor_mismatches.is_empty()
354 }
355}
356
357// Compare two indexes - uses fully qualified path to avoid direct Index import
358fn compare_indexes(
359 local: &plcbundle::index::Index,
360 target: &plcbundle::index::Index,
361 origins_match: bool,
362) -> IndexComparison {
363 let mut comparison = IndexComparison {
364 origins_match,
365 ..Default::default()
366 };
367
368 let local_map: HashMap<u32, &plcbundle::index::BundleMetadata> =
369 local.bundles.iter().map(|b| (b.bundle_number, b)).collect();
370
371 let target_map: HashMap<u32, &plcbundle::index::BundleMetadata> = target
372 .bundles
373 .iter()
374 .map(|b| (b.bundle_number, b))
375 .collect();
376
377 comparison.local_count = local.bundles.len();
378 comparison.target_count = target.bundles.len();
379 comparison.local_total_size = local.total_size_bytes;
380 comparison.target_total_size = target.total_size_bytes;
381 comparison.local_updated = local.updated_at.clone();
382 comparison.target_updated = target.updated_at.clone();
383
384 // Get ranges
385 if !local.bundles.is_empty() {
386 comparison.local_range = Some((
387 local.bundles[0].bundle_number,
388 local.bundles[local.bundles.len() - 1].bundle_number,
389 ));
390 }
391
392 if !target.bundles.is_empty() {
393 comparison.target_range = Some((
394 target.bundles[0].bundle_number,
395 target.bundles[target.bundles.len() - 1].bundle_number,
396 ));
397 }
398
399 // Find missing bundles
400 for bundle_num in target_map.keys() {
401 if !local_map.contains_key(bundle_num) {
402 comparison.missing_bundles.push(*bundle_num);
403 }
404 }
405 comparison.missing_bundles.sort();
406
407 // Find extra bundles
408 for bundle_num in local_map.keys() {
409 if !target_map.contains_key(bundle_num) {
410 comparison.extra_bundles.push(*bundle_num);
411 }
412 }
413 comparison.extra_bundles.sort();
414
415 // Compare hashes
416 for (bundle_num, local_meta) in &local_map {
417 if let Some(target_meta) = target_map.get(bundle_num) {
418 comparison.common_count += 1;
419
420 let chain_mismatch = local_meta.hash != target_meta.hash;
421 let content_mismatch = local_meta.content_hash != target_meta.content_hash;
422
423 let cursor_mismatch = local_meta.cursor != target_meta.cursor;
424
425 if chain_mismatch || content_mismatch || cursor_mismatch {
426 let mismatch = HashMismatch {
427 bundle_number: *bundle_num,
428 local_hash: local_meta.hash.clone(),
429 target_hash: target_meta.hash.clone(),
430 local_content_hash: local_meta.content_hash.clone(),
431 target_content_hash: target_meta.content_hash.clone(),
432 local_cursor: local_meta.cursor.clone(),
433 target_cursor: target_meta.cursor.clone(),
434 };
435
436 if chain_mismatch {
437 comparison.hash_mismatches.push(mismatch);
438 } else if content_mismatch {
439 comparison.content_mismatches.push(mismatch);
440 } else if cursor_mismatch {
441 // Cursor-only mismatch (hashes match but cursor differs)
442 comparison.cursor_mismatches.push(mismatch);
443 }
444 }
445 }
446 }
447
448 // Sort mismatches
449 comparison.hash_mismatches.sort_by_key(|m| m.bundle_number);
450 comparison
451 .content_mismatches
452 .sort_by_key(|m| m.bundle_number);
453 comparison
454 .cursor_mismatches
455 .sort_by_key(|m| m.bundle_number);
456
457 comparison
458}
459
460fn display_comparison(c: &IndexComparison, verbose: bool, origins_match: bool) {
461 eprintln!("\n📊 Comparison Results");
462 eprintln!("═══════════════════════\n");
463
464 if !origins_match {
465 eprintln!("⚠️ COMPARING DIFFERENT ORIGINS - All differences are expected!\n");
466 }
467
468 eprintln!("Summary");
469 eprintln!("───────");
470 eprintln!(" Local bundles: {}", c.local_count);
471 eprintln!(" Target bundles: {}", c.target_count);
472 eprintln!(" Common bundles: {}", c.common_count);
473
474 // Missing bundles - informational, not critical
475 if !c.missing_bundles.is_empty() {
476 eprintln!(
477 " Missing bundles: ℹ️ {} (in target, not in local)",
478 c.missing_bundles.len()
479 );
480 } else {
481 eprintln!(
482 " Missing bundles: {}{} ✓{}",
483 colors::GREEN,
484 c.missing_bundles.len(),
485 colors::RESET
486 );
487 }
488
489 // Extra bundles - informational
490 if !c.extra_bundles.is_empty() {
491 eprintln!(
492 " Extra bundles: ℹ️ {} (in local, not in target)",
493 c.extra_bundles.len()
494 );
495 } else {
496 eprintln!(
497 " Extra bundles: {}{} ✓{}",
498 colors::GREEN,
499 c.extra_bundles.len(),
500 colors::RESET
501 );
502 }
503
504 // Hash mismatches - CRITICAL only if origins match
505 if !c.hash_mismatches.is_empty() {
506 if origins_match {
507 eprintln!(
508 " Hash mismatches: ⚠️ {} (CRITICAL - different content)",
509 c.hash_mismatches.len()
510 );
511 } else {
512 eprintln!(
513 " Hash mismatches: ℹ️ {} (EXPECTED - different origins)",
514 c.hash_mismatches.len()
515 );
516 }
517 } else {
518 eprintln!(
519 " Hash mismatches: {}{} ✓{}",
520 colors::GREEN,
521 c.hash_mismatches.len(),
522 colors::RESET
523 );
524 }
525
526 // Content mismatches - less critical than chain hash
527 if !c.content_mismatches.is_empty() {
528 if origins_match {
529 eprintln!(
530 " Content mismatches: ⚠️ {} (different content hash)",
531 c.content_mismatches.len()
532 );
533 } else {
534 eprintln!(
535 " Content mismatches: ℹ️ {} (EXPECTED - different origins)",
536 c.content_mismatches.len()
537 );
538 }
539 } else {
540 eprintln!(
541 " Content mismatches: {}{} ✓{}",
542 colors::GREEN,
543 c.content_mismatches.len(),
544 colors::RESET
545 );
546 }
547
548 // Cursor mismatches - metadata issue
549 if !c.cursor_mismatches.is_empty() {
550 eprintln!(
551 " Cursor mismatches: ⚠️ {} (cursor should match previous bundle end_time)",
552 c.cursor_mismatches.len()
553 );
554 } else {
555 eprintln!(
556 " Cursor mismatches: {}{} ✓{}",
557 colors::GREEN,
558 c.cursor_mismatches.len(),
559 colors::RESET
560 );
561 }
562
563 if let Some((start, end)) = c.local_range {
564 eprintln!("\n Local range: {} - {}", start, end);
565 eprintln!(
566 " Local size: {:.2} MB",
567 c.local_total_size as f64 / (1024.0 * 1024.0)
568 );
569 eprintln!(" Local updated: {}", c.local_updated);
570 }
571
572 if let Some((start, end)) = c.target_range {
573 eprintln!("\n Target range: {} - {}", start, end);
574 eprintln!(
575 " Target size: {:.2} MB",
576 c.target_total_size as f64 / (1024.0 * 1024.0)
577 );
578 eprintln!(" Target updated: {}", c.target_updated);
579 }
580
581 // Show differences
582 if !c.hash_mismatches.is_empty() {
583 show_hash_mismatches(&c.hash_mismatches, verbose, origins_match);
584 }
585
586 if !c.cursor_mismatches.is_empty() {
587 show_cursor_mismatches(&c.cursor_mismatches, verbose);
588 }
589
590 if !c.missing_bundles.is_empty() {
591 show_missing_bundles(&c.missing_bundles, verbose);
592 }
593
594 if !c.extra_bundles.is_empty() {
595 show_extra_bundles(&c.extra_bundles, verbose);
596 }
597
598 // Chain analysis - find where chain broke
599 if !c.hash_mismatches.is_empty() && origins_match {
600 analyze_chain_break(&c.hash_mismatches, &c.local_range, &c.target_range);
601 }
602
603 // Final status
604 eprintln!();
605 if !c.has_differences() {
606 eprintln!("✅ Indexes are identical");
607 } else if !origins_match {
608 // Different origins - all differences are expected
609 eprintln!("ℹ️ Indexes differ (EXPECTED - comparing different origins)");
610 eprintln!(" All differences shown above are normal when comparing");
611 eprintln!(" repositories from different PLC directory sources.");
612 } else {
613 // Same origins - differences may be critical
614 if !c.hash_mismatches.is_empty() {
615 eprintln!("❌ Indexes differ (CRITICAL: hash mismatches detected)");
616 eprintln!("\n⚠️ WARNING: Chain hash mismatches indicate different bundle content");
617 eprintln!(" or chain integrity issues. This requires investigation.");
618 } else {
619 // Just missing/extra bundles - not critical
620 eprintln!("ℹ️ Indexes differ (missing or extra bundles, but hashes match)");
621 eprintln!(" This is normal when comparing repositories at different sync states.");
622 }
623 }
624}
625
626fn show_hash_mismatches(mismatches: &[HashMismatch], verbose: bool, origins_match: bool) {
627 if origins_match {
628 eprintln!("\n⚠️ CHAIN HASH MISMATCHES (CRITICAL)");
629 eprintln!("═══════════════════════════════════════════════════\n");
630 } else {
631 eprintln!("\nℹ️ CHAIN HASH MISMATCHES (EXPECTED - Different Origins)");
632 eprintln!("═══════════════════════════════════════════════════════════════\n");
633 }
634
635 let display_count = if mismatches.len() > 10 && !verbose {
636 10
637 } else {
638 mismatches.len()
639 };
640
641 for m in mismatches.iter().take(display_count) {
642 eprintln!(" Bundle {}:", m.bundle_number);
643 eprintln!(" Chain Hash:");
644 eprintln!(" Local: {}", m.local_hash);
645 eprintln!(" Target: {}", m.target_hash);
646
647 if m.local_content_hash != m.target_content_hash {
648 eprintln!(" Content Hash (also differs):");
649 eprintln!(" Local: {}", m.local_content_hash);
650 eprintln!(" Target: {}", m.target_content_hash);
651 }
652
653 if m.local_cursor != m.target_cursor {
654 eprintln!(" Cursor (also differs):");
655 eprintln!(" Local: {}", m.local_cursor);
656 eprintln!(" Target: {}", m.target_cursor);
657 }
658 eprintln!();
659 }
660
661 if mismatches.len() > display_count {
662 eprintln!(
663 " ... and {} more (use -v to show all)\n",
664 mismatches.len() - display_count
665 );
666 }
667}
668
669fn analyze_chain_break(
670 mismatches: &[HashMismatch],
671 _local_range: &Option<(u32, u32)>,
672 _target_range: &Option<(u32, u32)>,
673) {
674 if mismatches.is_empty() {
675 return;
676 }
677
678 eprintln!("\n🔗 Chain Break Analysis");
679 eprintln!("═══════════════════════════════════════════════════\n");
680
681 // Find the first bundle where chain broke
682 let first_break = mismatches[0].bundle_number;
683
684 // Determine last good bundle (one before first break)
685 let last_good = first_break.saturating_sub(1);
686
687 eprintln!(" Chain Status:");
688 if last_good > 0 {
689 eprintln!(" ✅ Bundles 1 - {}: Chain intact", last_good);
690 eprintln!(
691 " ❌ Bundle {}: Chain broken (first mismatch)",
692 first_break
693 );
694 } else {
695 eprintln!(" ❌ Bundle 1: Chain broken from start");
696 }
697
698 // Count consecutive breaks
699 let mut consecutive_breaks = 1;
700 for window in mismatches.windows(2) {
701 if window[1].bundle_number == first_break + consecutive_breaks {
702 consecutive_breaks += 1;
703 } else {
704 break;
705 }
706 }
707
708 if consecutive_breaks > 1 {
709 let last_break = first_break + consecutive_breaks - 1;
710 eprintln!(
711 " ❌ Bundles {} - {}: Chain broken ({} consecutive)",
712 first_break, last_break, consecutive_breaks
713 );
714 }
715
716 // Show total affected
717 eprintln!("\n Summary:");
718 eprintln!(" Last good bundle: {}", last_good);
719 eprintln!(" First break: {}", first_break);
720 eprintln!(" Total mismatches: {}", mismatches.len());
721
722 // Show parent hash comparison for first break
723 if let Some(first_mismatch) = mismatches.first() {
724 eprintln!("\n First Break Details (Bundle {}):", first_break);
725 eprintln!(
726 " Local chain hash: {}",
727 &first_mismatch.local_hash[..16]
728 );
729 eprintln!(
730 " Target chain hash: {}",
731 &first_mismatch.target_hash[..16]
732 );
733
734 // Check if parent hashes match (indicates where divergence started)
735 eprintln!("\n 💡 Interpretation:");
736 if last_good > 0 {
737 eprintln!(" • Bundles before {} are identical", last_good);
738 eprintln!(
739 " • Bundle {} has different content or was created differently",
740 first_break
741 );
742 eprintln!(" • All subsequent bundles will have different chain hashes");
743 eprintln!("\n To fix:");
744 eprintln!(" 1. Check bundle {} content differences", first_break);
745 eprintln!(
746 " 2. Verify operations in bundle {} match expected",
747 first_break
748 );
749 eprintln!(
750 " 3. Consider re-syncing from bundle {} onwards",
751 first_break
752 );
753 } else {
754 eprintln!(" • Chain broken from the very first bundle");
755 eprintln!(" • This indicates completely different repositories");
756 }
757 }
758
759 eprintln!();
760}
761
762fn show_cursor_mismatches(mismatches: &[HashMismatch], verbose: bool) {
763 eprintln!("\n⚠️ CURSOR MISMATCHES");
764 eprintln!("═══════════════════════════════════════════════════");
765 eprintln!(" Cursor should match previous bundle's end_time per spec.\n");
766
767 let display_count = if mismatches.len() > 10 && !verbose {
768 10
769 } else {
770 mismatches.len()
771 };
772
773 for m in mismatches.iter().take(display_count) {
774 let local = if m.local_cursor.is_empty() {
775 "(empty)"
776 } else {
777 &m.local_cursor
778 };
779 eprintln!(
780 " {}: Local: {} → Target: {}",
781 m.bundle_number, local, m.target_cursor
782 );
783 }
784
785 if mismatches.len() > display_count {
786 eprintln!(
787 " ... and {} more (use -v to show all)",
788 mismatches.len() - display_count
789 );
790 }
791}
792
793fn show_missing_bundles(bundles: &[u32], verbose: bool) {
794 eprintln!("\nℹ️ Missing Bundles (in target but not in local)");
795 eprintln!("─────────────────────────────────────────────────────────────");
796
797 if verbose || bundles.len() <= 20 {
798 let display_count = if bundles.len() > 20 && !verbose {
799 20
800 } else {
801 bundles.len()
802 };
803
804 for bundle in bundles.iter().take(display_count) {
805 eprintln!(" {}", bundle);
806 }
807
808 if bundles.len() > display_count {
809 eprintln!(
810 " ... and {} more (use -v to show all)",
811 bundles.len() - display_count
812 );
813 }
814 } else {
815 display_bundle_ranges(bundles, Some(bundles.len()));
816 }
817}
818
819fn show_extra_bundles(bundles: &[u32], verbose: bool) {
820 eprintln!("\nℹ️ Extra Bundles (in local but not in target)");
821 eprintln!("─────────────────────────────────────────────────────────────");
822
823 if verbose || bundles.len() <= 20 {
824 let display_count = if bundles.len() > 20 && !verbose {
825 20
826 } else {
827 bundles.len()
828 };
829
830 for bundle in bundles.iter().take(display_count) {
831 eprintln!(" {}", bundle);
832 }
833
834 if bundles.len() > display_count {
835 eprintln!(
836 " ... and {} more (use -v to show all)",
837 bundles.len() - display_count
838 );
839 }
840 } else {
841 display_bundle_ranges(bundles, Some(bundles.len()));
842 }
843}
844
845fn display_bundle_ranges(bundles: &[u32], total_count: Option<usize>) {
846 if bundles.is_empty() {
847 return;
848 }
849
850 let mut range_start = bundles[0];
851 let mut range_end = bundles[0];
852 let mut ranges = Vec::new();
853
854 for window in bundles.windows(2) {
855 if window[1] == range_end + 1 {
856 range_end = window[1];
857 } else {
858 ranges.push((range_start, range_end));
859 range_start = window[1];
860 range_end = window[1];
861 }
862 }
863
864 ranges.push((range_start, range_end));
865
866 // Display all ranges except the last one
867 for (start, end) in ranges.iter().take(ranges.len().saturating_sub(1)) {
868 if start == end {
869 eprintln!(" {}", start);
870 } else {
871 eprintln!(" {} - {}", start, end);
872 }
873 }
874
875 // Display the last range with optional count
876 if let Some((start, end)) = ranges.last() {
877 if start == end {
878 if let Some(count) = total_count {
879 eprintln!(
880 " {} ({} bundle{})",
881 start,
882 count,
883 if count == 1 { "" } else { "s" }
884 );
885 } else {
886 eprintln!(" {}", start);
887 }
888 } else if let Some(count) = total_count {
889 eprintln!(
890 " {} - {} ({} bundle{})",
891 start,
892 end,
893 count,
894 if count == 1 { "" } else { "s" }
895 );
896 } else {
897 eprintln!(" {} - {}", start, end);
898 }
899 }
900}
901
902fn display_bundle_metadata_comparison_full(
903 local_ops: &[plcbundle::Operation],
904 local_meta: &plcbundle::index::BundleMetadata,
905 remote_meta: &plcbundle::index::BundleMetadata,
906 _bundle_num: u32,
907 origins_match: bool,
908) {
909 if !origins_match {
910 eprintln!(
911 "⚠️ NOTE: Comparing bundles from different origins - differences are expected\n"
912 );
913 }
914 eprintln!("📋 Metadata Comparison");
915 eprintln!("───────────────────────\n");
916
917 eprintln!(" Bundle Number: {}", remote_meta.bundle_number);
918
919 // Compare operation counts
920 let op_count_match = local_ops.len() == remote_meta.operation_count as usize;
921 eprintln!(
922 " Operation Count: {} {}",
923 if op_count_match {
924 format!("{}", local_ops.len())
925 } else {
926 format!(
927 "local={}, remote={}",
928 local_ops.len(),
929 remote_meta.operation_count
930 )
931 },
932 if op_count_match { "✅" } else { "❌" }
933 );
934 eprintln!(" Local: {}", local_ops.len());
935 eprintln!(" Remote: {}", remote_meta.operation_count);
936
937 // Compare DID counts
938 let did_count_match = local_meta.did_count == remote_meta.did_count;
939 eprintln!(
940 " DID Count: {} {}",
941 if did_count_match {
942 format!("{}", local_meta.did_count)
943 } else {
944 format!(
945 "local={}, remote={}",
946 local_meta.did_count, remote_meta.did_count
947 )
948 },
949 if did_count_match { "✅" } else { "❌" }
950 );
951
952 // Compare sizes
953 let size_match = local_meta.compressed_size == remote_meta.compressed_size;
954 eprintln!(
955 " Compressed Size: {} {}",
956 if size_match {
957 format!("{}", local_meta.compressed_size)
958 } else {
959 format!(
960 "local={}, remote={}",
961 local_meta.compressed_size, remote_meta.compressed_size
962 )
963 },
964 if size_match { "✅" } else { "❌" }
965 );
966
967 let uncomp_match = local_meta.uncompressed_size == remote_meta.uncompressed_size;
968 eprintln!(
969 " Uncompressed Size: {} {}",
970 if uncomp_match {
971 format!("{}", local_meta.uncompressed_size)
972 } else {
973 format!(
974 "local={}, remote={}",
975 local_meta.uncompressed_size, remote_meta.uncompressed_size
976 )
977 },
978 if uncomp_match { "✅" } else { "❌" }
979 );
980
981 // Compare times
982 let start_match = local_meta.start_time == remote_meta.start_time;
983 eprintln!(
984 " Start Time: {} {}",
985 if start_match { "identical" } else { "differs" },
986 if start_match { "✅" } else { "❌" }
987 );
988 eprintln!(" Local: {}", local_meta.start_time);
989 eprintln!(" Remote: {}", remote_meta.start_time);
990
991 let end_match = local_meta.end_time == remote_meta.end_time;
992 eprintln!(
993 " End Time: {} {}",
994 if end_match { "identical" } else { "differs" },
995 if end_match { "✅" } else { "❌" }
996 );
997 eprintln!(" Local: {}", local_meta.end_time);
998 eprintln!(" Remote: {}", remote_meta.end_time);
999
1000 // Compare cursor
1001 let cursor_match = local_meta.cursor == remote_meta.cursor;
1002 eprintln!(
1003 " Cursor: {} {}",
1004 if cursor_match { "identical" } else { "differs" },
1005 if cursor_match { "✅" } else { "❌" }
1006 );
1007 eprintln!(" Local: {}", local_meta.cursor);
1008 eprintln!(" Remote: {}", remote_meta.cursor);
1009 if !cursor_match {
1010 eprintln!(" ⚠️ Cursor should match previous bundle's end_time per spec");
1011 }
1012}
1013
1014fn display_operation_comparison(
1015 local_ops: &[plcbundle::Operation],
1016 remote_ops: &[plcbundle::Operation],
1017 sample_size: usize,
1018 origins_match: bool,
1019 bundle_num: u32,
1020 target: &str,
1021) {
1022 eprintln!("🔍 Operation Comparison");
1023 eprintln!("════════════════════════\n");
1024
1025 if !origins_match {
1026 eprintln!(" ⚠️ NOTE: Different origins - operation differences are expected\n");
1027 }
1028
1029 if local_ops.len() != remote_ops.len() {
1030 if origins_match {
1031 eprintln!(
1032 " ⚠️ Different operation counts: local={}, remote={}\n",
1033 local_ops.len(),
1034 remote_ops.len()
1035 );
1036 } else {
1037 eprintln!(
1038 " ℹ️ Different operation counts (expected): local={}, remote={}\n",
1039 local_ops.len(),
1040 remote_ops.len()
1041 );
1042 }
1043 }
1044
1045 // Build CID maps for comparison
1046 let mut local_cids: HashMap<String, usize> = HashMap::new();
1047 let mut remote_cids: HashMap<String, usize> = HashMap::new();
1048
1049 for (i, op) in local_ops.iter().enumerate() {
1050 if let Some(ref cid) = op.cid {
1051 local_cids.insert(cid.clone(), i);
1052 }
1053 }
1054
1055 for (i, op) in remote_ops.iter().enumerate() {
1056 if let Some(ref cid) = op.cid {
1057 remote_cids.insert(cid.clone(), i);
1058 }
1059 }
1060
1061 // Find differences
1062 let mut missing_in_local: Vec<(String, usize)> = Vec::new();
1063 let mut missing_in_remote: Vec<(String, usize)> = Vec::new();
1064 let mut position_mismatches: Vec<(String, usize, usize)> = Vec::new();
1065
1066 for (cid, remote_pos) in &remote_cids {
1067 match local_cids.get(cid) {
1068 Some(&local_pos) => {
1069 if local_pos != *remote_pos {
1070 position_mismatches.push((cid.clone(), local_pos, *remote_pos));
1071 }
1072 }
1073 None => {
1074 missing_in_local.push((cid.clone(), *remote_pos));
1075 }
1076 }
1077 }
1078
1079 for (cid, local_pos) in &local_cids {
1080 if !remote_cids.contains_key(cid) {
1081 missing_in_remote.push((cid.clone(), *local_pos));
1082 }
1083 }
1084
1085 // Sort by position
1086 missing_in_local.sort_by_key(|(_, pos)| *pos);
1087 missing_in_remote.sort_by_key(|(_, pos)| *pos);
1088 position_mismatches.sort_by_key(|(_, local_pos, _)| *local_pos);
1089
1090 // Display differences
1091 if !missing_in_local.is_empty() {
1092 eprintln!(
1093 " Missing in Local ({} operations):",
1094 missing_in_local.len()
1095 );
1096 let display_count = sample_size.min(missing_in_local.len());
1097 for (cid, pos) in missing_in_local.iter().take(display_count) {
1098 // Find the operation in remote_ops to get details
1099 if let Some(remote_op) = remote_ops.get(*pos) {
1100 eprintln!(" - [{:04}] {}", pos, cid);
1101 eprintln!(" DID: {}", remote_op.did);
1102 eprintln!(" Time: {}", remote_op.created_at);
1103 eprintln!(" Nullified: {}", remote_op.nullified);
1104 if let Some(op_type) = remote_op.operation.get("type").and_then(|v| v.as_str()) {
1105 eprintln!(" Type: {}", op_type);
1106 }
1107 } else {
1108 eprintln!(" - [{:04}] {}", pos, cid);
1109 }
1110 }
1111 if missing_in_local.len() > display_count {
1112 eprintln!(
1113 " ... and {} more",
1114 missing_in_local.len() - display_count
1115 );
1116 }
1117
1118 // Add hints for exploring missing operations
1119 if let Some((first_cid, first_pos)) = missing_in_local.first()
1120 && target.starts_with("http")
1121 {
1122 let base_url = if let Some(stripped) = target.strip_suffix('/') {
1123 stripped
1124 } else {
1125 target
1126 };
1127 let global_pos = bundle_position_to_global(bundle_num, *first_pos);
1128 eprintln!(" 💡 To explore missing operations:");
1129 eprintln!(
1130 " • Global position: {} (bundle {} position {})",
1131 global_pos, bundle_num, first_pos
1132 );
1133 eprintln!(
1134 " • View in remote: curl '{}/op/{}' | grep '{}' | jq .",
1135 base_url, global_pos, first_cid
1136 );
1137 }
1138 eprintln!();
1139 }
1140
1141 if !missing_in_remote.is_empty() {
1142 eprintln!(
1143 " Missing in Remote ({} operations):",
1144 missing_in_remote.len()
1145 );
1146 let display_count = sample_size.min(missing_in_remote.len());
1147 for (cid, pos) in missing_in_remote.iter().take(display_count) {
1148 // Find the operation in local_ops to get details
1149 if let Some(local_op) = local_ops.get(*pos) {
1150 eprintln!(" + [{:04}] {}", pos, cid);
1151 eprintln!(" DID: {}", local_op.did);
1152 eprintln!(" Time: {}", local_op.created_at);
1153 eprintln!(" Nullified: {}", local_op.nullified);
1154 if let Some(op_type) = local_op.operation.get("type").and_then(|v| v.as_str()) {
1155 eprintln!(" Type: {}", op_type);
1156 }
1157 } else {
1158 eprintln!(" + [{:04}] {}", pos, cid);
1159 }
1160 }
1161 if missing_in_remote.len() > display_count {
1162 eprintln!(
1163 " ... and {} more",
1164 missing_in_remote.len() - display_count
1165 );
1166 }
1167
1168 // Add hints for exploring missing operations
1169 if let Some((_first_cid, first_pos)) = missing_in_remote.first() {
1170 let global_pos = bundle_position_to_global(bundle_num, *first_pos);
1171 eprintln!(" 💡 To explore missing operations:");
1172 eprintln!(
1173 " • Global position: {} (bundle {} position {})",
1174 global_pos, bundle_num, first_pos
1175 );
1176 eprintln!(
1177 " • View in local bundle: {} op get {} {}",
1178 constants::BINARY_NAME,
1179 bundle_num,
1180 first_pos
1181 );
1182 }
1183 eprintln!();
1184 }
1185
1186 if !position_mismatches.is_empty() {
1187 eprintln!(
1188 " Position Mismatches ({} operations):",
1189 position_mismatches.len()
1190 );
1191 let display_count = sample_size.min(position_mismatches.len());
1192 for (cid, local_pos, remote_pos) in position_mismatches.iter().take(display_count) {
1193 eprintln!(" ~ {}", cid);
1194 eprintln!(" Local: position {:04}", local_pos);
1195 eprintln!(" Remote: position {:04}", remote_pos);
1196 }
1197 if position_mismatches.len() > display_count {
1198 eprintln!(
1199 " ... and {} more",
1200 position_mismatches.len() - display_count
1201 );
1202 }
1203 eprintln!();
1204 }
1205
1206 if missing_in_local.is_empty() && missing_in_remote.is_empty() && position_mismatches.is_empty()
1207 {
1208 eprintln!(" ✅ All operations match (same CIDs, same order)\n");
1209 }
1210
1211 // Show sample operations for context
1212 if !local_ops.is_empty() {
1213 eprintln!(
1214 "Sample Operations (first {}):",
1215 sample_size.min(local_ops.len())
1216 );
1217 eprintln!("────────────────────────────────");
1218 for (i, op) in local_ops
1219 .iter()
1220 .enumerate()
1221 .take(sample_size.min(local_ops.len()))
1222 {
1223 let remote_match = if let Some(ref cid) = op.cid {
1224 if let Some(&remote_pos) = remote_cids.get(cid) {
1225 if remote_pos == i {
1226 " ✅".to_string()
1227 } else {
1228 format!(" ⚠️ (remote pos: {:04})", remote_pos)
1229 }
1230 } else {
1231 " ❌ (missing in remote)".to_string()
1232 }
1233 } else {
1234 String::new()
1235 };
1236
1237 eprintln!(
1238 " [{:04}] {}{}",
1239 i,
1240 op.cid.as_ref().unwrap_or(&"<no-cid>".to_string()),
1241 remote_match
1242 );
1243 eprintln!(" DID: {}", op.did);
1244 eprintln!(" Time: {}", op.created_at);
1245 }
1246 eprintln!();
1247 }
1248}
1249
1250fn display_hash_analysis_full(
1251 local_meta: &plcbundle::index::BundleMetadata,
1252 remote_meta: &plcbundle::index::BundleMetadata,
1253 _bundle_num: u32,
1254 origins_match: bool,
1255) {
1256 eprintln!("🔐 Hash Analysis");
1257 eprintln!("════════════════\n");
1258
1259 if !origins_match {
1260 eprintln!(" ⚠️ NOTE: Different origins - hash mismatches are expected\n");
1261 }
1262
1263 // Content hash (most important)
1264 let content_match = local_meta.content_hash == remote_meta.content_hash;
1265 eprintln!(
1266 " Content Hash: {}",
1267 if content_match {
1268 "✅"
1269 } else if origins_match {
1270 "❌"
1271 } else {
1272 "ℹ️ (expected)"
1273 }
1274 );
1275 eprintln!(" Local: {}", local_meta.content_hash);
1276 eprintln!(" Remote: {}", remote_meta.content_hash);
1277 if !content_match {
1278 if origins_match {
1279 eprintln!(" ⚠️ Different bundle content!");
1280 } else {
1281 eprintln!(" ℹ️ Different bundle content (expected - different origins)");
1282 }
1283 }
1284 eprintln!();
1285
1286 // Compressed hash
1287 let comp_match = local_meta.compressed_hash == remote_meta.compressed_hash;
1288 eprintln!(
1289 " Compressed Hash: {}",
1290 if comp_match {
1291 "✅"
1292 } else if origins_match {
1293 "❌"
1294 } else {
1295 "ℹ️ (expected)"
1296 }
1297 );
1298 eprintln!(" Local: {}", local_meta.compressed_hash);
1299 eprintln!(" Remote: {}", remote_meta.compressed_hash);
1300 if !comp_match && content_match {
1301 eprintln!(" ℹ️ Different compression (same content)");
1302 } else if !comp_match && !origins_match {
1303 eprintln!(" ℹ️ Different compression (expected - different origins)");
1304 }
1305 eprintln!();
1306
1307 // Chain hash
1308 let chain_match = local_meta.hash == remote_meta.hash;
1309 eprintln!(
1310 " Chain Hash: {}",
1311 if chain_match {
1312 "✅"
1313 } else if origins_match {
1314 "❌"
1315 } else {
1316 "ℹ️ (expected)"
1317 }
1318 );
1319 eprintln!(" Local: {}", local_meta.hash);
1320 eprintln!(" Remote: {}", remote_meta.hash);
1321 if !chain_match {
1322 // Analyze why chain hash differs
1323 let parent_match = local_meta.parent == remote_meta.parent;
1324 eprintln!("\n Chain Components:");
1325 eprintln!(
1326 " Parent: {}",
1327 if parent_match {
1328 "✅"
1329 } else if origins_match {
1330 "❌"
1331 } else {
1332 "ℹ️ (expected)"
1333 }
1334 );
1335 eprintln!(" Local: {}", local_meta.parent);
1336 eprintln!(" Remote: {}", remote_meta.parent);
1337
1338 if !origins_match {
1339 eprintln!(" ℹ️ Different origins → different chains (expected)");
1340 } else if !parent_match {
1341 eprintln!(" ⚠️ Different parent → chain diverged at earlier bundle");
1342 } else if !content_match {
1343 eprintln!(" ⚠️ Same parent but different content → different operations");
1344 }
1345 }
1346}
1347
1348// Helper functions removed - using direct formatting with colors inline