summaryrefslogtreecommitdiff
path: root/internal/cache/cache.go
blob: 892df4a5a5649ab6cfd4827782c9f6ee583619f9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
// Package cache stores the lookups that never change: a place name's
// coordinates, and a coordinate's TERYT powiat code.
//
// Forecasts are never cached -- they would be stale immediately.
package cache

import (
	"bytes"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sort"
)

// Geo is a resolved location.
type Geo struct {
	Lat     float64
	Lon     float64
	Label   string
	Country string
}

// Geo is stored as a 4-element array, not an object, because the Python
// implementation shares this file and reads [lat, lon, label, country]. The two
// coexist until the Go version reaches parity, and a cache one of them cannot
// read makes the other crash on its own data.
func (g Geo) MarshalJSON() ([]byte, error) { return g.jsonLine() }

// jsonLine renders the stored array with a space after each comma, which is how
// it is written to disk. encoding/json compacts whatever a Marshaler returns, so
// the file writer calls this directly; going through json.Marshal would strip
// the spaces again. Element order is the contract described above, kept here so
// it is stated once.
func (g Geo) jsonLine() ([]byte, error) {
	parts := make([][]byte, 0, 4)
	for _, v := range []any{g.Lat, g.Lon, g.Label, g.Country} {
		b, err := json.Marshal(v)
		if err != nil {
			return nil, err
		}
		parts = append(parts, b)
	}
	return append(append([]byte{'['}, bytes.Join(parts, []byte(", "))...), ']'), nil
}

func (g *Geo) UnmarshalJSON(b []byte) error {
	var raw []any
	if err := json.Unmarshal(b, &raw); err != nil {
		return err
	}
	if len(raw) < 3 {
		return fmt.Errorf("geo entry has %d fields, want at least 3", len(raw))
	}
	lat, ok1 := raw[0].(float64)
	lon, ok2 := raw[1].(float64)
	if !ok1 || !ok2 {
		return fmt.Errorf("geo entry has non-numeric coordinates")
	}
	label, _ := raw[2].(string)
	country := ""
	if len(raw) > 3 {
		country, _ = raw[3].(string)
	}
	*g = Geo{Lat: lat, Lon: lon, Label: label, Country: country}
	return nil
}

type store struct {
	Geo   map[string]Geo    `json:"geo"`
	Teryt map[string]string `json:"teryt"`
}

// Cache is a JSON file holding both sections.
type Cache struct{ path string }

// New returns a cache backed by path. The file need not exist.
func New(path string) *Cache { return &Cache{path: path} }

// DefaultPath is ~/.cache/prognosis/cache.json.
func DefaultPath() string {
	if dir, err := os.UserCacheDir(); err == nil {
		return filepath.Join(dir, "prognosis", "cache.json")
	}
	return filepath.Join(os.Getenv("HOME"), ".cache", "prognosis", "cache.json")
}

// load never fails the run: a cache it cannot read is treated as empty, because
// losing a cache costs one extra request and failing the run costs the forecast.
//
// It salvages per entry rather than per file. Decoding the sections whole meant
// one unreadable entry made the entire cache look empty, and the save that
// followed then wrote that emptiness over every entry that was still good.
//
// The second result reports whether the file was usable. It is false only when
// the document itself will not parse, which tells the writers to move it aside
// before replacing it: the cache is disposable, but a file someone hand-edited
// is the only copy of what they typed.
func (c *Cache) load() (store, bool) {
	s := store{Geo: map[string]Geo{}, Teryt: map[string]string{}}
	data, err := os.ReadFile(c.path)
	if err != nil {
		return s, true // absent is not corrupt; a fresh cache may be written
	}
	if len(bytes.TrimSpace(data)) == 0 {
		return s, true // an empty file is simply no cache yet
	}
	var raw struct {
		Geo   map[string]json.RawMessage `json:"geo"`
		Teryt map[string]json.RawMessage `json:"teryt"`
	}
	if err := json.Unmarshal(data, &raw); err != nil {
		return s, false
	}
	for k, v := range raw.Geo {
		var g Geo
		if err := json.Unmarshal(v, &g); err == nil {
			s.Geo[k] = g
		}
	}
	for k, v := range raw.Teryt {
		var code string
		if err := json.Unmarshal(v, &code); err == nil {
			s.Teryt[k] = code
		}
	}
	return s, true
}

