A Transparent and Verifiable Way to Sync the AT Protocol's PLC Directory
1package bundle
2
3import (
4 "fmt"
5 "os"
6 "sort"
7 "sync"
8 "time"
9
10 "github.com/goccy/go-json"
11)
12
13const (
14 // INDEX_FILE is the default index filename
15 INDEX_FILE = "plc_bundles.json"
16
17 // INDEX_VERSION is the current index format version
18 INDEX_VERSION = "1.0"
19)
20
21// Index represents the JSON index file
22type Index struct {
23 Version string `json:"version"`
24 Origin string `json:"origin"`
25 LastBundle int `json:"last_bundle"`
26 UpdatedAt time.Time `json:"updated_at"`
27 TotalSize int64 `json:"total_size_bytes"`
28 TotalUncompressedSize int64 `json:"total_uncompressed_size_bytes"`
29 Bundles []*BundleMetadata `json:"bundles"`
30
31 mu sync.RWMutex `json:"-"`
32}
33
34// NewIndex creates a new empty index
35func NewIndex(origin string) *Index {
36 return &Index{
37 Version: INDEX_VERSION,
38 Origin: origin,
39 Bundles: make([]*BundleMetadata, 0),
40 UpdatedAt: time.Now().UTC(),
41 }
42}
43
44// LoadIndex loads an index from a file
45func LoadIndex(path string) (*Index, error) {
46 data, err := os.ReadFile(path)
47 if err != nil {
48 return nil, fmt.Errorf("failed to read index file: %w", err)
49 }
50
51 var idx Index
52 if err := json.Unmarshal(data, &idx); err != nil {
53 return nil, fmt.Errorf("failed to parse index file: %w", err)
54 }
55
56 // Validate version
57 if idx.Version != INDEX_VERSION {
58 return nil, fmt.Errorf("unsupported index version: %s (expected %s)", idx.Version, INDEX_VERSION)
59 }
60
61 // Recalculate derived fields (handles new fields added to Index)
62 idx.recalculate()
63
64 return &idx, nil
65}
66
67// Save saves the index to a file
68func (idx *Index) Save(path string) error {
69 idx.mu.Lock()
70 defer idx.mu.Unlock()
71
72 idx.UpdatedAt = time.Now().UTC()
73
74 data, err := json.MarshalIndent(idx, "", " ")
75 if err != nil {
76 return fmt.Errorf("failed to marshal index: %w", err)
77 }
78
79 // Write atomically (write to temp file, then rename)
80 tempPath := path + ".tmp"
81 if err := os.WriteFile(tempPath, data, 0644); err != nil {
82 return fmt.Errorf("failed to write temp file: %w", err)
83 }
84
85 if err := os.Rename(tempPath, path); err != nil {
86 os.Remove(tempPath) // Clean up temp file
87 return fmt.Errorf("failed to rename temp file: %w", err)
88 }
89
90 return nil
91}
92
93// AddBundle adds a bundle to the index
94func (idx *Index) AddBundle(meta *BundleMetadata) {
95 idx.mu.Lock()
96 defer idx.mu.Unlock()
97
98 // Check if bundle already exists
99 for i, existing := range idx.Bundles {
100 if existing.BundleNumber == meta.BundleNumber {
101 // Update existing
102 idx.Bundles[i] = meta
103 idx.recalculate()
104 return
105 }
106 }
107
108 // Add new bundle
109 idx.Bundles = append(idx.Bundles, meta)
110 idx.sort()
111 idx.recalculate()
112}
113
114// GetBundle retrieves a bundle metadata by number
115func (idx *Index) GetBundle(bundleNumber int) (*BundleMetadata, error) {
116 idx.mu.RLock()
117 defer idx.mu.RUnlock()
118
119 for _, meta := range idx.Bundles {
120 if meta.BundleNumber == bundleNumber {
121 return meta, nil
122 }
123 }
124
125 return nil, fmt.Errorf("bundle %d not found in index", bundleNumber)
126}
127
128// GetLastBundle returns the metadata of the last bundle
129func (idx *Index) GetLastBundle() *BundleMetadata {
130 idx.mu.RLock()
131 defer idx.mu.RUnlock()
132
133 if len(idx.Bundles) == 0 {
134 return nil
135 }
136
137 return idx.Bundles[len(idx.Bundles)-1]
138}
139
140// GetBundles returns all bundle metadata
141func (idx *Index) GetBundles() []*BundleMetadata {
142 idx.mu.RLock()
143 defer idx.mu.RUnlock()
144
145 // Return a copy
146 result := make([]*BundleMetadata, len(idx.Bundles))
147 copy(result, idx.Bundles)
148 return result
149}
150
151// GetBundleRange returns bundles in a specific range
152func (idx *Index) GetBundleRange(start, end int) []*BundleMetadata {
153 idx.mu.RLock()
154 defer idx.mu.RUnlock()
155
156 var result []*BundleMetadata
157 for _, meta := range idx.Bundles {
158 if meta.BundleNumber >= start && meta.BundleNumber <= end {
159 result = append(result, meta)
160 }
161 }
162 return result
163}
164
165// Count returns the number of bundles in the index
166func (idx *Index) Count() int {
167 idx.mu.RLock()
168 defer idx.mu.RUnlock()
169 return len(idx.Bundles)
170}
171
172// FindGaps finds missing bundle numbers in the sequence
173func (idx *Index) FindGaps() []int {
174 idx.mu.RLock()
175 defer idx.mu.RUnlock()
176
177 if len(idx.Bundles) == 0 {
178 return nil
179 }
180
181 var gaps []int
182 first := idx.Bundles[0].BundleNumber
183 last := idx.Bundles[len(idx.Bundles)-1].BundleNumber
184
185 bundleMap := make(map[int]bool)
186 for _, meta := range idx.Bundles {
187 bundleMap[meta.BundleNumber] = true
188 }
189
190 for i := first; i <= last; i++ {
191 if !bundleMap[i] {
192 gaps = append(gaps, i)
193 }
194 }
195
196 return gaps
197}
198
199// GetStats returns statistics about the index
200func (idx *Index) GetStats() map[string]interface{} {
201 idx.mu.RLock()
202 defer idx.mu.RUnlock()
203
204 if len(idx.Bundles) == 0 {
205 return map[string]interface{}{
206 "bundle_count": 0,
207 "total_size": 0,
208 "total_uncompressed_size": 0,
209 }
210 }
211
212 first := idx.Bundles[0]
213 last := idx.Bundles[len(idx.Bundles)-1]
214
215 return map[string]interface{}{
216 "bundle_count": len(idx.Bundles),
217 "first_bundle": first.BundleNumber,
218 "last_bundle": last.BundleNumber,
219 "total_size": idx.TotalSize,
220 "total_uncompressed_size": idx.TotalUncompressedSize,
221 "start_time": first.StartTime,
222 "end_time": last.EndTime,
223 "updated_at": idx.UpdatedAt,
224 "gaps": len(idx.FindGaps()),
225 }
226}
227
228// sort sorts bundles by bundle number
229func (idx *Index) sort() {
230 sort.Slice(idx.Bundles, func(i, j int) bool {
231 return idx.Bundles[i].BundleNumber < idx.Bundles[j].BundleNumber
232 })
233}
234
235// recalculate recalculates derived fields (called after modifications)
236func (idx *Index) recalculate() {
237 if len(idx.Bundles) == 0 {
238 idx.LastBundle = 0
239 idx.TotalSize = 0
240 idx.TotalUncompressedSize = 0
241 return
242 }
243
244 // Find last bundle
245 maxBundle := 0
246 totalSize := int64(0)
247 totalUncompressed := int64(0)
248
249 for _, meta := range idx.Bundles {
250 if meta.BundleNumber > maxBundle {
251 maxBundle = meta.BundleNumber
252 }
253 totalSize += meta.CompressedSize
254 totalUncompressed += meta.UncompressedSize
255 }
256
257 idx.LastBundle = maxBundle
258 idx.TotalSize = totalSize
259 idx.TotalUncompressedSize = totalUncompressed
260}
261
262// Rebuild rebuilds the index from bundle metadata
263func (idx *Index) Rebuild(bundles []*BundleMetadata) {
264 idx.mu.Lock()
265 defer idx.mu.Unlock()
266
267 idx.Bundles = bundles
268 idx.sort()
269 idx.recalculate()
270 idx.UpdatedAt = time.Now().UTC()
271}
272
273// Clear clears all bundles from the index
274func (idx *Index) Clear() {
275 idx.mu.Lock()
276 defer idx.mu.Unlock()
277
278 idx.Bundles = make([]*BundleMetadata, 0)
279 idx.LastBundle = 0
280 idx.TotalSize = 0
281 idx.UpdatedAt = time.Now().UTC()
282}