diff --git a/README.md b/README.md index 05a14cf..40bca54 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ * unit systems: `metric`, `imperial`, `si`, `metric-ms` * disk caching of weather data with configurable TTL (`--cache-ttl`) * multi language support +* location aliases for quick lookups (`-a`, `--alias`, or direct location shortcut) * config file for default location which can be overridden by commandline * automatic config management with [ingo](https://github.com/schachmat/ingo) * built-in man page (`wego --man`) @@ -62,6 +63,25 @@ The config file is resolved in the following order: 3. `$HOME/.wegorc` (legacy location, for backward compatibility), if it exists. 4. If none of the above exist, a new config file is created at `$XDG_CONFIG_HOME/wego/wegorc`. +### Location Aliases + +You can configure shortcuts for frequently checked locations or coordinates in your `wegorc` file: +``` +location-aliases=home=27.35,-82.49,work=28.4311,-81.3077 +``` +Or in an `aliases` file in your wego configuration directory (e.g. `~/.config/wego/aliases`): +``` +home=27.35,-82.49 +work=28.4311,-81.3077 +``` + +Aliases can then be passed with the `-a` / `--alias` flag, or directly as a location shortcut (case-insensitively): +```shell +wego -a home +wego home +wego -a work 4 +``` + ### Backends __[Open-Meteo](https://open-meteo.com/)__ — free, no API key required: diff --git a/main.go b/main.go index 3f8aa92..593da00 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "crypto/sha256" "encoding/json" "flag" @@ -122,6 +123,151 @@ func saveCache(path string, data iface.Data, ttl time.Duration) { } } +// parseAliases parses alias definitions from a string. +// Supported formats include comma-, semicolon-, and newline-separated pairs of +// alias=location or alias:location. Alias names are stored in lowercase. +func parseAliases(s string) map[string]string { + aliases := make(map[string]string) + if strings.TrimSpace(s) == "" { + return aliases + } + + var lines []string + scanner := bufio.NewScanner(strings.NewReader(s)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + lines = append(lines, line) + } + + normalized := strings.Join(lines, ";") + + for _, chunk := range strings.Split(normalized, ";") { + chunk = strings.TrimSpace(chunk) + if chunk == "" { + continue + } + + // Count assignment operators (= or :) in this chunk + assignCount := 0 + for _, r := range chunk { + if r == '=' || r == ':' { + assignCount++ + } + } + + if assignCount <= 1 { + idx := strings.IndexAny(chunk, "=:") + if idx != -1 { + key := strings.TrimSpace(chunk[:idx]) + val := strings.TrimSpace(chunk[idx+1:]) + if key != "" && val != "" { + aliases[strings.ToLower(key)] = val + } + } + continue + } + + // Multiple assignments in a single chunk (e.g. comma-separated pairs) + var currentKey, currentVal string + for _, part := range strings.Split(chunk, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + if idx := strings.IndexAny(part, "=:"); idx != -1 { + if currentKey != "" && currentVal != "" { + aliases[strings.ToLower(currentKey)] = currentVal + } + currentKey = strings.TrimSpace(part[:idx]) + currentVal = strings.TrimSpace(part[idx+1:]) + } else { + if currentKey != "" { + if currentVal != "" { + currentVal += "," + part + } else { + currentVal = part + } + } + } + } + if currentKey != "" && currentVal != "" { + aliases[strings.ToLower(currentKey)] = currentVal + } + } + + return aliases +} + +// loadAliasesFile loads alias mappings from a file if it exists. +// Returns an empty map if the file does not exist or cannot be read. +func loadAliasesFile(path string) map[string]string { + data, err := os.ReadFile(path) + if err != nil { + return make(map[string]string) + } + return parseAliases(string(data)) +} + +// lookupAlias looks up an alias in the aliases map case-insensitively, +// following alias chains while avoiding cycles. +func lookupAlias(name string, aliases map[string]string) (string, bool) { + if aliases == nil { + return "", false + } + curr := strings.ToLower(strings.TrimSpace(name)) + if curr == "" { + return "", false + } + target, ok := aliases[curr] + if !ok { + return "", false + } + // Follow alias chains with cycle prevention + visited := map[string]bool{curr: true} + for { + nextKey := strings.ToLower(strings.TrimSpace(target)) + if visited[nextKey] { + break + } + nextTarget, nextOk := aliases[nextKey] + if !nextOk { + break + } + nextTargetKey := strings.ToLower(strings.TrimSpace(nextTarget)) + if visited[nextTargetKey] { + break + } + visited[nextKey] = true + target = nextTarget + } + return target, true +} + +// resolveAlias resolves an alias name against defined aliases (case-insensitively). +// If the alias is found, it returns the resolved location. +// Otherwise, it returns the input name as a fallback. +func resolveAlias(name string, aliases map[string]string) string { + if target, ok := lookupAlias(name, aliases); ok { + return target + } + return name +} + +// resolveLocation resolves the weather query location based on location, alias flag, and defined aliases. +// If an alias flag is provided, it resolves the alias against defined aliases (case-insensitively). +// If no alias flag is provided, it checks whether the location matches a defined alias. +// In all cases, if no matching alias is found, it falls back to the provided location or alias. +func resolveLocation(loc, alias string, aliases map[string]string) string { + if alias != "" { + return resolveAlias(alias, aliases) + } + return resolveAlias(loc, aliases) +} + func pluginLists() { bEnds := make([]string, 0, len(iface.AllBackends)) for name := range iface.AllBackends { @@ -151,6 +297,10 @@ func main() { // initialize global flags and default config location := flag.String("location", "40.748,-73.985", "`LOCATION` to be queried") flag.StringVar(location, "l", "40.748,-73.985", "`LOCATION` to be queried (shorthand)") + alias := flag.String("alias", "", "`ALIAS` for location to be queried") + flag.StringVar(alias, "a", "", "`ALIAS` for location to be queried (shorthand)") + locationAliases := flag.String("location-aliases", "", "comma or semicolon-separated list of `ALIAS=LOCATION` pairs") + flag.StringVar(locationAliases, "aliases", "", "comma or semicolon-separated list of `ALIAS=LOCATION` pairs (shorthand)") numdays := flag.Int("days", 3, "`NUMBER` of days of weather forecast to be displayed") flag.IntVar(numdays, "d", 3, "`NUMBER` of days of weather forecast to be displayed (shorthand)") unitSystem := flag.String("units", "metric", "`UNITSYSTEM` to use for output.\n \tChoices are: metric, imperial, si, metric-ms") @@ -217,6 +367,13 @@ func main() { } } + // resolve location aliases (from alias file and config/flags) + aliases := loadAliasesFile(filepath.Join(filepath.Dir(configPath), "aliases")) + for k, v := range parseAliases(*locationAliases) { + aliases[k] = v + } + *location = resolveLocation(*location, *alias, aliases) + // get selected backend and fetch the weather data from it be, ok := iface.AllBackends[*selectedBackend] if !ok { diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..cb21330 --- /dev/null +++ b/main_test.go @@ -0,0 +1,371 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestParseAliases(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + }{ + { + name: "empty string", + input: "", + expected: map[string]string{}, + }, + { + name: "whitespace only", + input: " \t\n ", + expected: map[string]string{}, + }, + { + name: "single alias with equal sign", + input: "home=27.35,-82.49", + expected: map[string]string{"home": "27.35,-82.49"}, + }, + { + name: "single alias with colon", + input: "home:27.35,-82.49", + expected: map[string]string{"home": "27.35,-82.49"}, + }, + { + name: "multiple semicolon-separated aliases", + input: "home=27.35,-82.49;work=28.4311,-81.3077", + expected: map[string]string{ + "home": "27.35,-82.49", + "work": "28.4311,-81.3077", + }, + }, + { + name: "multiple comma-separated aliases with coordinate commas", + input: "home=27.35,-82.49,work=28.4311,-81.3077", + expected: map[string]string{ + "home": "27.35,-82.49", + "work": "28.4311,-81.3077", + }, + }, + { + name: "semicolon-separated with spaces and city names", + input: " home = New York, NY ; work = London, UK ", + expected: map[string]string{ + "home": "New York, NY", + "work": "London, UK", + }, + }, + { + name: "multiline input with comments and blank lines", + input: "# User location aliases\nhome=27.35,-82.49\n\n# Vacation spot\nbeach=25.76,-80.19\n", + expected: map[string]string{ + "home": "27.35,-82.49", + "beach": "25.76,-80.19", + }, + }, + { + name: "case normalization of alias keys", + input: "HOME=27.35,-82.49;WorkPlace=London", + expected: map[string]string{ + "home": "27.35,-82.49", + "workplace": "London", + }, + }, + { + name: "invalid entries skipped", + input: "noequal;=novalue;nokey=;valid=12.34,56.78", + expected: map[string]string{"valid": "12.34,56.78"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := parseAliases(tc.input) + if len(got) == 0 && len(tc.expected) == 0 { + return + } + if !reflect.DeepEqual(got, tc.expected) { + t.Errorf("parseAliases(%q) = %v, expected %v", tc.input, got, tc.expected) + } + }) + } +} + +func TestLookupAlias(t *testing.T) { + aliases := map[string]string{ + "home": "27.35,-82.49", + "work": "28.4311,-81.3077", + "office": "work", + "headquar": "office", + "loopa": "loopb", + "loopb": "loopa", + "airport": "kmco", + "kmco": "28.4311,-81.3077", + } + + tests := []struct { + name string + aliases map[string]string + query string + expectedVal string + expectedOk bool + }{ + { + name: "nil aliases map", + aliases: nil, + query: "home", + expectedVal: "", + expectedOk: false, + }, + { + name: "empty query", + aliases: aliases, + query: "", + expectedVal: "", + expectedOk: false, + }, + { + name: "exact match", + aliases: aliases, + query: "home", + expectedVal: "27.35,-82.49", + expectedOk: true, + }, + { + name: "case-insensitive upper case", + aliases: aliases, + query: "HOME", + expectedVal: "27.35,-82.49", + expectedOk: true, + }, + { + name: "case-insensitive mixed case", + aliases: aliases, + query: "HoMe", + expectedVal: "27.35,-82.49", + expectedOk: true, + }, + { + name: "query with surrounding whitespace", + aliases: aliases, + query: " home ", + expectedVal: "27.35,-82.49", + expectedOk: true, + }, + { + name: "non-existent alias", + aliases: aliases, + query: "unknown", + expectedVal: "", + expectedOk: false, + }, + { + name: "alias chain resolution", + aliases: aliases, + query: "airport", + expectedVal: "28.4311,-81.3077", + expectedOk: true, + }, + { + name: "multi-hop alias chain resolution", + aliases: aliases, + query: "headquar", + expectedVal: "28.4311,-81.3077", + expectedOk: true, + }, + { + name: "cyclic alias resolution does not panic or loop", + aliases: aliases, + query: "loopa", + expectedVal: "loopb", + expectedOk: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + val, ok := lookupAlias(tc.query, tc.aliases) + if ok != tc.expectedOk || val != tc.expectedVal { + t.Errorf("lookupAlias(%q) = (%q, %v), expected (%q, %v)", + tc.query, val, ok, tc.expectedVal, tc.expectedOk) + } + }) + } +} + +func TestResolveAlias(t *testing.T) { + aliases := map[string]string{ + "home": "27.35,-82.49", + "work": "London", + } + + tests := []struct { + name string + aliases map[string]string + query string + expected string + }{ + { + name: "resolves matching alias", + aliases: aliases, + query: "home", + expected: "27.35,-82.49", + }, + { + name: "resolves case-insensitively", + aliases: aliases, + query: "HOME", + expected: "27.35,-82.49", + }, + { + name: "fallback for unknown alias returns input", + aliases: aliases, + query: "Paris", + expected: "Paris", + }, + { + name: "fallback for raw coordinates returns coordinates", + aliases: aliases, + query: "40.748,-73.985", + expected: "40.748,-73.985", + }, + { + name: "fallback with nil aliases map returns input", + aliases: nil, + query: "London", + expected: "London", + }, + { + name: "fallback for empty input returns empty", + aliases: aliases, + query: "", + expected: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := resolveAlias(tc.query, tc.aliases) + if got != tc.expected { + t.Errorf("resolveAlias(%q) = %q, expected %q", tc.query, got, tc.expected) + } + }) + } +} + +func TestResolveLocation(t *testing.T) { + aliases := map[string]string{ + "home": "27.35,-82.49", + "work": "28.4311,-81.3077", + } + + tests := []struct { + name string + loc string + alias string + aliases map[string]string + expected string + }{ + { + name: "alias flag provided and resolves", + loc: "40.748,-73.985", + alias: "home", + aliases: aliases, + expected: "27.35,-82.49", + }, + { + name: "alias flag provided with uppercase resolves", + loc: "40.748,-73.985", + alias: "HOME", + aliases: aliases, + expected: "27.35,-82.49", + }, + { + name: "alias flag provided but unknown falls back to alias value", + loc: "40.748,-73.985", + alias: "unknown-place", + aliases: aliases, + expected: "unknown-place", + }, + { + name: "positional location matches alias and resolves", + loc: "home", + alias: "", + aliases: aliases, + expected: "27.35,-82.49", + }, + { + name: "positional location with uppercase matches alias and resolves", + loc: "HOME", + alias: "", + aliases: aliases, + expected: "27.35,-82.49", + }, + { + name: "positional location not matching alias falls back to location", + loc: "London", + alias: "", + aliases: aliases, + expected: "London", + }, + { + name: "coordinates as location fall back to coordinates", + loc: "40.748,-73.985", + alias: "", + aliases: aliases, + expected: "40.748,-73.985", + }, + { + name: "empty alias and empty location fall back to empty", + loc: "", + alias: "", + aliases: aliases, + expected: "", + }, + { + name: "alias flag takes precedence over positional location", + loc: "London", + alias: "work", + aliases: aliases, + expected: "28.4311,-81.3077", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := resolveLocation(tc.loc, tc.alias, tc.aliases) + if got != tc.expected { + t.Errorf("resolveLocation(%q, %q) = %q, expected %q", tc.loc, tc.alias, got, tc.expected) + } + }) + } +} + +func TestLoadAliasesFile(t *testing.T) { + t.Run("nonexistent file returns empty map without error", func(t *testing.T) { + got := loadAliasesFile("/path/to/nonexistent/file") + if len(got) != 0 { + t.Errorf("loadAliasesFile for nonexistent file returned non-empty map: %v", got) + } + }) + + t.Run("valid alias file loaded correctly", func(t *testing.T) { + tmpDir := t.TempDir() + aliasFilePath := filepath.Join(tmpDir, "aliases") + content := "# Configured aliases\nhome=27.35,-82.49\n\nWORK=28.4311,-81.3077\n" + if err := os.WriteFile(aliasFilePath, []byte(content), 0600); err != nil { + t.Fatalf("failed to write test alias file: %v", err) + } + + got := loadAliasesFile(aliasFilePath) + expected := map[string]string{ + "home": "27.35,-82.49", + "work": "28.4311,-81.3077", + } + if !reflect.DeepEqual(got, expected) { + t.Errorf("loadAliasesFile(%s) = %v, expected %v", aliasFilePath, got, expected) + } + }) +}