// encode writes the store one entry per line, keys sorted so the file is stable
// between runs. It is small, hand-edited, and read in a terminal, none of which
// a single long line serves.
func encode(s store) ([]byte, error) {
	geo := make(map[string]json.RawMessage, len(s.Geo))
	for k, g := range s.Geo {
		v, err := g.jsonLine()
		if err != nil {
			return nil, err
		}
		geo[k] = v
	}
	teryt := make(map[string]json.RawMessage, len(s.Teryt))
	for k, code := range s.Teryt {
		v, err := json.Marshal(code)
		if err != nil {
			return nil, err
		}
		teryt[k] = v
	}
	var b bytes.Buffer
	b.WriteString("{\n")
	writeSection(&b, "geo", geo)
	b.WriteString(",\n")
	writeSection(&b, "teryt", teryt)
	b.WriteString("\n}\n")
	return b.Bytes(), nil
}

func writeSection(b *bytes.Buffer, name string, entries map[string]json.RawMessage) {
	if len(entries) == 0 {
		fmt.Fprintf(b, "  %q: {}", name)
		return
	}
	keys := make([]string, 0, len(entries))
	for k := range entries {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	fmt.Fprintf(b, "  %q: {\n", name)
	for i, k := range keys {
		key, err := json.Marshal(k) // a place name may contain anything
		if err != nil {
			continue
		}
		fmt.Fprintf(b, "    %s: %s", key, entries[k])
		if i < len(keys)-1 {
			b.WriteByte(',')
		}
		b.WriteByte('\n')
	}
	b.WriteString("  }")
}

// save writes atomically: a temporary file in the same directory, then a
// rename. Two runs at once would otherwise interleave and leave truncated JSON
// that the next run silently reads as an empty cache.
func (c *Cache) save(s store) error {
	if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
		return err
	}
	tmp := fmt.Sprintf("%s.%d.tmp", c.path, os.Getpid())
	data, err := encode(s)
	if err != nil {
		return err
	}
	if err := os.WriteFile(tmp, data, 0o644); err != nil {
		return err
	}
	if err := os.Rename(tmp, c.path); err != nil {
		os.Remove(tmp)
		return err
	}
	return nil
}

// quarantine moves a cache that will not parse to <path>.bad, so replacing it
// costs nothing that cannot be recovered. Losing the cache is cheap -- one extra
// request -- but losing a hand-edit is not, and the two used to be the same act.
func (c *Cache) quarantine() error {
	return os.Rename(c.path, c.path+".bad")
}

// Geo returns a cached location.
func (c *Cache) Geo(place string) (Geo, bool) {
	s, _ := c.load()
	g, ok := s.Geo[place]
	return g, ok
}

// PutGeo records a location.
func (c *Cache) PutGeo(place string, g Geo) error {
	s, ok := c.load()
	if !ok {
		if err := c.quarantine(); err != nil {
			return err
		}
	}
	s.Geo[place] = g
	return c.save(s)
}

// Teryt returns a cached powiat code. The empty string is a real answer meaning
// "GUGiK knows this point is not in Poland"; the boolean distinguishes it from
// never having asked.
func (c *Cache) Teryt(key string) (string, bool) {
	s, _ := c.load()
	code, ok := s.Teryt[key]
	return code, ok
}

// PutTeryt records a powiat code, or "" for a point outside Poland.
func (c *Cache) PutTeryt(key, code string) error {
	s, ok := c.load()
	if !ok {
		if err := c.quarantine(); err != nil {
			return err
		}
	}
	s.Teryt[key] = code
	return c.save(s)
}