aboutsummaryrefslogtreecommitdiff
path: root/internal/cache
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
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')
-rw-r--r--internal/cache/cache.go251
-rw-r--r--internal/cache/cache_test.go282
2 files changed, 533 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)
+}
diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go
new file mode 100644
index 0000000..3283db2
--- /dev/null
+++ b/internal/cache/cache_test.go
@@ -0,0 +1,282 @@
+package cache
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+func tmpCache(t *testing.T) *Cache {
+ t.Helper()
+ return New(filepath.Join(t.TempDir(), "sub", "cache.json"))
+}
+
+func TestGeoRoundTrip(t *testing.T) {
+ c := tmpCache(t)
+ if _, ok := c.Geo("Krakow"); ok {
+ t.Fatal("empty cache reported a hit")
+ }
+ want := Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}
+ if err := c.PutGeo("Krakow", want); err != nil {
+ t.Fatal(err)
+ }
+ got, ok := c.Geo("Krakow")
+ if !ok || got != want {
+ t.Fatalf("got %+v (%v), want %+v", got, ok, want)
+ }
+}
+
+// "" is a real answer -- not in Poland -- and must be distinguishable from
+// never having asked, or every foreign location re-queries GUGiK forever.
+func TestTerytEmptyStringIsARealAnswer(t *testing.T) {
+ c := tmpCache(t)
+ if _, ok := c.Teryt("52.5200,13.4000"); ok {
+ t.Fatal("empty cache reported a hit")
+ }
+ if err := c.PutTeryt("52.5200,13.4000", ""); err != nil {
+ t.Fatal(err)
+ }
+ code, ok := c.Teryt("52.5200,13.4000")
+ if !ok {
+ t.Fatal("a cached empty code must report as present")
+ }
+ if code != "" {
+ t.Fatalf("code = %q, want empty", code)
+ }
+}
+
+func TestCorruptFileIsTreatedAsEmpty(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "cache.json")
+ if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ c := New(path)
+ if _, ok := c.Geo("anything"); ok {
+ t.Fatal("corrupt cache must read as empty, not error")
+ }
+ if err := c.PutGeo("x", Geo{Lat: 1}); err != nil {
+ t.Fatalf("must be able to overwrite a corrupt cache: %v", err)
+ }
+}
+
+func TestSaveLeavesNoTempFiles(t *testing.T) {
+ dir := t.TempDir()
+ c := New(filepath.Join(dir, "cache.json"))
+ if err := c.PutGeo("a", Geo{Lat: 1}); err != nil {
+ t.Fatal(err)
+ }
+ entries, _ := filepath.Glob(filepath.Join(dir, "*.tmp"))
+ if len(entries) != 0 {
+ t.Fatalf("temp files left behind: %v", entries)
+ }
+}
+
+// The whole point of the atomic write: concurrent writers must never leave a
+// file that fails to parse.
+func TestConcurrentWritesKeepValidJSON(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "cache.json")
+ var wg sync.WaitGroup
+ for i := 0; i < 16; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ New(path).PutGeo("place", Geo{Lat: float64(i)})
+ }(i)
+ }
+ wg.Wait()
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var s store
+ if err := json.Unmarshal(data, &s); err != nil {
+ t.Fatalf("cache is not valid JSON after concurrent writes: %v\n%s", err, data)
+ }
+}
+
+// The Python implementation shares this file and stores geo entries as
+// [lat, lon, label, country]. If Go writes an object instead, Python crashes on
+// its own cache -- which is exactly what happened once.
+func TestGeoIsStoredAsAnArrayForPythonInterop(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "cache.json")
+ c := New(path)
+ if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}); err != nil {
+ t.Fatal(err)
+ }
+ data, _ := os.ReadFile(path)
+ var probe struct {
+ Geo map[string][]any `json:"geo"`
+ }
+ if err := json.Unmarshal(data, &probe); err != nil {
+ t.Fatalf("geo must decode as arrays: %v\n%s", err, data)
+ }
+ entry := probe.Geo["Krakow"]
+ if len(entry) != 4 {
+ t.Fatalf("geo entry = %v, want 4 elements", entry)
+ }
+ if entry[2] != "Krakow, PL" {
+ t.Errorf("third element must be the label, got %v", entry[2])
+ }
+}
+
+// A cache written in the Python shape must load here unchanged.
+func TestReadsPythonWrittenCache(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "cache.json")
+ body := `{"geo":{"Krakow":[50.06170,19.93730,"Krakow, PL","PL"]},"teryt":{"50.0617,19.9373":"1815"}}`
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ c := New(path)
+ g, ok := c.Geo("Krakow")
+ if !ok || g.Label != "Krakow, PL" || g.Country != "PL" {
+ t.Fatalf("got %+v (%v)", g, ok)
+ }
+ if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" {
+ t.Fatalf("teryt = %q (%v)", code, ok)
+ }
+}
+
+// writeCache puts raw bytes where the cache expects its file.
+func writeCache(t *testing.T, c *Cache, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(c.path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// One unreadable entry used to cost the whole file: load treated the parse
+// error as "empty cache", and the next save wrote that emptiness over
+// everything that was still fine.
+func TestOneMalformedEntryDoesNotDestroyTheOthers(t *testing.T) {
+ c := tmpCache(t)
+ writeCache(t, c, `{"geo":{"Gdansk":[54.35227,18.64912,"Gdansk, PL","PL"],"Broken":[1]},"teryt":{"50.0617,19.9373":"1815"}}`)
+
+ if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := c.Geo("Gdansk"); !ok {
+ t.Error("a good entry was destroyed by an unrelated malformed one")
+ }
+ if _, ok := c.Geo("Krakow"); !ok {
+ t.Error("the new entry was not stored")
+ }
+ if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" {
+ t.Errorf("teryt section lost too: got %q, %v", code, ok)
+ }
+ if _, ok := c.Geo("Broken"); ok {
+ t.Error("the malformed entry should be dropped, not resurrected")
+ }
+}
+
+// A hand-edit that breaks the whole document must not cost the file. The cache
+// is disposable, but whatever was typed into it is not, so it moves aside
+// rather than being overwritten -- and the run still gets a working cache.
+func TestAnUnparseableFileIsMovedAsideNotDestroyed(t *testing.T) {
+ c := tmpCache(t)
+ const broken = `{"geo":{"Gdansk":[54.35227,`
+ writeCache(t, c, broken)
+
+ if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373}); err != nil {
+ t.Fatalf("the tool must keep working: %v", err)
+ }
+ kept, err := os.ReadFile(c.path + ".bad")
+ if err != nil {
+ t.Fatalf("the unparseable file was not preserved: %v", err)
+ }
+ if string(kept) != broken {
+ t.Errorf("preserved copy differs:\n got %s\nwant %s", kept, broken)
+ }
+ if _, ok := c.Geo("Krakow"); !ok {
+ t.Error("the fresh cache did not take the new entry")
+ }
+}
+
+func TestPutTerytAlsoMovesAnUnparseableFileAside(t *testing.T) {
+ c := tmpCache(t)
+ const broken = `{oops`
+ writeCache(t, c, broken)
+ if err := c.PutTeryt("50.0617,19.9373", "1815"); err != nil {
+ t.Fatalf("the tool must keep working: %v", err)
+ }
+ kept, err := os.ReadFile(c.path + ".bad")
+ if err != nil || string(kept) != broken {
+ t.Errorf("unparseable file not preserved: %q, %v", kept, err)
+ }
+ if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" {
+ t.Errorf("fresh cache did not take the entry: %q, %v", code, ok)
+ }
+}
+
+// The file is read by a human at least as often as by the program.
+func TestSaveWritesOneEntryPerLine(t *testing.T) {
+ c := tmpCache(t)
+ if err := c.PutGeo("Krakow", Geo{Lat: 50.06170, Lon: 19.93730, Label: "Krakow, PL", Country: "PL"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.PutGeo("Chiang Mai", Geo{Lat: 18.79038, Lon: 98.98468, Label: "Chiang Mai, TH", Country: "TH"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.PutTeryt("50.0617,19.9373", "1815"); err != nil {
+ t.Fatal(err)
+ }
+ body, err := os.ReadFile(c.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := `{
+ "geo": {
+ "Chiang Mai": [18.79038, 98.98468, "Chiang Mai, TH", "TH"],
+ "Krakow": [50.06170, 19.93730, "Krakow, PL", "PL"]
+ },
+ "teryt": {
+ "50.0617,19.9373": "1815"
+ }
+}
+`
+ if string(body) != want {
+ t.Errorf("got:\n%s\nwant:\n%s", body, want)
+ }
+}
+
+func TestSavedFileIsStillValidJSONForTheOtherImplementation(t *testing.T) {
+ c := tmpCache(t)
+ want := Geo{Lat: 50.06170, Lon: 19.93730, Label: "Krakow, PL", Country: "PL"}
+ if err := c.PutGeo("Krakow", want); err != nil {
+ t.Fatal(err)
+ }
+ body, err := os.ReadFile(c.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got struct {
+ Geo map[string][]any `json:"geo"`
+ }
+ if err := json.Unmarshal(body, &got); err != nil {
+ t.Fatalf("a stock JSON parser could not read it: %v", err)
+ }
+ row := got.Geo["Krakow"]
+ if len(row) != 4 {
+ t.Fatalf("got %d fields, want the 4-element shape Python reads: %v", len(row), row)
+ }
+ if row[2] != "Krakow, PL" {
+ t.Errorf("label field is %v, want the third element", row[2])
+ }
+}
+
+func TestEmptyCacheSavesReadableJSON(t *testing.T) {
+ c := tmpCache(t)
+ if err := c.PutTeryt("52.5200,13.4000", ""); err != nil {
+ t.Fatal(err)
+ }
+ body, _ := os.ReadFile(c.path)
+ if !json.Valid(body) {
+ t.Fatalf("invalid JSON with an empty geo section:\n%s", body)
+ }
+}