summaryrefslogtreecommitdiff
path: root/internal/cache/cache.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
commite14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (patch)
tree670ef0897839871a64d3a3bb2e17e242e7d6c385 /internal/cache/cache.go
downloadprognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.tar.gz
prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.zip
Initial commit: prognosis, the Go implementation
An hour-by-hour forecast for the terminal, with official IMGW warnings for Polish locations. Replaces the Python version, whose cache file format it keeps so the two can coexist until this reaches parity. Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered to that powiat rather than the whole country. Only the two lookups that never change are cached. Forecasts never are. Silence is never allowed to read as all-clear: "no warnings in force" and "the check failed" are reported as distinct states. Place names are resolved without guessing. A name matching several places is refused with a numbered list carrying each candidate's region and coordinates, and -pick N chooses one and remembers it. A stray positional beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to somewhere else. The cache is written one entry per line with sorted keys, and treated as disposable but not worthless: an entry that will not parse is skipped and the rest kept, and a file that will not parse at all is moved to cache.json.bad rather than overwritten. No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests.
Diffstat (limited to 'internal/cache/cache.go')
-rw-r--r--internal/cache/cache.go251
1 files changed, 251 insertions, 0 deletions
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
new file mode 100644
index 0000000..892df4a
--- /dev/null
+++ b/internal/cache/cache.go
@@ -0,0 +1,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)
+}