aboutsummaryrefslogtreecommitdiff
path: root/internal/cache
diff options
context:
space:
mode:
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)
+ }
+}