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