[DEPRECATED] Go implementation of plcbundle
at rust-test 798 lines 24 kB view raw
1package commands 2 3import ( 4 "context" 5 "fmt" 6 "io" 7 "net/http" 8 "sort" 9 "strings" 10 "time" 11 12 "github.com/goccy/go-json" 13 "github.com/spf13/cobra" 14 "tangled.org/atscan.net/plcbundle/bundle" 15 "tangled.org/atscan.net/plcbundle/internal/bundleindex" 16 "tangled.org/atscan.net/plcbundle/internal/plcclient" 17) 18 19func NewDiffCommand() *cobra.Command { 20 var ( 21 verbose bool 22 bundleNum int 23 showOperations bool 24 showSample int 25 ) 26 27 cmd := &cobra.Command{ 28 Use: "diff <target>", 29 Aliases: []string{"compare"}, 30 Short: "Compare repositories", 31 Long: `Compare local repository against remote or local target 32 33Compares bundle indexes to find differences such as: 34 • Missing bundles (in target but not local) 35 • Extra bundles (in local but not target) 36 • Hash mismatches (different content) 37 • Content mismatches (different data) 38 39For deeper analysis of specific bundles, use --bundle flag to see 40detailed differences in metadata and operations. 41 42The target can be: 43 • Remote HTTP URL (e.g., https://plc.example.com) 44 • Remote index URL (e.g., https://plc.example.com/index.json) 45 • Local file path (e.g., /path/to/plc_bundles.json)`, 46 47 Example: ` # High-level comparison 48 plcbundle diff https://plc.example.com 49 50 # Show all differences (verbose) 51 plcbundle diff https://plc.example.com -v 52 53 # Deep dive into specific bundle 54 plcbundle diff https://plc.example.com --bundle 23 55 56 # Compare bundle with operation samples 57 plcbundle diff https://plc.example.com --bundle 23 --show-operations 58 59 # Show first 50 operations 60 plcbundle diff https://plc.example.com --bundle 23 --sample 50 61 62 # Using alias 63 plcbundle compare https://plc.example.com`, 64 65 Args: cobra.ExactArgs(1), 66 67 RunE: func(cmd *cobra.Command, args []string) error { 68 target := args[0] 69 70 mgr, dir, err := getManager(&ManagerOptions{Cmd: cmd}) 71 if err != nil { 72 return err 73 } 74 defer mgr.Close() 75 76 // If specific bundle requested, do detailed diff 77 if bundleNum > 0 { 78 return diffSpecificBundle(mgr, target, bundleNum, showOperations, showSample) 79 } 80 81 // Otherwise, do high-level index comparison 82 return diffIndexes(mgr, dir, target, verbose) 83 }, 84 } 85 86 // Flags 87 cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show all differences (verbose output)") 88 cmd.Flags().IntVarP(&bundleNum, "bundle", "b", 0, "Deep diff of specific bundle") 89 cmd.Flags().BoolVar(&showOperations, "show-operations", false, "Show operation differences (use with --bundle)") 90 cmd.Flags().IntVar(&showSample, "sample", 10, "Number of sample operations to show (use with --bundle)") 91 92 return cmd 93} 94 95// diffIndexes performs high-level index comparison 96func diffIndexes(mgr BundleManager, dir string, target string, verbose bool) error { 97 fmt.Printf("Comparing: %s\n", dir) 98 fmt.Printf(" Against: %s\n\n", target) 99 100 // Load local index 101 localIndex := mgr.GetIndex() 102 103 // Load target index 104 fmt.Printf("Loading target index...\n") 105 targetIndex, err := loadTargetIndex(target) 106 if err != nil { 107 return fmt.Errorf("error loading target index: %w", err) 108 } 109 110 // Perform comparison 111 comparison := compareIndexes(localIndex, targetIndex) 112 113 // Display results 114 displayComparison(comparison, verbose) 115 116 // If there are hash mismatches, suggest deep dive 117 if len(comparison.HashMismatches) > 0 { 118 fmt.Printf("\n💡 Tip: Use --bundle flag to investigate specific mismatches:\n") 119 fmt.Printf(" plcbundle diff %s --bundle %d --show-operations\n", 120 target, comparison.HashMismatches[0].BundleNumber) 121 } 122 123 if comparison.HasDifferences() { 124 return fmt.Errorf("indexes have differences") 125 } 126 127 return nil 128} 129 130// diffSpecificBundle performs detailed comparison of a specific bundle 131func diffSpecificBundle(mgr BundleManager, target string, bundleNum int, showOps bool, sampleSize int) error { 132 ctx := context.Background() 133 134 fmt.Printf("Deep Diff: Bundle %06d\n", bundleNum) 135 fmt.Printf("═══════════════════════════════════════════════════════════════\n\n") 136 137 // Load local bundle 138 fmt.Printf("Loading local bundle %06d...\n", bundleNum) 139 localBundle, err := mgr.LoadBundle(ctx, bundleNum) 140 if err != nil { 141 return fmt.Errorf("failed to load local bundle: %w", err) 142 } 143 144 // Load remote index to get metadata 145 fmt.Printf("Loading remote index...\n") 146 remoteIndex, err := loadTargetIndex(target) 147 if err != nil { 148 return fmt.Errorf("failed to load remote index: %w", err) 149 } 150 151 remoteMeta, err := remoteIndex.GetBundle(bundleNum) 152 if err != nil { 153 return fmt.Errorf("bundle not found in remote index: %w", err) 154 } 155 156 // Load remote bundle 157 fmt.Printf("Loading remote bundle %06d...\n\n", bundleNum) 158 remoteOps, err := loadRemoteBundle(target, bundleNum) 159 if err != nil { 160 return fmt.Errorf("failed to load remote bundle: %w", err) 161 } 162 163 // Compare metadata 164 displayBundleMetadataComparison(localBundle, remoteMeta) 165 166 // Compare operations 167 if showOps { 168 fmt.Printf("\n") 169 displayOperationComparison(localBundle.Operations, remoteOps, sampleSize) 170 } 171 172 // Compare hashes in detail 173 fmt.Printf("\n") 174 displayHashAnalysis(localBundle, remoteMeta) 175 176 return nil 177} 178 179// displayBundleMetadataComparison shows metadata comparison 180func displayBundleMetadataComparison(local *bundle.Bundle, remote *bundleindex.BundleMetadata) { 181 fmt.Printf("Metadata Comparison\n") 182 fmt.Printf("───────────────────\n\n") 183 184 // Basic info 185 fmt.Printf(" Bundle Number: %06d\n", local.BundleNumber) 186 187 // Times 188 timeMatch := local.StartTime.Equal(remote.StartTime) && local.EndTime.Equal(remote.EndTime) 189 fmt.Printf(" Start Time: %s %s\n", 190 formatTimeDiff(local.StartTime, remote.StartTime), 191 statusIcon(timeMatch)) 192 fmt.Printf(" Local: %s\n", local.StartTime.Format(time.RFC3339)) 193 fmt.Printf(" Remote: %s\n", remote.StartTime.Format(time.RFC3339)) 194 195 fmt.Printf(" End Time: %s %s\n", 196 formatTimeDiff(local.EndTime, remote.EndTime), 197 statusIcon(timeMatch)) 198 fmt.Printf(" Local: %s\n", local.EndTime.Format(time.RFC3339)) 199 fmt.Printf(" Remote: %s\n", remote.EndTime.Format(time.RFC3339)) 200 201 // Counts 202 opCountMatch := len(local.Operations) == remote.OperationCount 203 didCountMatch := local.DIDCount == remote.DIDCount 204 205 fmt.Printf(" Operation Count: %s %s\n", 206 formatCountDiff(len(local.Operations), remote.OperationCount), 207 statusIcon(opCountMatch)) 208 fmt.Printf(" DID Count: %s %s\n", 209 formatCountDiff(local.DIDCount, remote.DIDCount), 210 statusIcon(didCountMatch)) 211 212 // Sizes 213 sizeMatch := local.CompressedSize == remote.CompressedSize 214 fmt.Printf(" Compressed Size: %s %s\n", 215 formatSizeDiff(local.CompressedSize, remote.CompressedSize), 216 statusIcon(sizeMatch)) 217 218 uncompMatch := local.UncompressedSize == remote.UncompressedSize 219 fmt.Printf(" Uncompressed Size: %s %s\n", 220 formatSizeDiff(local.UncompressedSize, remote.UncompressedSize), 221 statusIcon(uncompMatch)) 222} 223 224// displayHashAnalysis shows detailed hash comparison 225func displayHashAnalysis(local *bundle.Bundle, remote *bundleindex.BundleMetadata) { 226 fmt.Printf("Hash Analysis\n") 227 fmt.Printf("═════════════\n\n") 228 229 // Content hash (most important) 230 contentMatch := local.ContentHash == remote.ContentHash 231 fmt.Printf(" Content Hash: %s\n", statusIcon(contentMatch)) 232 fmt.Printf(" Local: %s\n", local.ContentHash) 233 fmt.Printf(" Remote: %s\n", remote.ContentHash) 234 if !contentMatch { 235 fmt.Printf(" ⚠️ Different bundle content!\n") 236 } 237 fmt.Printf("\n") 238 239 // Compressed hash 240 compMatch := local.CompressedHash == remote.CompressedHash 241 fmt.Printf(" Compressed Hash: %s\n", statusIcon(compMatch)) 242 fmt.Printf(" Local: %s\n", local.CompressedHash) 243 fmt.Printf(" Remote: %s\n", remote.CompressedHash) 244 if !compMatch && contentMatch { 245 fmt.Printf(" ℹ️ Different compression (same content)\n") 246 } 247 fmt.Printf("\n") 248 249 // Chain hash 250 chainMatch := local.Hash == remote.Hash 251 fmt.Printf(" Chain Hash: %s\n", statusIcon(chainMatch)) 252 fmt.Printf(" Local: %s\n", local.Hash) 253 fmt.Printf(" Remote: %s\n", remote.Hash) 254 if !chainMatch { 255 // Analyze why chain hash differs 256 parentMatch := local.Parent == remote.Parent 257 fmt.Printf("\n Chain Components:\n") 258 fmt.Printf(" Parent: %s\n", statusIcon(parentMatch)) 259 fmt.Printf(" Local: %s\n", local.Parent) 260 fmt.Printf(" Remote: %s\n", remote.Parent) 261 262 if !parentMatch { 263 fmt.Printf(" ⚠️ Different parent → chain diverged at earlier bundle\n") 264 } else if !contentMatch { 265 fmt.Printf(" ⚠️ Same parent but different content → different operations\n") 266 } 267 } 268} 269 270// displayOperationComparison shows operation differences 271func displayOperationComparison(localOps []plcclient.PLCOperation, remoteOps []plcclient.PLCOperation, sampleSize int) { 272 fmt.Printf("Operation Comparison\n") 273 fmt.Printf("════════════════════\n\n") 274 275 if len(localOps) != len(remoteOps) { 276 fmt.Printf(" ⚠️ Different operation counts: local=%d, remote=%d\n\n", 277 len(localOps), len(remoteOps)) 278 } 279 280 // Build CID sets for comparison 281 localCIDs := make(map[string]int) 282 remoteCIDs := make(map[string]int) 283 284 for i, op := range localOps { 285 localCIDs[op.CID] = i 286 } 287 for i, op := range remoteOps { 288 remoteCIDs[op.CID] = i 289 } 290 291 // Find differences - store as position+CID pairs 292 type cidWithPos struct { 293 cid string 294 pos int 295 } 296 297 var missingInLocal []cidWithPos 298 var missingInRemote []cidWithPos 299 var positionMismatches []cidWithPos 300 301 for cid, remotePos := range remoteCIDs { 302 if localPos, exists := localCIDs[cid]; !exists { 303 missingInLocal = append(missingInLocal, cidWithPos{cid, remotePos}) 304 } else if localPos != remotePos { 305 positionMismatches = append(positionMismatches, cidWithPos{cid, localPos}) 306 } 307 } 308 309 for cid, localPos := range localCIDs { 310 if _, exists := remoteCIDs[cid]; !exists { 311 missingInRemote = append(missingInRemote, cidWithPos{cid, localPos}) 312 } 313 } 314 315 // Sort by position 316 sort.Slice(missingInLocal, func(i, j int) bool { 317 return missingInLocal[i].pos < missingInLocal[j].pos 318 }) 319 sort.Slice(missingInRemote, func(i, j int) bool { 320 return missingInRemote[i].pos < missingInRemote[j].pos 321 }) 322 sort.Slice(positionMismatches, func(i, j int) bool { 323 return positionMismatches[i].pos < positionMismatches[j].pos 324 }) 325 326 // Display differences 327 if len(missingInLocal) > 0 { 328 fmt.Printf(" Missing in Local (%d operations):\n", len(missingInLocal)) 329 displaySample := min(sampleSize, len(missingInLocal)) 330 for i := 0; i < displaySample; i++ { 331 item := missingInLocal[i] 332 fmt.Printf(" - [%04d] %s\n", item.pos, item.cid) 333 } 334 if len(missingInLocal) > displaySample { 335 fmt.Printf(" ... and %d more\n", len(missingInLocal)-displaySample) 336 } 337 fmt.Printf("\n") 338 } 339 340 if len(missingInRemote) > 0 { 341 fmt.Printf(" Missing in Remote (%d operations):\n", len(missingInRemote)) 342 displaySample := min(sampleSize, len(missingInRemote)) 343 for i := 0; i < displaySample; i++ { 344 item := missingInRemote[i] 345 fmt.Printf(" + [%04d] %s\n", item.pos, item.cid) 346 } 347 if len(missingInRemote) > displaySample { 348 fmt.Printf(" ... and %d more\n", len(missingInRemote)-displaySample) 349 } 350 fmt.Printf("\n") 351 } 352 353 if len(positionMismatches) > 0 { 354 fmt.Printf(" Position Mismatches (%d operations):\n", len(positionMismatches)) 355 displaySample := min(sampleSize, len(positionMismatches)) 356 for i := 0; i < displaySample; i++ { 357 item := positionMismatches[i] 358 remotePos := remoteCIDs[item.cid] 359 fmt.Printf(" ~ %s\n", item.cid) 360 fmt.Printf(" Local: position %04d\n", item.pos) 361 fmt.Printf(" Remote: position %04d\n", remotePos) 362 } 363 if len(positionMismatches) > displaySample { 364 fmt.Printf(" ... and %d more\n", len(positionMismatches)-displaySample) 365 } 366 fmt.Printf("\n") 367 } 368 369 if len(missingInLocal) == 0 && len(missingInRemote) == 0 && len(positionMismatches) == 0 { 370 fmt.Printf(" ✓ All operations match (same CIDs, same order)\n\n") 371 } 372 373 // Show sample operations for context 374 if len(localOps) > 0 { 375 fmt.Printf("Sample Operations (first %d):\n", min(sampleSize, len(localOps))) 376 fmt.Printf("────────────────────────────────\n") 377 for i := 0; i < min(sampleSize, len(localOps)); i++ { 378 op := localOps[i] 379 remoteMatch := "" 380 if remotePos, exists := remoteCIDs[op.CID]; exists { 381 if remotePos == i { 382 remoteMatch = " ✓" 383 } else { 384 remoteMatch = fmt.Sprintf(" ~ (remote pos: %04d)", remotePos) 385 } 386 } else { 387 remoteMatch = " ✗ (missing in remote)" 388 } 389 390 fmt.Printf(" [%04d] %s%s\n", i, op.CID, remoteMatch) 391 fmt.Printf(" DID: %s\n", op.DID) 392 fmt.Printf(" Time: %s\n", op.CreatedAt.Format(time.RFC3339)) 393 } 394 fmt.Printf("\n") 395 } 396} 397 398// loadRemoteBundle loads a bundle from remote server 399func loadRemoteBundle(baseURL string, bundleNum int) ([]plcclient.PLCOperation, error) { 400 // Determine the data URL 401 url := baseURL 402 if !strings.HasSuffix(url, ".json") { 403 url = strings.TrimSuffix(url, "/") 404 } 405 url = strings.TrimSuffix(url, "/index.json") 406 url = strings.TrimSuffix(url, "/plc_bundles.json") 407 url = fmt.Sprintf("%s/jsonl/%d", url, bundleNum) 408 409 client := &http.Client{Timeout: 60 * time.Second} 410 411 resp, err := client.Get(url) 412 if err != nil { 413 return nil, fmt.Errorf("failed to download: %w", err) 414 } 415 defer resp.Body.Close() 416 417 if resp.StatusCode != http.StatusOK { 418 return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 419 } 420 421 // Parse JSONL 422 var operations []plcclient.PLCOperation 423 decoder := json.NewDecoder(resp.Body) 424 425 for { 426 var op plcclient.PLCOperation 427 if err := decoder.Decode(&op); err != nil { 428 if err == io.EOF { 429 break 430 } 431 return nil, fmt.Errorf("failed to parse operation: %w", err) 432 } 433 operations = append(operations, op) 434 } 435 436 return operations, nil 437} 438 439// loadTargetIndex loads index from URL or local path 440func loadTargetIndex(target string) (*bundleindex.Index, error) { 441 if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { 442 return loadIndexFromURL(target) 443 } 444 return bundleindex.LoadIndex(target) 445} 446 447// loadIndexFromURL loads index from remote URL 448func loadIndexFromURL(url string) (*bundleindex.Index, error) { 449 if !strings.HasSuffix(url, ".json") { 450 url = strings.TrimSuffix(url, "/") + "/index.json" 451 } 452 453 client := &http.Client{Timeout: 30 * time.Second} 454 455 resp, err := client.Get(url) 456 if err != nil { 457 return nil, fmt.Errorf("failed to download: %w", err) 458 } 459 defer resp.Body.Close() 460 461 if resp.StatusCode != http.StatusOK { 462 return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 463 } 464 465 data, err := io.ReadAll(resp.Body) 466 if err != nil { 467 return nil, fmt.Errorf("failed to read response: %w", err) 468 } 469 470 var idx bundleindex.Index 471 if err := json.Unmarshal(data, &idx); err != nil { 472 return nil, fmt.Errorf("failed to parse index: %w", err) 473 } 474 475 return &idx, nil 476} 477 478// compareIndexes performs the actual comparison 479func compareIndexes(local, target *bundleindex.Index) *IndexComparison { 480 localBundles := local.GetBundles() 481 targetBundles := target.GetBundles() 482 483 localMap := make(map[int]*bundleindex.BundleMetadata) 484 targetMap := make(map[int]*bundleindex.BundleMetadata) 485 486 for _, b := range localBundles { 487 localMap[b.BundleNumber] = b 488 } 489 for _, b := range targetBundles { 490 targetMap[b.BundleNumber] = b 491 } 492 493 comparison := &IndexComparison{ 494 LocalCount: len(localBundles), 495 TargetCount: len(targetBundles), 496 MissingBundles: make([]int, 0), 497 ExtraBundles: make([]int, 0), 498 HashMismatches: make([]HashMismatch, 0), 499 ContentMismatches: make([]HashMismatch, 0), 500 } 501 502 // Get ranges 503 if len(localBundles) > 0 { 504 comparison.LocalRange = [2]int{localBundles[0].BundleNumber, localBundles[len(localBundles)-1].BundleNumber} 505 comparison.LocalUpdated = local.UpdatedAt 506 comparison.LocalTotalSize = local.TotalSize 507 } 508 509 if len(targetBundles) > 0 { 510 comparison.TargetRange = [2]int{targetBundles[0].BundleNumber, targetBundles[len(targetBundles)-1].BundleNumber} 511 comparison.TargetUpdated = target.UpdatedAt 512 comparison.TargetTotalSize = target.TotalSize 513 } 514 515 // Find missing bundles 516 for bundleNum := range targetMap { 517 if _, exists := localMap[bundleNum]; !exists { 518 comparison.MissingBundles = append(comparison.MissingBundles, bundleNum) 519 } 520 } 521 sort.Ints(comparison.MissingBundles) 522 523 // Find extra bundles 524 for bundleNum := range localMap { 525 if _, exists := targetMap[bundleNum]; !exists { 526 comparison.ExtraBundles = append(comparison.ExtraBundles, bundleNum) 527 } 528 } 529 sort.Ints(comparison.ExtraBundles) 530 531 // Compare hashes 532 for bundleNum, localMeta := range localMap { 533 if targetMeta, exists := targetMap[bundleNum]; exists { 534 comparison.CommonCount++ 535 536 chainMismatch := localMeta.Hash != targetMeta.Hash 537 contentMismatch := localMeta.ContentHash != targetMeta.ContentHash 538 539 if chainMismatch || contentMismatch { 540 mismatch := HashMismatch{ 541 BundleNumber: bundleNum, 542 LocalHash: localMeta.Hash, 543 TargetHash: targetMeta.Hash, 544 LocalContentHash: localMeta.ContentHash, 545 TargetContentHash: targetMeta.ContentHash, 546 } 547 548 if chainMismatch { 549 comparison.HashMismatches = append(comparison.HashMismatches, mismatch) 550 } 551 if contentMismatch && !chainMismatch { 552 comparison.ContentMismatches = append(comparison.ContentMismatches, mismatch) 553 } 554 } 555 } 556 } 557 558 // ADD THIS: Sort mismatches by bundle number 559 sort.Slice(comparison.HashMismatches, func(i, j int) bool { 560 return comparison.HashMismatches[i].BundleNumber < comparison.HashMismatches[j].BundleNumber 561 }) 562 sort.Slice(comparison.ContentMismatches, func(i, j int) bool { 563 return comparison.ContentMismatches[i].BundleNumber < comparison.ContentMismatches[j].BundleNumber 564 }) 565 566 return comparison 567} 568 569// displayComparison shows comparison results 570func displayComparison(c *IndexComparison, verbose bool) { 571 fmt.Printf("Comparison Results\n") 572 fmt.Printf("══════════════════\n\n") 573 574 fmt.Printf("Summary\n───────\n") 575 fmt.Printf(" Local bundles: %d\n", c.LocalCount) 576 fmt.Printf(" Target bundles: %d\n", c.TargetCount) 577 fmt.Printf(" Common bundles: %d\n", c.CommonCount) 578 fmt.Printf(" Missing bundles: %s\n", formatCount(len(c.MissingBundles))) 579 fmt.Printf(" Extra bundles: %s\n", formatCount(len(c.ExtraBundles))) 580 fmt.Printf(" Hash mismatches: %s\n", formatCountCritical(len(c.HashMismatches))) 581 fmt.Printf(" Content mismatches: %s\n", formatCount(len(c.ContentMismatches))) 582 583 if c.LocalCount > 0 { 584 fmt.Printf("\n Local range: %06d - %06d\n", c.LocalRange[0], c.LocalRange[1]) 585 fmt.Printf(" Local size: %.2f MB\n", float64(c.LocalTotalSize)/(1024*1024)) 586 fmt.Printf(" Local updated: %s\n", c.LocalUpdated.Format("2006-01-02 15:04:05")) 587 } 588 589 if c.TargetCount > 0 { 590 fmt.Printf("\n Target range: %06d - %06d\n", c.TargetRange[0], c.TargetRange[1]) 591 fmt.Printf(" Target size: %.2f MB\n", float64(c.TargetTotalSize)/(1024*1024)) 592 fmt.Printf(" Target updated: %s\n", c.TargetUpdated.Format("2006-01-02 15:04:05")) 593 } 594 595 // Show differences 596 if len(c.HashMismatches) > 0 { 597 showHashMismatches(c.HashMismatches, verbose) 598 } 599 600 if len(c.MissingBundles) > 0 { 601 showMissingBundles(c.MissingBundles, verbose) 602 } 603 604 if len(c.ExtraBundles) > 0 { 605 showExtraBundles(c.ExtraBundles, verbose) 606 } 607 608 // Final status 609 fmt.Printf("\n") 610 if !c.HasDifferences() { 611 fmt.Printf("✓ Indexes are identical\n") 612 } else { 613 fmt.Printf("✗ Indexes have differences\n") 614 if len(c.HashMismatches) > 0 { 615 fmt.Printf("\n⚠️ WARNING: Chain hash mismatches detected!\n") 616 fmt.Printf("This indicates different bundle content or chain integrity issues.\n") 617 } 618 } 619} 620 621// showHashMismatches displays hash mismatches 622func showHashMismatches(mismatches []HashMismatch, verbose bool) { 623 fmt.Printf("\n⚠️ CHAIN HASH MISMATCHES (CRITICAL)\n") 624 fmt.Printf("════════════════════════════════════\n\n") 625 626 displayCount := len(mismatches) 627 if displayCount > 10 && !verbose { 628 displayCount = 10 629 } 630 631 for i := 0; i < displayCount; i++ { 632 m := mismatches[i] 633 fmt.Printf(" Bundle %06d:\n", m.BundleNumber) 634 fmt.Printf(" Chain Hash:\n") 635 fmt.Printf(" Local: %s\n", m.LocalHash) 636 fmt.Printf(" Target: %s\n", m.TargetHash) 637 638 if m.LocalContentHash != m.TargetContentHash { 639 fmt.Printf(" Content Hash (also differs):\n") 640 fmt.Printf(" Local: %s\n", m.LocalContentHash) 641 fmt.Printf(" Target: %s\n", m.TargetContentHash) 642 } 643 fmt.Printf("\n") 644 } 645 646 if len(mismatches) > displayCount { 647 fmt.Printf(" ... and %d more (use -v to show all)\n\n", len(mismatches)-displayCount) 648 } 649} 650 651// showMissingBundles displays missing bundles 652func showMissingBundles(bundles []int, verbose bool) { 653 fmt.Printf("\nMissing Bundles (in target but not local)\n") 654 fmt.Printf("──────────────────────────────────────────\n") 655 656 if verbose || len(bundles) <= 20 { 657 displayCount := len(bundles) 658 if displayCount > 20 && !verbose { 659 displayCount = 20 660 } 661 662 for i := 0; i < displayCount; i++ { 663 fmt.Printf(" %06d\n", bundles[i]) 664 } 665 666 if len(bundles) > displayCount { 667 fmt.Printf(" ... and %d more (use -v to show all)\n", len(bundles)-displayCount) 668 } 669 } else { 670 displayBundleRanges(bundles) 671 } 672} 673 674// showExtraBundles displays extra bundles 675func showExtraBundles(bundles []int, verbose bool) { 676 fmt.Printf("\nExtra Bundles (in local but not target)\n") 677 fmt.Printf("────────────────────────────────────────\n") 678 679 if verbose || len(bundles) <= 20 { 680 displayCount := len(bundles) 681 if displayCount > 20 && !verbose { 682 displayCount = 20 683 } 684 685 for i := 0; i < displayCount; i++ { 686 fmt.Printf(" %06d\n", bundles[i]) 687 } 688 689 if len(bundles) > displayCount { 690 fmt.Printf(" ... and %d more (use -v to show all)\n", len(bundles)-displayCount) 691 } 692 } else { 693 displayBundleRanges(bundles) 694 } 695} 696 697// displayBundleRanges displays bundles as ranges 698func displayBundleRanges(bundles []int) { 699 if len(bundles) == 0 { 700 return 701 } 702 703 rangeStart := bundles[0] 704 rangeEnd := bundles[0] 705 706 for i := 1; i < len(bundles); i++ { 707 if bundles[i] == rangeEnd+1 { 708 rangeEnd = bundles[i] 709 } else { 710 if rangeStart == rangeEnd { 711 fmt.Printf(" %06d\n", rangeStart) 712 } else { 713 fmt.Printf(" %06d - %06d\n", rangeStart, rangeEnd) 714 } 715 rangeStart = bundles[i] 716 rangeEnd = bundles[i] 717 } 718 } 719 720 if rangeStart == rangeEnd { 721 fmt.Printf(" %06d\n", rangeStart) 722 } else { 723 fmt.Printf(" %06d - %06d\n", rangeStart, rangeEnd) 724 } 725} 726 727// Helper formatting functions 728 729func statusIcon(match bool) string { 730 if match { 731 return "✓" 732 } 733 return "✗" 734} 735 736func formatTimeDiff(local, remote time.Time) string { 737 if local.Equal(remote) { 738 return "identical" 739 } 740 diff := local.Sub(remote) 741 if diff < 0 { 742 diff = -diff 743 } 744 return fmt.Sprintf("differs by %s", diff) 745} 746 747func formatCountDiff(local, remote int) string { 748 if local == remote { 749 return fmt.Sprintf("%d", local) 750 } 751 return fmt.Sprintf("local=%d, remote=%d", local, remote) 752} 753 754func formatSizeDiff(local, remote int64) string { 755 if local == remote { 756 return formatBytes(local) 757 } 758 diff := local - remote 759 sign := "+" 760 if diff < 0 { 761 sign = "-" 762 diff = -diff 763 } 764 return fmt.Sprintf("local=%s, remote=%s (%s%s)", 765 formatBytes(local), formatBytes(remote), sign, formatBytes(diff)) 766} 767 768// IndexComparison holds comparison results 769type IndexComparison struct { 770 LocalCount int 771 TargetCount int 772 CommonCount int 773 MissingBundles []int 774 ExtraBundles []int 775 HashMismatches []HashMismatch 776 ContentMismatches []HashMismatch 777 LocalRange [2]int 778 TargetRange [2]int 779 LocalTotalSize int64 780 TargetTotalSize int64 781 LocalUpdated time.Time 782 TargetUpdated time.Time 783} 784 785// HashMismatch represents a hash mismatch between bundles 786type HashMismatch struct { 787 BundleNumber int 788 LocalHash string 789 TargetHash string 790 LocalContentHash string 791 TargetContentHash string 792} 793 794// HasDifferences checks if there are any differences 795func (ic *IndexComparison) HasDifferences() bool { 796 return len(ic.MissingBundles) > 0 || len(ic.ExtraBundles) > 0 || 797 len(ic.HashMismatches) > 0 || len(ic.ContentMismatches) > 0 798}