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