// Compare command - compare repositories use anyhow::{Context, Result, bail}; use clap::{Args, ValueHint}; use plcbundle::constants::bundle_position_to_global; use plcbundle::{BundleManager, constants, remote}; use sonic_rs::JsonValueTrait; use std::collections::HashMap; use std::path::{Path, PathBuf}; use tokio::runtime::Runtime; use super::utils::colors; #[derive(Args)] #[command( alias = "diff", about = "Compare bundle repositories", long_about = "Compare your local repository against a remote instance or another local repository to identify differences, missing bundles, or integrity issues. Performs a comprehensive comparison of bundle indexes, detecting missing bundles (in target but not local), extra bundles (in local but not target), hash mismatches (indicating different content), and content hash differences. The command automatically detects when repositories are from different origins (PLC directory sources) and warns you that differences are expected in such cases. When origins match, hash mismatches indicate potential data corruption or synchronization issues that require investigation. Use --bundles with a specific bundle number for deep analysis that compares metadata and operations in detail. Use --show-operations to see operation-level differences and --sample to control how many operations are displayed. The target can be a remote HTTP URL (e.g., https://plc.example.com), a remote index URL, or a local file path to another repository's index file.", help_template = crate::clap_help!( examples: " # High-level comparison\n \ {bin} compare https://plc.example.com\n\n \ # Show all differences (verbose)\n \ {bin} compare https://plc.example.com -v\n\n \ # Deep dive into specific bundle\n \ {bin} compare https://plc.example.com --bundles 23\n\n \ # Compare bundle with operation samples\n \ {bin} compare https://plc.example.com --bundles 23 --show-operations\n\n \ # Show first 50 operations\n \ {bin} compare https://plc.example.com --bundles 23 --sample 50" ) )] pub struct CompareCommand { /// Target to compare against (URL or local path) #[arg(value_hint = ValueHint::AnyPath)] pub target: String, /// Deep comparison of specific bundle (alias: --bundles) #[arg(long)] pub bundles: Option, /// Show operation differences (use with --bundles) #[arg(long)] pub show_operations: bool, /// Number of sample operations to show (use with --bundles) #[arg(long, default_value = "10")] pub sample: usize, } pub fn run(cmd: CompareCommand, dir: PathBuf, global_verbose: bool) -> Result<()> { let manager = super::utils::create_manager(dir.clone(), global_verbose, false, false)?; let rt = Runtime::new()?; // If specific bundle requested, do detailed comparison if let Some(bundles_str) = cmd.bundles { let last_bundle = manager.get_last_bundle(); let bundle_nums = super::utils::parse_bundle_spec(Some(bundles_str), last_bundle)?; if bundle_nums.len() != 1 { anyhow::bail!( "--bundles must specify a single bundle number for comparison (e.g., \"23\")" ); } let bundle_num = bundle_nums[0]; rt.block_on(diff_specific_bundle( &manager, &cmd.target, bundle_num, cmd.show_operations, cmd.sample, )) } else { // Otherwise, do high-level index comparison rt.block_on(diff_indexes( &manager, &dir, &cmd.target, global_verbose, constants::BINARY_NAME, )) } } async fn diff_indexes( manager: &BundleManager, dir: &Path, target: &str, verbose: bool, binary_name: &str, ) -> Result<()> { use super::utils::display_path; // Resolve "." to actual path (rule: always resolve dot to full path) let local_path = display_path(dir); // Resolve target to full path if it's a local path (not URL) let target_display = if target.starts_with("http://") || target.starts_with("https://") { target.to_string() } else { // Local path - resolve to full path display_path(&PathBuf::from(target)).display().to_string() }; eprintln!("\nšŸ” Comparing repositories"); eprintln!(" Local: {}", local_path.display()); eprintln!(" Target: {}\n", target_display); // Load local index let local_index = manager.get_index(); // Load target index eprintln!("šŸ“„ Loading target index..."); let target_index = if target.starts_with("http://") || target.starts_with("https://") { let client = remote::RemoteClient::new(target)?; client .fetch_index() .await .context("Failed to load target index")? } else { remote::load_local_index(target).context("Failed to load target index")? }; // Check origins - CRITICAL: must match for valid comparison let local_origin = &local_index.origin; let target_origin = &target_index.origin; let origins_match = local_origin == target_origin; eprintln!("\n🌐 Origin Check"); eprintln!("═══════════════"); eprintln!(" Local Origin: {}", local_origin); eprintln!(" Target Origin: {}", target_origin); if !origins_match { eprintln!("\nāš ļø āš ļø āš ļø WARNING: DIFFERENT ORIGINS āš ļø āš ļø āš ļø"); eprintln!("═══════════════════════════════════════════════════════════════"); eprintln!(" The repositories are from DIFFERENT PLC directory sources!"); eprintln!(" Local: {}", local_origin); eprintln!(" Target: {}", target_origin); eprintln!("\n āš ļø Comparing bundles from different origins may show"); eprintln!(" differences that are expected and not errors."); eprintln!(" āš ļø Bundle numbers may overlap but contain different data."); eprintln!(" āš ļø Hash mismatches are expected when origins differ."); eprintln!("═══════════════════════════════════════════════════════════════\n"); } else { eprintln!(" āœ… Origins match - comparing same source\n"); } // Perform comparison let comparison = compare_indexes(&local_index, &target_index, origins_match); // Display results display_comparison(&comparison, verbose, origins_match); // If there are hash mismatches, suggest deep dive if !comparison.hash_mismatches.is_empty() { eprintln!("\nšŸ’” Tip: Investigate specific mismatches with:"); eprintln!( " {} compare {} --bundles {} --show-operations", binary_name, target, comparison.hash_mismatches[0].bundle_number ); } // Only fail if there are critical hash mismatches AND origins match // Missing/extra bundles are not errors, just informational // Hash mismatches are expected when origins differ if origins_match && (!comparison.hash_mismatches.is_empty() || !comparison.content_mismatches.is_empty()) { bail!("indexes have critical differences (hash mismatches)"); } Ok(()) } async fn diff_specific_bundle( manager: &BundleManager, target: &str, bundle_num: u32, show_ops: bool, sample_size: usize, ) -> Result<()> { // Store bundle_num for use in hints eprintln!("\nšŸ”¬ Deep Comparison: Bundle {}", bundle_num); eprintln!("═══════════════════════════════════════════════════════════════\n"); // Get local index and check origin let local_index = manager.get_index(); let local_origin = &local_index.origin; // Load remote index to get metadata and origin eprintln!("šŸ“„ Loading remote index..."); let remote_index = if target.starts_with("http://") || target.starts_with("https://") { let client = remote::RemoteClient::new(target)?; client .fetch_index() .await .context("Failed to load remote index")? } else { remote::load_local_index(target).context("Failed to load remote index")? }; let target_origin = &remote_index.origin; // Check origins - CRITICAL: must match for valid comparison let origins_match = local_origin == target_origin; eprintln!("\n🌐 Origin Check"); eprintln!("═══════════════"); eprintln!(" Local Origin: {}", local_origin); eprintln!(" Target Origin: {}", target_origin); if !origins_match { eprintln!("\nāš ļø āš ļø āš ļø WARNING: DIFFERENT ORIGINS āš ļø āš ļø āš ļø"); eprintln!("═══════════════════════════════════════════════════════════════"); eprintln!(" The repositories are from DIFFERENT PLC directory sources!"); eprintln!(" Local: {}", local_origin); eprintln!(" Target: {}", target_origin); eprintln!( "\n āš ļø Bundle {} may have the same number but contain", bundle_num ); eprintln!(" completely different data from different sources."); eprintln!(" āš ļø All differences shown below are EXPECTED when origins differ."); eprintln!(" āš ļø Do not treat hash/content mismatches as errors."); eprintln!("═══════════════════════════════════════════════════════════════\n"); } else { eprintln!(" āœ… Origins match - comparing same source\n"); } // Get local metadata let local_meta = manager .get_bundle_metadata(bundle_num)? .ok_or_else(|| anyhow::anyhow!("Bundle {} not found in local index", bundle_num))?; // Load local bundle eprintln!("šŸ“¦ Loading local bundle {}...", bundle_num); let local_result = manager.load_bundle(bundle_num, plcbundle::LoadOptions::default())?; let local_ops = local_result.operations; let remote_meta = remote_index .get_bundle(bundle_num) .ok_or_else(|| anyhow::anyhow!("Bundle {} not found in remote index", bundle_num))?; // Load remote bundle eprintln!("šŸ“¦ Loading remote bundle {}...\n", bundle_num); let remote_ops = if target.starts_with("http://") || target.starts_with("https://") { let client = remote::RemoteClient::new(target)?; client .fetch_bundle_operations(bundle_num) .await .context("Failed to load remote bundle")? } else { // Local path - try to load from directory use std::path::Path; let target_path = Path::new(target); let target_dir = if target_path.is_file() { target_path.parent().unwrap_or(Path::new(".")) } else { target_path }; // Try to load bundle from target directory let target_manager = super::utils::create_manager(target_dir.to_path_buf(), false, false, false)?; let target_result = target_manager .load_bundle(bundle_num, plcbundle::LoadOptions::default()) .context("Failed to load bundle from target directory")?; target_result.operations }; // Compare metadata display_bundle_metadata_comparison_full( &local_ops, &local_meta, remote_meta, bundle_num, origins_match, ); // Compare operations if show_ops { eprintln!(); display_operation_comparison( &local_ops, &remote_ops, sample_size, origins_match, bundle_num, target, ); } // Compare hashes in detail eprintln!(); display_hash_analysis_full(&local_meta, remote_meta, bundle_num, origins_match); Ok(()) } // Comparison structures #[derive(Debug, Default)] struct IndexComparison { local_count: usize, target_count: usize, common_count: usize, missing_bundles: Vec, extra_bundles: Vec, hash_mismatches: Vec, content_mismatches: Vec, cursor_mismatches: Vec, local_range: Option<(u32, u32)>, target_range: Option<(u32, u32)>, local_total_size: u64, target_total_size: u64, local_updated: String, target_updated: String, #[allow(dead_code)] origins_match: bool, } #[derive(Debug)] struct HashMismatch { bundle_number: u32, local_hash: String, target_hash: String, local_content_hash: String, target_content_hash: String, local_cursor: String, target_cursor: String, } impl IndexComparison { fn has_differences(&self) -> bool { !self.missing_bundles.is_empty() || !self.extra_bundles.is_empty() || !self.hash_mismatches.is_empty() || !self.content_mismatches.is_empty() || !self.cursor_mismatches.is_empty() } } // Compare two indexes - uses fully qualified path to avoid direct Index import fn compare_indexes( local: &plcbundle::index::Index, target: &plcbundle::index::Index, origins_match: bool, ) -> IndexComparison { let mut comparison = IndexComparison { origins_match, ..Default::default() }; let local_map: HashMap = local.bundles.iter().map(|b| (b.bundle_number, b)).collect(); let target_map: HashMap = target .bundles .iter() .map(|b| (b.bundle_number, b)) .collect(); comparison.local_count = local.bundles.len(); comparison.target_count = target.bundles.len(); comparison.local_total_size = local.total_size_bytes; comparison.target_total_size = target.total_size_bytes; comparison.local_updated = local.updated_at.clone(); comparison.target_updated = target.updated_at.clone(); // Get ranges if !local.bundles.is_empty() { comparison.local_range = Some(( local.bundles[0].bundle_number, local.bundles[local.bundles.len() - 1].bundle_number, )); } if !target.bundles.is_empty() { comparison.target_range = Some(( target.bundles[0].bundle_number, target.bundles[target.bundles.len() - 1].bundle_number, )); } // Find missing bundles for bundle_num in target_map.keys() { if !local_map.contains_key(bundle_num) { comparison.missing_bundles.push(*bundle_num); } } comparison.missing_bundles.sort(); // Find extra bundles for bundle_num in local_map.keys() { if !target_map.contains_key(bundle_num) { comparison.extra_bundles.push(*bundle_num); } } comparison.extra_bundles.sort(); // Compare hashes for (bundle_num, local_meta) in &local_map { if let Some(target_meta) = target_map.get(bundle_num) { comparison.common_count += 1; let chain_mismatch = local_meta.hash != target_meta.hash; let content_mismatch = local_meta.content_hash != target_meta.content_hash; let cursor_mismatch = local_meta.cursor != target_meta.cursor; if chain_mismatch || content_mismatch || cursor_mismatch { let mismatch = HashMismatch { bundle_number: *bundle_num, local_hash: local_meta.hash.clone(), target_hash: target_meta.hash.clone(), local_content_hash: local_meta.content_hash.clone(), target_content_hash: target_meta.content_hash.clone(), local_cursor: local_meta.cursor.clone(), target_cursor: target_meta.cursor.clone(), }; if chain_mismatch { comparison.hash_mismatches.push(mismatch); } else if content_mismatch { comparison.content_mismatches.push(mismatch); } else if cursor_mismatch { // Cursor-only mismatch (hashes match but cursor differs) comparison.cursor_mismatches.push(mismatch); } } } } // Sort mismatches comparison.hash_mismatches.sort_by_key(|m| m.bundle_number); comparison .content_mismatches .sort_by_key(|m| m.bundle_number); comparison .cursor_mismatches .sort_by_key(|m| m.bundle_number); comparison } fn display_comparison(c: &IndexComparison, verbose: bool, origins_match: bool) { eprintln!("\nšŸ“Š Comparison Results"); eprintln!("═══════════════════════\n"); if !origins_match { eprintln!("āš ļø COMPARING DIFFERENT ORIGINS - All differences are expected!\n"); } eprintln!("Summary"); eprintln!("───────"); eprintln!(" Local bundles: {}", c.local_count); eprintln!(" Target bundles: {}", c.target_count); eprintln!(" Common bundles: {}", c.common_count); // Missing bundles - informational, not critical if !c.missing_bundles.is_empty() { eprintln!( " Missing bundles: ā„¹ļø {} (in target, not in local)", c.missing_bundles.len() ); } else { eprintln!( " Missing bundles: {}{} āœ“{}", colors::GREEN, c.missing_bundles.len(), colors::RESET ); } // Extra bundles - informational if !c.extra_bundles.is_empty() { eprintln!( " Extra bundles: ā„¹ļø {} (in local, not in target)", c.extra_bundles.len() ); } else { eprintln!( " Extra bundles: {}{} āœ“{}", colors::GREEN, c.extra_bundles.len(), colors::RESET ); } // Hash mismatches - CRITICAL only if origins match if !c.hash_mismatches.is_empty() { if origins_match { eprintln!( " Hash mismatches: āš ļø {} (CRITICAL - different content)", c.hash_mismatches.len() ); } else { eprintln!( " Hash mismatches: ā„¹ļø {} (EXPECTED - different origins)", c.hash_mismatches.len() ); } } else { eprintln!( " Hash mismatches: {}{} āœ“{}", colors::GREEN, c.hash_mismatches.len(), colors::RESET ); } // Content mismatches - less critical than chain hash if !c.content_mismatches.is_empty() { if origins_match { eprintln!( " Content mismatches: āš ļø {} (different content hash)", c.content_mismatches.len() ); } else { eprintln!( " Content mismatches: ā„¹ļø {} (EXPECTED - different origins)", c.content_mismatches.len() ); } } else { eprintln!( " Content mismatches: {}{} āœ“{}", colors::GREEN, c.content_mismatches.len(), colors::RESET ); } // Cursor mismatches - metadata issue if !c.cursor_mismatches.is_empty() { eprintln!( " Cursor mismatches: āš ļø {} (cursor should match previous bundle end_time)", c.cursor_mismatches.len() ); } else { eprintln!( " Cursor mismatches: {}{} āœ“{}", colors::GREEN, c.cursor_mismatches.len(), colors::RESET ); } if let Some((start, end)) = c.local_range { eprintln!("\n Local range: {} - {}", start, end); eprintln!( " Local size: {:.2} MB", c.local_total_size as f64 / (1024.0 * 1024.0) ); eprintln!(" Local updated: {}", c.local_updated); } if let Some((start, end)) = c.target_range { eprintln!("\n Target range: {} - {}", start, end); eprintln!( " Target size: {:.2} MB", c.target_total_size as f64 / (1024.0 * 1024.0) ); eprintln!(" Target updated: {}", c.target_updated); } // Show differences if !c.hash_mismatches.is_empty() { show_hash_mismatches(&c.hash_mismatches, verbose, origins_match); } if !c.cursor_mismatches.is_empty() { show_cursor_mismatches(&c.cursor_mismatches, verbose); } if !c.missing_bundles.is_empty() { show_missing_bundles(&c.missing_bundles, verbose); } if !c.extra_bundles.is_empty() { show_extra_bundles(&c.extra_bundles, verbose); } // Chain analysis - find where chain broke if !c.hash_mismatches.is_empty() && origins_match { analyze_chain_break(&c.hash_mismatches, &c.local_range, &c.target_range); } // Final status eprintln!(); if !c.has_differences() { eprintln!("āœ… Indexes are identical"); } else if !origins_match { // Different origins - all differences are expected eprintln!("ā„¹ļø Indexes differ (EXPECTED - comparing different origins)"); eprintln!(" All differences shown above are normal when comparing"); eprintln!(" repositories from different PLC directory sources."); } else { // Same origins - differences may be critical if !c.hash_mismatches.is_empty() { eprintln!("āŒ Indexes differ (CRITICAL: hash mismatches detected)"); eprintln!("\nāš ļø WARNING: Chain hash mismatches indicate different bundle content"); eprintln!(" or chain integrity issues. This requires investigation."); } else { // Just missing/extra bundles - not critical eprintln!("ā„¹ļø Indexes differ (missing or extra bundles, but hashes match)"); eprintln!(" This is normal when comparing repositories at different sync states."); } } } fn show_hash_mismatches(mismatches: &[HashMismatch], verbose: bool, origins_match: bool) { if origins_match { eprintln!("\nāš ļø CHAIN HASH MISMATCHES (CRITICAL)"); eprintln!("═══════════════════════════════════════════════════\n"); } else { eprintln!("\nā„¹ļø CHAIN HASH MISMATCHES (EXPECTED - Different Origins)"); eprintln!("═══════════════════════════════════════════════════════════════\n"); } let display_count = if mismatches.len() > 10 && !verbose { 10 } else { mismatches.len() }; for m in mismatches.iter().take(display_count) { eprintln!(" Bundle {}:", m.bundle_number); eprintln!(" Chain Hash:"); eprintln!(" Local: {}", m.local_hash); eprintln!(" Target: {}", m.target_hash); if m.local_content_hash != m.target_content_hash { eprintln!(" Content Hash (also differs):"); eprintln!(" Local: {}", m.local_content_hash); eprintln!(" Target: {}", m.target_content_hash); } if m.local_cursor != m.target_cursor { eprintln!(" Cursor (also differs):"); eprintln!(" Local: {}", m.local_cursor); eprintln!(" Target: {}", m.target_cursor); } eprintln!(); } if mismatches.len() > display_count { eprintln!( " ... and {} more (use -v to show all)\n", mismatches.len() - display_count ); } } fn analyze_chain_break( mismatches: &[HashMismatch], _local_range: &Option<(u32, u32)>, _target_range: &Option<(u32, u32)>, ) { if mismatches.is_empty() { return; } eprintln!("\nšŸ”— Chain Break Analysis"); eprintln!("═══════════════════════════════════════════════════\n"); // Find the first bundle where chain broke let first_break = mismatches[0].bundle_number; // Determine last good bundle (one before first break) let last_good = first_break.saturating_sub(1); eprintln!(" Chain Status:"); if last_good > 0 { eprintln!(" āœ… Bundles 1 - {}: Chain intact", last_good); eprintln!( " āŒ Bundle {}: Chain broken (first mismatch)", first_break ); } else { eprintln!(" āŒ Bundle 1: Chain broken from start"); } // Count consecutive breaks let mut consecutive_breaks = 1; for window in mismatches.windows(2) { if window[1].bundle_number == first_break + consecutive_breaks { consecutive_breaks += 1; } else { break; } } if consecutive_breaks > 1 { let last_break = first_break + consecutive_breaks - 1; eprintln!( " āŒ Bundles {} - {}: Chain broken ({} consecutive)", first_break, last_break, consecutive_breaks ); } // Show total affected eprintln!("\n Summary:"); eprintln!(" Last good bundle: {}", last_good); eprintln!(" First break: {}", first_break); eprintln!(" Total mismatches: {}", mismatches.len()); // Show parent hash comparison for first break if let Some(first_mismatch) = mismatches.first() { eprintln!("\n First Break Details (Bundle {}):", first_break); eprintln!( " Local chain hash: {}", &first_mismatch.local_hash[..16] ); eprintln!( " Target chain hash: {}", &first_mismatch.target_hash[..16] ); // Check if parent hashes match (indicates where divergence started) eprintln!("\n šŸ’” Interpretation:"); if last_good > 0 { eprintln!(" • Bundles before {} are identical", last_good); eprintln!( " • Bundle {} has different content or was created differently", first_break ); eprintln!(" • All subsequent bundles will have different chain hashes"); eprintln!("\n To fix:"); eprintln!(" 1. Check bundle {} content differences", first_break); eprintln!( " 2. Verify operations in bundle {} match expected", first_break ); eprintln!( " 3. Consider re-syncing from bundle {} onwards", first_break ); } else { eprintln!(" • Chain broken from the very first bundle"); eprintln!(" • This indicates completely different repositories"); } } eprintln!(); } fn show_cursor_mismatches(mismatches: &[HashMismatch], verbose: bool) { eprintln!("\nāš ļø CURSOR MISMATCHES"); eprintln!("═══════════════════════════════════════════════════"); eprintln!(" Cursor should match previous bundle's end_time per spec.\n"); let display_count = if mismatches.len() > 10 && !verbose { 10 } else { mismatches.len() }; for m in mismatches.iter().take(display_count) { let local = if m.local_cursor.is_empty() { "(empty)" } else { &m.local_cursor }; eprintln!( " {}: Local: {} → Target: {}", m.bundle_number, local, m.target_cursor ); } if mismatches.len() > display_count { eprintln!( " ... and {} more (use -v to show all)", mismatches.len() - display_count ); } } fn show_missing_bundles(bundles: &[u32], verbose: bool) { eprintln!("\nā„¹ļø Missing Bundles (in target but not in local)"); eprintln!("─────────────────────────────────────────────────────────────"); if verbose || bundles.len() <= 20 { let display_count = if bundles.len() > 20 && !verbose { 20 } else { bundles.len() }; for bundle in bundles.iter().take(display_count) { eprintln!(" {}", bundle); } if bundles.len() > display_count { eprintln!( " ... and {} more (use -v to show all)", bundles.len() - display_count ); } } else { display_bundle_ranges(bundles, Some(bundles.len())); } } fn show_extra_bundles(bundles: &[u32], verbose: bool) { eprintln!("\nā„¹ļø Extra Bundles (in local but not in target)"); eprintln!("─────────────────────────────────────────────────────────────"); if verbose || bundles.len() <= 20 { let display_count = if bundles.len() > 20 && !verbose { 20 } else { bundles.len() }; for bundle in bundles.iter().take(display_count) { eprintln!(" {}", bundle); } if bundles.len() > display_count { eprintln!( " ... and {} more (use -v to show all)", bundles.len() - display_count ); } } else { display_bundle_ranges(bundles, Some(bundles.len())); } } fn display_bundle_ranges(bundles: &[u32], total_count: Option) { if bundles.is_empty() { return; } let mut range_start = bundles[0]; let mut range_end = bundles[0]; let mut ranges = Vec::new(); for window in bundles.windows(2) { if window[1] == range_end + 1 { range_end = window[1]; } else { ranges.push((range_start, range_end)); range_start = window[1]; range_end = window[1]; } } ranges.push((range_start, range_end)); // Display all ranges except the last one for (start, end) in ranges.iter().take(ranges.len().saturating_sub(1)) { if start == end { eprintln!(" {}", start); } else { eprintln!(" {} - {}", start, end); } } // Display the last range with optional count if let Some((start, end)) = ranges.last() { if start == end { if let Some(count) = total_count { eprintln!( " {} ({} bundle{})", start, count, if count == 1 { "" } else { "s" } ); } else { eprintln!(" {}", start); } } else if let Some(count) = total_count { eprintln!( " {} - {} ({} bundle{})", start, end, count, if count == 1 { "" } else { "s" } ); } else { eprintln!(" {} - {}", start, end); } } } fn display_bundle_metadata_comparison_full( local_ops: &[plcbundle::Operation], local_meta: &plcbundle::index::BundleMetadata, remote_meta: &plcbundle::index::BundleMetadata, _bundle_num: u32, origins_match: bool, ) { if !origins_match { eprintln!( "āš ļø NOTE: Comparing bundles from different origins - differences are expected\n" ); } eprintln!("šŸ“‹ Metadata Comparison"); eprintln!("───────────────────────\n"); eprintln!(" Bundle Number: {}", remote_meta.bundle_number); // Compare operation counts let op_count_match = local_ops.len() == remote_meta.operation_count as usize; eprintln!( " Operation Count: {} {}", if op_count_match { format!("{}", local_ops.len()) } else { format!( "local={}, remote={}", local_ops.len(), remote_meta.operation_count ) }, if op_count_match { "āœ…" } else { "āŒ" } ); eprintln!(" Local: {}", local_ops.len()); eprintln!(" Remote: {}", remote_meta.operation_count); // Compare DID counts let did_count_match = local_meta.did_count == remote_meta.did_count; eprintln!( " DID Count: {} {}", if did_count_match { format!("{}", local_meta.did_count) } else { format!( "local={}, remote={}", local_meta.did_count, remote_meta.did_count ) }, if did_count_match { "āœ…" } else { "āŒ" } ); // Compare sizes let size_match = local_meta.compressed_size == remote_meta.compressed_size; eprintln!( " Compressed Size: {} {}", if size_match { format!("{}", local_meta.compressed_size) } else { format!( "local={}, remote={}", local_meta.compressed_size, remote_meta.compressed_size ) }, if size_match { "āœ…" } else { "āŒ" } ); let uncomp_match = local_meta.uncompressed_size == remote_meta.uncompressed_size; eprintln!( " Uncompressed Size: {} {}", if uncomp_match { format!("{}", local_meta.uncompressed_size) } else { format!( "local={}, remote={}", local_meta.uncompressed_size, remote_meta.uncompressed_size ) }, if uncomp_match { "āœ…" } else { "āŒ" } ); // Compare times let start_match = local_meta.start_time == remote_meta.start_time; eprintln!( " Start Time: {} {}", if start_match { "identical" } else { "differs" }, if start_match { "āœ…" } else { "āŒ" } ); eprintln!(" Local: {}", local_meta.start_time); eprintln!(" Remote: {}", remote_meta.start_time); let end_match = local_meta.end_time == remote_meta.end_time; eprintln!( " End Time: {} {}", if end_match { "identical" } else { "differs" }, if end_match { "āœ…" } else { "āŒ" } ); eprintln!(" Local: {}", local_meta.end_time); eprintln!(" Remote: {}", remote_meta.end_time); // Compare cursor let cursor_match = local_meta.cursor == remote_meta.cursor; eprintln!( " Cursor: {} {}", if cursor_match { "identical" } else { "differs" }, if cursor_match { "āœ…" } else { "āŒ" } ); eprintln!(" Local: {}", local_meta.cursor); eprintln!(" Remote: {}", remote_meta.cursor); if !cursor_match { eprintln!(" āš ļø Cursor should match previous bundle's end_time per spec"); } } fn display_operation_comparison( local_ops: &[plcbundle::Operation], remote_ops: &[plcbundle::Operation], sample_size: usize, origins_match: bool, bundle_num: u32, target: &str, ) { eprintln!("šŸ” Operation Comparison"); eprintln!("════════════════════════\n"); if !origins_match { eprintln!(" āš ļø NOTE: Different origins - operation differences are expected\n"); } if local_ops.len() != remote_ops.len() { if origins_match { eprintln!( " āš ļø Different operation counts: local={}, remote={}\n", local_ops.len(), remote_ops.len() ); } else { eprintln!( " ā„¹ļø Different operation counts (expected): local={}, remote={}\n", local_ops.len(), remote_ops.len() ); } } // Build CID maps for comparison let mut local_cids: HashMap = HashMap::new(); let mut remote_cids: HashMap = HashMap::new(); for (i, op) in local_ops.iter().enumerate() { if let Some(ref cid) = op.cid { local_cids.insert(cid.clone(), i); } } for (i, op) in remote_ops.iter().enumerate() { if let Some(ref cid) = op.cid { remote_cids.insert(cid.clone(), i); } } // Find differences let mut missing_in_local: Vec<(String, usize)> = Vec::new(); let mut missing_in_remote: Vec<(String, usize)> = Vec::new(); let mut position_mismatches: Vec<(String, usize, usize)> = Vec::new(); for (cid, remote_pos) in &remote_cids { match local_cids.get(cid) { Some(&local_pos) => { if local_pos != *remote_pos { position_mismatches.push((cid.clone(), local_pos, *remote_pos)); } } None => { missing_in_local.push((cid.clone(), *remote_pos)); } } } for (cid, local_pos) in &local_cids { if !remote_cids.contains_key(cid) { missing_in_remote.push((cid.clone(), *local_pos)); } } // Sort by position missing_in_local.sort_by_key(|(_, pos)| *pos); missing_in_remote.sort_by_key(|(_, pos)| *pos); position_mismatches.sort_by_key(|(_, local_pos, _)| *local_pos); // Display differences if !missing_in_local.is_empty() { eprintln!( " Missing in Local ({} operations):", missing_in_local.len() ); let display_count = sample_size.min(missing_in_local.len()); for (cid, pos) in missing_in_local.iter().take(display_count) { // Find the operation in remote_ops to get details if let Some(remote_op) = remote_ops.get(*pos) { eprintln!(" - [{:04}] {}", pos, cid); eprintln!(" DID: {}", remote_op.did); eprintln!(" Time: {}", remote_op.created_at); eprintln!(" Nullified: {}", remote_op.nullified); if let Some(op_type) = remote_op.operation.get("type").and_then(|v| v.as_str()) { eprintln!(" Type: {}", op_type); } } else { eprintln!(" - [{:04}] {}", pos, cid); } } if missing_in_local.len() > display_count { eprintln!( " ... and {} more", missing_in_local.len() - display_count ); } // Add hints for exploring missing operations if let Some((first_cid, first_pos)) = missing_in_local.first() && target.starts_with("http") { let base_url = if let Some(stripped) = target.strip_suffix('/') { stripped } else { target }; let global_pos = bundle_position_to_global(bundle_num, *first_pos); eprintln!(" šŸ’” To explore missing operations:"); eprintln!( " • Global position: {} (bundle {} position {})", global_pos, bundle_num, first_pos ); eprintln!( " • View in remote: curl '{}/op/{}' | grep '{}' | jq .", base_url, global_pos, first_cid ); } eprintln!(); } if !missing_in_remote.is_empty() { eprintln!( " Missing in Remote ({} operations):", missing_in_remote.len() ); let display_count = sample_size.min(missing_in_remote.len()); for (cid, pos) in missing_in_remote.iter().take(display_count) { // Find the operation in local_ops to get details if let Some(local_op) = local_ops.get(*pos) { eprintln!(" + [{:04}] {}", pos, cid); eprintln!(" DID: {}", local_op.did); eprintln!(" Time: {}", local_op.created_at); eprintln!(" Nullified: {}", local_op.nullified); if let Some(op_type) = local_op.operation.get("type").and_then(|v| v.as_str()) { eprintln!(" Type: {}", op_type); } } else { eprintln!(" + [{:04}] {}", pos, cid); } } if missing_in_remote.len() > display_count { eprintln!( " ... and {} more", missing_in_remote.len() - display_count ); } // Add hints for exploring missing operations if let Some((_first_cid, first_pos)) = missing_in_remote.first() { let global_pos = bundle_position_to_global(bundle_num, *first_pos); eprintln!(" šŸ’” To explore missing operations:"); eprintln!( " • Global position: {} (bundle {} position {})", global_pos, bundle_num, first_pos ); eprintln!( " • View in local bundle: {} op get {} {}", constants::BINARY_NAME, bundle_num, first_pos ); } eprintln!(); } if !position_mismatches.is_empty() { eprintln!( " Position Mismatches ({} operations):", position_mismatches.len() ); let display_count = sample_size.min(position_mismatches.len()); for (cid, local_pos, remote_pos) in position_mismatches.iter().take(display_count) { eprintln!(" ~ {}", cid); eprintln!(" Local: position {:04}", local_pos); eprintln!(" Remote: position {:04}", remote_pos); } if position_mismatches.len() > display_count { eprintln!( " ... and {} more", position_mismatches.len() - display_count ); } eprintln!(); } if missing_in_local.is_empty() && missing_in_remote.is_empty() && position_mismatches.is_empty() { eprintln!(" āœ… All operations match (same CIDs, same order)\n"); } // Show sample operations for context if !local_ops.is_empty() { eprintln!( "Sample Operations (first {}):", sample_size.min(local_ops.len()) ); eprintln!("────────────────────────────────"); for (i, op) in local_ops .iter() .enumerate() .take(sample_size.min(local_ops.len())) { let remote_match = if let Some(ref cid) = op.cid { if let Some(&remote_pos) = remote_cids.get(cid) { if remote_pos == i { " āœ…".to_string() } else { format!(" āš ļø (remote pos: {:04})", remote_pos) } } else { " āŒ (missing in remote)".to_string() } } else { String::new() }; eprintln!( " [{:04}] {}{}", i, op.cid.as_ref().unwrap_or(&"".to_string()), remote_match ); eprintln!(" DID: {}", op.did); eprintln!(" Time: {}", op.created_at); } eprintln!(); } } fn display_hash_analysis_full( local_meta: &plcbundle::index::BundleMetadata, remote_meta: &plcbundle::index::BundleMetadata, _bundle_num: u32, origins_match: bool, ) { eprintln!("šŸ” Hash Analysis"); eprintln!("════════════════\n"); if !origins_match { eprintln!(" āš ļø NOTE: Different origins - hash mismatches are expected\n"); } // Content hash (most important) let content_match = local_meta.content_hash == remote_meta.content_hash; eprintln!( " Content Hash: {}", if content_match { "āœ…" } else if origins_match { "āŒ" } else { "ā„¹ļø (expected)" } ); eprintln!(" Local: {}", local_meta.content_hash); eprintln!(" Remote: {}", remote_meta.content_hash); if !content_match { if origins_match { eprintln!(" āš ļø Different bundle content!"); } else { eprintln!(" ā„¹ļø Different bundle content (expected - different origins)"); } } eprintln!(); // Compressed hash let comp_match = local_meta.compressed_hash == remote_meta.compressed_hash; eprintln!( " Compressed Hash: {}", if comp_match { "āœ…" } else if origins_match { "āŒ" } else { "ā„¹ļø (expected)" } ); eprintln!(" Local: {}", local_meta.compressed_hash); eprintln!(" Remote: {}", remote_meta.compressed_hash); if !comp_match && content_match { eprintln!(" ā„¹ļø Different compression (same content)"); } else if !comp_match && !origins_match { eprintln!(" ā„¹ļø Different compression (expected - different origins)"); } eprintln!(); // Chain hash let chain_match = local_meta.hash == remote_meta.hash; eprintln!( " Chain Hash: {}", if chain_match { "āœ…" } else if origins_match { "āŒ" } else { "ā„¹ļø (expected)" } ); eprintln!(" Local: {}", local_meta.hash); eprintln!(" Remote: {}", remote_meta.hash); if !chain_match { // Analyze why chain hash differs let parent_match = local_meta.parent == remote_meta.parent; eprintln!("\n Chain Components:"); eprintln!( " Parent: {}", if parent_match { "āœ…" } else if origins_match { "āŒ" } else { "ā„¹ļø (expected)" } ); eprintln!(" Local: {}", local_meta.parent); eprintln!(" Remote: {}", remote_meta.parent); if !origins_match { eprintln!(" ā„¹ļø Different origins → different chains (expected)"); } else if !parent_match { eprintln!(" āš ļø Different parent → chain diverged at earlier bundle"); } else if !content_match { eprintln!(" āš ļø Same parent but different content → different operations"); } } } // Helper functions removed - using direct formatting with colors inline