aboutsummaryrefslogtreecommitdiff
path: root/internal/config
diff options
context:
space:
mode:
Diffstat (limited to 'internal/config')
-rw-r--r--internal/config/config.go360
-rw-r--r--internal/config/config_test.go158
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)
+ }
+}