aboutsummaryrefslogtreecommitdiff
path: root/internal/config
diff options
context:
space:
mode:
Diffstat (limited to 'internal/config')
-rw-r--r--internal/config/config.go162
-rw-r--r--internal/config/config_test.go123
2 files changed, 270 insertions, 15 deletions
diff --git a/internal/config/config.go b/internal/config/config.go
index 51d8c27..b2b96cf 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1,8 +1,8 @@
// Package config reads prognosis' KEY=VALUE configuration file.
//
-// The format is deliberately the same shape as wego's ~/.wegorc: one KEY=VALUE
-// per line, '#' starts a comment, values are never quoted. Parsing it here
-// rather than pulling in a config library keeps the binary dependency-free.
+// The format is one KEY=VALUE per line, '#' starts a comment, and values are
+// never quoted. Parsing it here rather than pulling in a config library keeps
+// the binary dependency-free.
package config
import (
@@ -37,6 +37,22 @@ var columnFields = map[string]string{
"visibility": "visibility",
}
+// Sources a custom column can draw from. They are separate Open-Meteo APIs with
+// different field sets, so a column has to say which one it means.
+var validSources = map[string]bool{"forecast": true, "air": true}
+
+// CustomColumn is a column defined in the config rather than built in, so a user
+// can display a field prognosis never anticipated -- an allergen, soil
+// temperature, anything the two APIs expose.
+type CustomColumn struct {
+ Source string // "forecast" or "air"
+ Field string // the API field name, verbatim
+ Label string // header text; defaults to the column name
+ Width int // 0 means derive it from the label
+ Decimals int // digits after the point
+ Suffix string // appended to the value, e.g. "°" or "%"
+}
+
var (
validIcons = map[string]bool{"nerd": true, "emoji": true, "none": true}
validColors = map[string]bool{"auto": true, "always": true, "never": true}
@@ -67,6 +83,16 @@ type Config struct {
// key, because it describes one invocation rather than a preference.
Minimal bool
+ // PollenExplicit records that the user named the species rather than asking
+ // for "all". A named species is shown even at zero -- you asked for it, so
+ // its absence is information -- while "all" shows only what is present, or
+ // the line would be six zeros of noise.
+ PollenExplicit bool
+
+ // Custom holds columns declared in the config, keyed by the short name used
+ // in Columns.
+ Custom map[string]CustomColumn
+
// ASCII restricts output to ASCII so an SMS stays in GSM-7 (160 characters
// per segment) instead of UCS-2 (70). One degree sign costs more than half
// the message.
@@ -79,7 +105,7 @@ func Default() Config {
return Config{
Hours: 12,
Units: "metric",
- Columns: []string{"hour", "temp", "feels", "conditions", "mm", "rain"},
+ Columns: []string{"hour", "temp", "feels", "conditions", "humidity", "mm", "rain"},
Icons: "nerd",
Graph: true,
GraphHeight: 5,
@@ -87,6 +113,7 @@ func Default() Config {
Pollen: append([]string(nil), AllSpecies...),
Color: "auto",
DisplayLang: "en",
+ Custom: map[string]CustomColumn{},
}
}
@@ -108,13 +135,32 @@ func ValidColumns() []string {
return names
}
-// Fields returns the Open-Meteo hourly fields the selected columns need.
+// Fields returns the forecast-API hourly fields the selected columns need.
// Only what is displayed is requested, so a narrow table costs a small response.
func (c Config) Fields() []string {
+ return c.fieldsFor("forecast")
+}
+
+// AirFields returns the air-quality-API hourly fields custom columns need. It is
+// empty unless the config declares one, so the extra request is only made when
+// something actually needs it.
+func (c Config) AirFields() []string {
+ return c.fieldsFor("air")
+}
+
+func (c Config) fieldsFor(source string) []string {
seen := map[string]bool{}
var out []string
for _, col := range c.Columns {
- f := columnFields[col]
+ var f string
+ if cc, ok := c.Custom[col]; ok {
+ if cc.Source != source {
+ continue
+ }
+ f = cc.Field
+ } else if source == "forecast" {
+ f = columnFields[col]
+ }
if f == "" || seen[f] {
continue
}
@@ -138,11 +184,24 @@ func (c Config) Has(column string) bool {
// Validate rejects unusable settings, naming the offending value and listing
// what would have been accepted. A silently blank column is worse than an error.
func (c Config) Validate() error {
+ for name, cc := range c.Custom {
+ if _, clash := columnFields[name]; clash {
+ return fmt.Errorf("column.%s: %q is a built-in column; pick another name", name, name)
+ }
+ if cc.Source == "" || cc.Field == "" {
+ return fmt.Errorf("%q has label/width/decimals but no column.%s = source:field",
+ name, name)
+ }
+ }
for _, col := range c.Columns {
- if _, ok := columnFields[col]; !ok {
- return fmt.Errorf("unknown column %q; valid: %s",
- col, strings.Join(ValidColumns(), ", "))
+ if _, ok := columnFields[col]; ok {
+ continue
}
+ if _, ok := c.Custom[col]; ok {
+ continue
+ }
+ return fmt.Errorf("unknown column %q; valid: %s (or declare it: column.%s = air:FIELD)",
+ col, strings.Join(ValidColumns(), ", "), col)
}
if !validIcons[c.Icons] {
return fmt.Errorf("unknown icons %q; valid: emoji, nerd, none", c.Icons)
@@ -214,7 +273,64 @@ func Load(path string) (Config, error) {
return cfg, sc.Err()
}
+// customKey splits "label.birch" into ("label", "birch").
+func customKey(key string) (attr, name string, ok bool) {
+ attr, name, ok = strings.Cut(key, ".")
+ if !ok || name == "" {
+ return "", "", false
+ }
+ switch attr {
+ case "column", "label", "width", "decimals", "suffix":
+ return attr, name, true
+ }
+ return "", "", false
+}
+
+func (c *Config) setCustom(attr, name, value string) error {
+ if c.Custom == nil {
+ c.Custom = map[string]CustomColumn{}
+ }
+ cc := c.Custom[name]
+ switch attr {
+ case "column":
+ src, field, ok := strings.Cut(value, ":")
+ if !ok {
+ return fmt.Errorf("column.%s: expected source:field, got %q; sources: air, forecast",
+ name, value)
+ }
+ src, field = strings.TrimSpace(src), strings.TrimSpace(field)
+ if !validSources[src] {
+ return fmt.Errorf("column.%s: unknown source %q; valid: air, forecast", name, src)
+ }
+ if field == "" {
+ return fmt.Errorf("column.%s: no field given after %q:", name, src)
+ }
+ cc.Source, cc.Field = src, field
+ case "label":
+ cc.Label = value
+ case "suffix":
+ cc.Suffix = value
+ case "width":
+ n, err := strconv.Atoi(value)
+ if err != nil || n < 1 {
+ return fmt.Errorf("width.%s: %q is not a positive number", name, value)
+ }
+ cc.Width = n
+ case "decimals":
+ n, err := strconv.Atoi(value)
+ if err != nil || n < 0 || n > 6 {
+ return fmt.Errorf("decimals.%s: %q is not a number between 0 and 6", name, value)
+ }
+ cc.Decimals = n
+ }
+ c.Custom[name] = cc
+ return nil
+}
+
func (c *Config) set(key, value string) error {
+ if attr, name, ok := customKey(key); ok {
+ return c.setCustom(attr, name, value)
+ }
switch key {
case "location":
c.Location = value
@@ -237,11 +353,11 @@ func (c *Config) set(key, value string) error {
case "pollen":
switch value {
case "all":
- c.Pollen = append([]string(nil), AllSpecies...)
+ c.Pollen, c.PollenExplicit = append([]string(nil), AllSpecies...), false
case "none":
- c.Pollen = nil
+ c.Pollen, c.PollenExplicit = nil, false
default:
- c.Pollen = splitList(value)
+ c.Pollen, c.PollenExplicit = splitList(value), true
}
case "hours":
n, err := strconv.Atoi(value)
@@ -298,8 +414,8 @@ const template = `# prognosis configuration
# One KEY=VALUE per line. '#' starts a comment. Values are not quoted.
# Command line flags override everything here.
-# Place to query. When empty, location= from ~/.wegorc is used, so prognosis
-# and wego never disagree about where you are.
+# Place to query: a name, or "lat,lon". Required -- prognosis has no other way
+# to know where you are, and will not guess.
location=%s
# Default span in hours. -n and -d override it.
@@ -324,7 +440,11 @@ graph_height=%d
# Official IMGW warnings for your powiat (Poland only).
warnings=%t
-# Pollen species to report, or "all" / "none".
+# Which allergens to report, or whether to report any at all.
+# none no pollen line
+# all every species that has a reading
+# birch,mugwort exactly these, always -- even at zero, because a species
+# you name is one you react to
pollen=%s
# Restrict output to ASCII: no degree sign, no diacritics, no block drawing.
@@ -334,6 +454,18 @@ ascii=%t
# auto (colour when stdout is a terminal) | always | never
color=%s
+# Columns prognosis does not ship with. Declare a short name against a source
+# and a field, then put the name in columns= above. "forecast" is the weather
+# API, "air" the air-quality one that carries the allergens; an air column costs
+# one extra request, made only when you declare one. See prognosis(1).
+#
+# column.birch = air:birch_pollen
+# column.soil = forecast:soil_temperature_0cm
+# label.birch = birch # header; defaults to the name
+# width.birch = 6 # defaults to fit the label
+# decimals.birch = 1 # digits after the point, default 0
+# suffix.soil = C # appended to the value
+
# Language for everything prognosis writes itself -- headers, condition names,
# labels, dates, pollen species: en | pl. IMGW publishes its warning text in
# Polish only, so that text stays Polish whatever this is set to.
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 06c0234..46a8635 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -156,3 +156,126 @@ func TestWriteDefaultRoundTrips(t *testing.T) {
t.Fatalf("the file we generate must validate: %v", err)
}
}
+
+func TestCustomColumnDeclaration(t *testing.T) {
+ cfg, err := Load(write(t, `columns=hour,temp,birch
+column.birch = air:birch_pollen
+label.birch = brzoza
+width.birch = 7
+decimals.birch = 2
+suffix.birch = g
+`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ cc, ok := cfg.Custom["birch"]
+ if !ok {
+ t.Fatal("birch was not declared")
+ }
+ if cc.Source != "air" || cc.Field != "birch_pollen" {
+ t.Errorf("source/field = %q/%q", cc.Source, cc.Field)
+ }
+ if cc.Label != "brzoza" || cc.Width != 7 || cc.Decimals != 2 || cc.Suffix != "g" {
+ t.Errorf("attributes not parsed: %+v", cc)
+ }
+ if err := cfg.Validate(); err != nil {
+ t.Fatalf("a complete declaration must validate: %v", err)
+ }
+}
+
+// Only the fields a selected column needs, split by which API serves them.
+func TestCustomColumnsSplitFieldsByApi(t *testing.T) {
+ cfg, err := Load(write(t, `columns=hour,temp,birch,soil
+column.birch = air:birch_pollen
+column.soil = forecast:soil_temperature_0cm
+`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ fc := strings.Join(cfg.Fields(), ",")
+ if !strings.Contains(fc, "soil_temperature_0cm") || !strings.Contains(fc, "temperature_2m") {
+ t.Errorf("forecast fields = %q", fc)
+ }
+ if strings.Contains(fc, "birch_pollen") {
+ t.Errorf("an air field must not be asked of the forecast API: %q", fc)
+ }
+ if air := strings.Join(cfg.AirFields(), ","); air != "birch_pollen" {
+ t.Errorf("air fields = %q, want birch_pollen", air)
+ }
+}
+
+// No custom air column means no second request at all.
+func TestNoAirFieldsWhenNoneDeclared(t *testing.T) {
+ if got := Default().AirFields(); len(got) != 0 {
+ t.Fatalf("AirFields() = %v, want empty", got)
+ }
+}
+
+func TestCustomColumnErrors(t *testing.T) {
+ for name, body := range map[string]string{
+ "no source": "column.x = birch_pollen\ncolumns=hour,x\n",
+ "unknown source": "column.x = weather:birch_pollen\ncolumns=hour,x\n",
+ "empty field": "column.x = air:\ncolumns=hour,x\n",
+ "bad width": "column.x = air:f\nwidth.x = wide\n",
+ "bad decimals": "column.x = air:f\ndecimals.x = 9\n",
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := Load(write(t, body)); err == nil {
+ t.Fatalf("expected an error for %q", body)
+ }
+ })
+ }
+}
+
+// Attributes without a declaration are a typo, not a silent no-op.
+func TestAttributesWithoutDeclarationAreRejected(t *testing.T) {
+ cfg, err := Load(write(t, "label.birch = brzoza\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := cfg.Validate(); err == nil {
+ t.Fatal("label.birch without column.birch must be an error")
+ }
+}
+
+// Shadowing a built-in would make which column you get depend on lookup order.
+func TestCustomColumnCannotShadowABuiltIn(t *testing.T) {
+ cfg, err := Load(write(t, "column.temp = air:birch_pollen\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = cfg.Validate()
+ if err == nil || !strings.Contains(err.Error(), "built-in") {
+ t.Fatalf("expected a built-in clash error, got %v", err)
+ }
+}
+
+// An undeclared column name should say how to declare it.
+func TestUnknownColumnSuggestsDeclaringIt(t *testing.T) {
+ cfg := Default()
+ cfg.Columns = []string{"hour", "birch"}
+ err := cfg.Validate()
+ if err == nil || !strings.Contains(err.Error(), "column.birch") {
+ t.Fatalf("error should show how to declare it, got %v", err)
+ }
+}
+
+func TestPollenExplicitTracksWhoChose(t *testing.T) {
+ cases := map[string]bool{
+ "pollen=grass,birch\n": true,
+ "pollen=all\n": false,
+ "pollen=none\n": false,
+ }
+ for body, want := range cases {
+ cfg, err := Load(write(t, body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.PollenExplicit != want {
+ t.Errorf("%q gave PollenExplicit=%v, want %v", body, cfg.PollenExplicit, want)
+ }
+ }
+ if Default().PollenExplicit {
+ t.Error("the default is not an explicit choice")
+ }
+}