diff options
Diffstat (limited to 'internal/config')
| -rw-r--r-- | internal/config/config.go | 162 | ||||
| -rw-r--r-- | internal/config/config_test.go | 123 |
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") + } +} |
