[DEPRECATED] Go implementation of plcbundle
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}