High-performance implementation of plcbundle written in Rust
at main 1348 lines 48 kB view raw
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