diff options
Diffstat (limited to 'internal/config')
| -rw-r--r-- | internal/config/config.go | 360 | ||||
| -rw-r--r-- | internal/config/config_test.go | 158 |
2 files changed, 518 insertions, 0 deletions
diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..51d8c27 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,360 @@ +// 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. +package config + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// Columns available for the hourly table, in the order they are documented. +// The value is the Open-Meteo hourly field the column needs, or "" when the +// column is derived from data already fetched. +var columnFields = map[string]string{ + "hour": "", + "icon": "weather_code", + "temp": "temperature_2m", + "feels": "apparent_temperature", + "conditions": "weather_code", + "mm": "precipitation", + "rain": "precipitation_probability", + "wind": "wind_speed_10m", + "gusts": "wind_gusts_10m", + "dir": "wind_direction_10m", + "humidity": "relative_humidity_2m", + "dew": "dew_point_2m", + "uv": "uv_index", + "cloud": "cloud_cover", + "pressure": "pressure_msl", + "visibility": "visibility", +} + +var ( + validIcons = map[string]bool{"nerd": true, "emoji": true, "none": true} + validColors = map[string]bool{"auto": true, "always": true, "never": true} + validUnits = map[string]bool{"metric": true, "imperial": true, "si": true} + validLangs = map[string]bool{"en": true, "pl": true} +) + +// AllSpecies is every pollen taxon Open-Meteo reports for Europe. +var AllSpecies = []string{"grass", "birch", "alder", "mugwort", "ragweed", "olive"} + +// Config is the fully resolved settings for one run. +type Config struct { + Location string + Hours int + Units string + Columns []string + Icons string + Graph bool + GraphHeight int + Warnings bool + Pollen []string + Color string + DisplayLang string + + // Minimal strips everything that is not the forecast itself: sun times, the + // day summary and pollen. Set by -weather, for output meant to be piped to + // someone who did not ask for pollen counts. Flag only -- there is no config + // key, because it describes one invocation rather than a preference. + Minimal bool + + // 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. + ASCII bool +} + +// Default returns the built-in configuration, used when no file exists and as +// the base every file and flag overrides. +func Default() Config { + return Config{ + Hours: 12, + Units: "metric", + Columns: []string{"hour", "temp", "feels", "conditions", "mm", "rain"}, + Icons: "nerd", + Graph: true, + GraphHeight: 5, + Warnings: true, + Pollen: append([]string(nil), AllSpecies...), + Color: "auto", + DisplayLang: "en", + } +} + +// Path is the default location of the config file. +func Path() string { + if dir, err := os.UserConfigDir(); err == nil { + return filepath.Join(dir, "prognosis", "config") + } + return filepath.Join(os.Getenv("HOME"), ".config", "prognosis", "config") +} + +// ValidColumns lists every column name, sorted, for error messages and docs. +func ValidColumns() []string { + names := make([]string, 0, len(columnFields)) + for k := range columnFields { + names = append(names, k) + } + sort.Strings(names) + return names +} + +// Fields returns the Open-Meteo 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 { + seen := map[string]bool{} + var out []string + for _, col := range c.Columns { + f := columnFields[col] + if f == "" || seen[f] { + continue + } + seen[f] = true + out = append(out, f) + } + sort.Strings(out) + return out +} + +// Has reports whether a column is selected. +func (c Config) Has(column string) bool { + for _, col := range c.Columns { + if col == column { + return true + } + } + return false +} + +// 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 _, col := range c.Columns { + if _, ok := columnFields[col]; !ok { + return fmt.Errorf("unknown column %q; valid: %s", + col, strings.Join(ValidColumns(), ", ")) + } + } + if !validIcons[c.Icons] { + return fmt.Errorf("unknown icons %q; valid: emoji, nerd, none", c.Icons) + } + if !validColors[c.Color] { + return fmt.Errorf("unknown color %q; valid: auto, always, never", c.Color) + } + if !validUnits[c.Units] { + return fmt.Errorf("unknown units %q; valid: metric, imperial, si", c.Units) + } + if !validLangs[c.DisplayLang] { + return fmt.Errorf("unknown display_lang %q; valid: en, pl", c.DisplayLang) + } + if c.Hours < 1 { + return fmt.Errorf("hours must be at least 1, got %d", c.Hours) + } + if c.GraphHeight < 2 { + return fmt.Errorf("graph_height must be at least 2, got %d", c.GraphHeight) + } + for _, s := range c.Pollen { + if !contains(AllSpecies, s) { + return fmt.Errorf("unknown pollen species %q; valid: %s, all, none", + s, strings.Join(AllSpecies, ", ")) + } + } + return nil +} + +func contains(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} + +// Load reads a config file over the defaults. A missing file is not an error: +// the defaults stand, and the caller may write them out. +func Load(path string) (Config, error) { + cfg := Default() + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return cfg, err + } + defer f.Close() + + sc := bufio.NewScanner(f) + for line := 1; sc.Scan(); line++ { + text := strings.TrimSpace(sc.Text()) + if text == "" || strings.HasPrefix(text, "#") { + continue + } + // Trailing comments are allowed so the generated file can annotate keys. + if i := strings.Index(text, "#"); i >= 0 { + text = strings.TrimSpace(text[:i]) + } + key, value, ok := strings.Cut(text, "=") + if !ok { + return cfg, fmt.Errorf("%s:%d: expected KEY=VALUE, got %q", path, line, text) + } + if err := cfg.set(strings.TrimSpace(key), strings.TrimSpace(value)); err != nil { + return cfg, fmt.Errorf("%s:%d: %w", path, line, err) + } + } + return cfg, sc.Err() +} + +func (c *Config) set(key, value string) error { + switch key { + case "location": + c.Location = value + case "units": + c.Units = value + case "icons": + c.Icons = value + case "color": + c.Color = value + case "display_lang": + c.DisplayLang = value + case "ascii": + b, err := parseBool(value) + if err != nil { + return fmt.Errorf("ascii: %w", err) + } + c.ASCII = b + case "columns": + c.Columns = splitList(value) + case "pollen": + switch value { + case "all": + c.Pollen = append([]string(nil), AllSpecies...) + case "none": + c.Pollen = nil + default: + c.Pollen = splitList(value) + } + case "hours": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("hours: %q is not a number", value) + } + c.Hours = n + case "graph_height": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("graph_height: %q is not a number", value) + } + c.GraphHeight = n + case "graph": + b, err := parseBool(value) + if err != nil { + return fmt.Errorf("graph: %w", err) + } + c.Graph = b + case "warnings": + b, err := parseBool(value) + if err != nil { + return fmt.Errorf("warnings: %w", err) + } + c.Warnings = b + default: + return fmt.Errorf("unknown key %q", key) + } + return nil +} + +func parseBool(v string) (bool, error) { + switch strings.ToLower(v) { + case "true", "yes", "on", "1": + return true, nil + case "false", "no", "off", "0": + return false, nil + } + return false, fmt.Errorf("%q is not true or false", v) +} + +func splitList(v string) []string { + var out []string + for _, part := range strings.Split(v, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + return out +} + +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. +location=%s + +# Default span in hours. -n and -d override it. +hours=%d + +# metric (C, km/h, mm) | imperial (F, mph, inch) | si (C, m/s, mm) +units=%s + +# Columns, in order. Available: +# %s +columns=%s + +# Weather glyph set for the "icon" column: nerd | emoji | none. +# nerd is single-width and monochrome, so it follows the terminal palette. +# emoji are colour glyphs from a fallback font and are not all one cell wide. +icons=%s + +# Temperature chart under the table. +graph=%t +graph_height=%d + +# Official IMGW warnings for your powiat (Poland only). +warnings=%t + +# Pollen species to report, or "all" / "none". +pollen=%s + +# Restrict output to ASCII: no degree sign, no diacritics, no block drawing. +# For SMS, where one non-ASCII character cuts the segment from 160 to 70 chars. +ascii=%t + +# auto (colour when stdout is a terminal) | always | never +color=%s + +# 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. +display_lang=%s +` + +// WriteDefault writes a commented configuration file, creating parent +// directories. The generated file documents every key, so the config is +// discoverable without the README. +func WriteDefault(path string, c Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + pollen := "none" + if len(c.Pollen) > 0 { + pollen = strings.Join(c.Pollen, ",") + } + body := fmt.Sprintf(template, + c.Location, c.Hours, c.Units, + strings.Join(ValidColumns(), ", "), + strings.Join(c.Columns, ","), + c.Icons, c.Graph, c.GraphHeight, c.Warnings, pollen, c.ASCII, c.Color, c.DisplayLang) + return os.WriteFile(path, []byte(body), 0o644) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..06c0234 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,158 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func write(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadMissingFileKeepsDefaults(t *testing.T) { + cfg, err := Load(filepath.Join(t.TempDir(), "absent")) + if err != nil { + t.Fatalf("missing file should not be an error: %v", err) + } + if cfg.Hours != 12 || cfg.Icons != "nerd" { + t.Fatalf("defaults not returned: %+v", cfg) + } +} + +func TestLoadOverridesOnlyWhatIsSet(t *testing.T) { + cfg, err := Load(write(t, "hours=24\nicons=emoji\n")) + if err != nil { + t.Fatal(err) + } + if cfg.Hours != 24 { + t.Errorf("hours = %d, want 24", cfg.Hours) + } + if cfg.Icons != "emoji" { + t.Errorf("icons = %q, want emoji", cfg.Icons) + } + // Untouched keys must keep their defaults. + if cfg.GraphHeight != 5 || !cfg.Graph { + t.Errorf("unset keys lost their defaults: %+v", cfg) + } +} + +func TestLoadCommentsAndBlanks(t *testing.T) { + cfg, err := Load(write(t, "\n# a comment\n\nhours=6 # trailing comment\n")) + if err != nil { + t.Fatal(err) + } + if cfg.Hours != 6 { + t.Fatalf("hours = %d, want 6 (trailing comment must be stripped)", cfg.Hours) + } +} + +func TestLoadErrors(t *testing.T) { + for name, body := range map[string]string{ + "no equals": "hours 12\n", + "unknown key": "colour=always\n", + "not a number": "hours=soon\n", + "not a bool": "graph=maybe\n", + } { + t.Run(name, func(t *testing.T) { + if _, err := Load(write(t, body)); err == nil { + t.Fatalf("expected an error for %q", body) + } + }) + } +} + +func TestPollenAllAndNone(t *testing.T) { + cfg, _ := Load(write(t, "pollen=all\n")) + if len(cfg.Pollen) != len(AllSpecies) { + t.Errorf("pollen=all gave %v", cfg.Pollen) + } + cfg, _ = Load(write(t, "pollen=none\n")) + if len(cfg.Pollen) != 0 { + t.Errorf("pollen=none gave %v", cfg.Pollen) + } +} + +func TestValidateNamesTheOffender(t *testing.T) { + cfg := Default() + cfg.Columns = []string{"hour", "tempature"} + err := cfg.Validate() + if err == nil { + t.Fatal("expected an error for an unknown column") + } + if !strings.Contains(err.Error(), "tempature") { + t.Errorf("error must name the offending column, got: %v", err) + } + if !strings.Contains(err.Error(), "conditions") { + t.Errorf("error must list valid columns, got: %v", err) + } +} + +func TestValidateRejectsBadEnums(t *testing.T) { + for name, mutate := range map[string]func(*Config){ + "icons": func(c *Config) { c.Icons = "pictures" }, + "color": func(c *Config) { c.Color = "sometimes" }, + "units": func(c *Config) { c.Units = "furlongs" }, + "hours": func(c *Config) { c.Hours = 0 }, + "graph_height": func(c *Config) { c.GraphHeight = 1 }, + "pollen": func(c *Config) { c.Pollen = []string{"oak"} }, + } { + t.Run(name, func(t *testing.T) { + cfg := Default() + mutate(&cfg) + if err := cfg.Validate(); err == nil { + t.Fatalf("expected %s to be rejected", name) + } + }) + } +} + +func TestDefaultIsValid(t *testing.T) { + if err := Default().Validate(); err != nil { + t.Fatalf("the built-in default must be valid: %v", err) + } +} + +func TestFieldsRequestsOnlySelectedColumns(t *testing.T) { + cfg := Default() + cfg.Columns = []string{"hour", "temp", "wind"} + got := strings.Join(cfg.Fields(), ",") + want := "temperature_2m,wind_speed_10m" + if got != want { + t.Fatalf("Fields() = %q, want %q", got, want) + } +} + +func TestFieldsDeduplicates(t *testing.T) { + cfg := Default() + // icon and conditions both come from weather_code. + cfg.Columns = []string{"icon", "conditions"} + if got := cfg.Fields(); len(got) != 1 || got[0] != "weather_code" { + t.Fatalf("Fields() = %v, want one weather_code", got) + } +} + +func TestWriteDefaultRoundTrips(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub", "config") + want := Default() + want.Location = "Krakow" + if err := WriteDefault(path, want); err != nil { + t.Fatal(err) + } + got, err := Load(path) + if err != nil { + t.Fatalf("the file we generate must parse: %v", err) + } + if got.Location != "Krakow" || got.Hours != want.Hours || got.Icons != want.Icons { + t.Fatalf("round trip changed values:\n got %+v\nwant %+v", got, want) + } + if err := got.Validate(); err != nil { + t.Fatalf("the file we generate must validate: %v", err) + } +} |
