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