aboutsummaryrefslogtreecommitdiff
path: root/cmd/prognosis
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/prognosis')
-rw-r--r--cmd/prognosis/args_test.go49
-rw-r--r--cmd/prognosis/main.go441
-rw-r--r--cmd/prognosis/resolve_test.go171
-rw-r--r--cmd/prognosis/resolver.go67
-rw-r--r--cmd/prognosis/term_other.go6
-rw-r--r--cmd/prognosis/term_unix.go28
6 files changed, 762 insertions, 0 deletions
diff --git a/cmd/prognosis/args_test.go b/cmd/prognosis/args_test.go
new file mode 100644
index 0000000..4a85a2d
--- /dev/null
+++ b/cmd/prognosis/args_test.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+// `prognosis -l Wiry, PL` is the unquoted form of `-l "Wiry, PL"`. The shell
+// splits it, so -l gets "Wiry," and "PL" arrives as a stray positional. Dropping
+// it silently is what geocoded "Wiry," to Ukraine without anyone noticing.
+func TestStrayPositionalBesideDashLIsAnError(t *testing.T) {
+ _, err := placeFromArgs("Wiry,", []string{"PL"})
+ if err == nil {
+ t.Fatal("expected an error; silently ignoring the argument hides a quoting mistake")
+ }
+ if !strings.Contains(err.Error(), "PL") {
+ t.Errorf("error %q should name the argument that was ignored", err)
+ }
+}
+
+func TestDashLIsThePlaceWhenNothingElseIsGiven(t *testing.T) {
+ got, err := placeFromArgs("Wiry, PL", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != "Wiry, PL" {
+ t.Errorf("got %q, want %q", got, "Wiry, PL")
+ }
+}
+
+func TestPositionalsJoinIntoThePlace(t *testing.T) {
+ got, err := placeFromArgs("", []string{"Wiry,", "PL"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != "Wiry, PL" {
+ t.Errorf("got %q, want %q: bare words are still accepted as the place", got, "Wiry, PL")
+ }
+}
+
+func TestNoPlaceAnywhereIsNotAnError(t *testing.T) {
+ got, err := placeFromArgs("", nil)
+ if err != nil {
+ t.Fatalf("unexpected error %v: the config and ~/.wegorc are consulted next", err)
+ }
+ if got != "" {
+ t.Errorf("got %q, want empty", got)
+ }
+}
diff --git a/cmd/prognosis/main.go b/cmd/prognosis/main.go
new file mode 100644
index 0000000..6e29c8e
--- /dev/null
+++ b/cmd/prognosis/main.go
@@ -0,0 +1,441 @@
+// Command prognosis prints an hour-by-hour forecast, with official IMGW
+// warnings for Polish locations.
+package main
+
+import (
+ "bufio"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/lukaszkasprzak/prognosis/internal/cache"
+ "github.com/lukaszkasprzak/prognosis/internal/config"
+ "github.com/lukaszkasprzak/prognosis/internal/i18n"
+ "github.com/lukaszkasprzak/prognosis/internal/imgw"
+ "github.com/lukaszkasprzak/prognosis/internal/openmeteo"
+ "github.com/lukaszkasprzak/prognosis/internal/render"
+)
+
+const wegorc = ".wegorc"
+
+func main() { os.Exit(run()) }
+
+func run() int {
+ // Android has no /etc/resolv.conf; without this every lookup fails.
+ configureResolver()
+
+ var (
+ location = flag.String("l", "", "place to query (default: config, then ~/.wegorc)")
+ hours = flag.Int("n", 0, "hours ahead to show")
+ days = flag.Int("d", 0, "days ahead to show, 24h each")
+ columns = flag.String("columns", "", "comma-separated columns to display")
+ icons = flag.String("icons", "", "icon set: nerd, emoji or none")
+ lang = flag.String("lang", "", "display language: en or pl")
+ noGraph = flag.Bool("no-graph", false, "table only, no chart")
+ weather = flag.Bool("weather", false, "forecast only: no sun times, summary, pollen or chart")
+ noWarn = flag.Bool("no-warnings", false, "omit IMGW warnings")
+ asciiOut = flag.Bool("ascii", false, "ASCII only, so an SMS stays in GSM-7")
+ noColor = flag.Bool("no-color", false, "plain output")
+ pick = flag.Int("pick", 0, "choose the Nth place when the name is ambiguous")
+ showCfg = flag.Bool("config", false, "print the config file path and exit")
+ )
+ flag.Usage = usage
+ // Go's flag package stops at the first non-flag argument, so
+ // "prognosis 52.52,13.40 -n 3" would swallow the flags into the place name.
+ // Re-parse around each positional, which is what argparse does.
+ var positional []string
+ rest := os.Args[1:]
+ for {
+ if err := flag.CommandLine.Parse(rest); err != nil {
+ return 2
+ }
+ if flag.NArg() == 0 {
+ break
+ }
+ positional = append(positional, flag.Arg(0))
+ rest = flag.Args()[1:]
+ }
+
+ cfgPath := config.Path()
+ if *showCfg {
+ fmt.Println(cfgPath)
+ return 0
+ }
+
+ cfg, err := config.Load(cfgPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "prognosis: %v\n", err)
+ return 2
+ }
+ // Write the defaults out on first run, so the file documents itself.
+ if _, statErr := os.Stat(cfgPath); os.IsNotExist(statErr) {
+ if err := config.WriteDefault(cfgPath, cfg); err == nil {
+ fmt.Fprintf(os.Stderr, "note: wrote default config to %s\n", cfgPath)
+ }
+ }
+
+ // Flags override the file. The place is settled later, by placeFromArgs.
+ if *columns != "" {
+ cfg.Columns = splitList(*columns)
+ }
+ if *icons != "" {
+ cfg.Icons = *icons
+ }
+ if *lang != "" {
+ cfg.DisplayLang = *lang
+ }
+ if *noGraph {
+ cfg.Graph = false
+ }
+ if *weather {
+ // Meant for piping to someone else: the forecast and nothing else.
+ cfg.Minimal = true
+ cfg.Graph = false
+ }
+ if *noWarn {
+ cfg.Warnings = false
+ }
+ if *asciiOut {
+ cfg.ASCII = true
+ }
+ if cfg.ASCII {
+ // Both glyph sets are non-ASCII by definition.
+ cfg.Icons = "none"
+ }
+ if *noColor {
+ cfg.Color = "never"
+ }
+ switch {
+ case *days > 0 && *hours > 0:
+ fmt.Fprintln(os.Stderr, "prognosis: -n and -d cannot be combined")
+ return 2
+ case *days > 0:
+ if *days > openmeteo.MaxForecastDays-1 {
+ fmt.Fprintf(os.Stderr, "prognosis: -d must be between 1 and %d\n", openmeteo.MaxForecastDays-1)
+ return 2
+ }
+ cfg.Hours = *days * 24
+ case *hours > 0:
+ cfg.Hours = *hours
+ case *days < 0 || *hours < 0:
+ fmt.Fprintln(os.Stderr, "prognosis: hours and days must be positive")
+ return 2
+ }
+
+ // -icons only chooses what the icon column draws. Without that column it
+ // changes nothing, which looks like the flag being ignored.
+ if *icons != "" && !cfg.Has("icon") {
+ fmt.Fprintf(os.Stderr,
+ "note: -icons has no effect: %q is not in columns (add it: -columns %s)\n",
+ "icon", strings.Join(append([]string{"hour", "icon"}, cfg.Columns[1:]...), ","))
+ }
+
+ if err := cfg.Validate(); err != nil {
+ fmt.Fprintf(os.Stderr, "prognosis: %v\n", err)
+ return 2
+ }
+
+ place, err := placeFromArgs(*location, positional)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "prognosis: %v\n", err)
+ return 2
+ }
+ if place != "" {
+ cfg.Location = place
+ }
+ if cfg.Location == "" {
+ cfg.Location = locationFromWegorc()
+ }
+ if cfg.Location == "" {
+ fmt.Fprintf(os.Stderr, "prognosis: no location set in %s and none in ~/%s\n", cfgPath, wegorc)
+ return 2
+ }
+
+ store := cache.New(cache.DefaultPath())
+ geo, err := resolve(store, cfg.Location, *pick)
+ if err != nil {
+ var amb *ambiguousError
+ if errors.As(err, &amb) {
+ fmt.Fprintf(os.Stderr, "prognosis: %s\n", ambiguousListing(amb))
+ return 2
+ }
+ fmt.Fprintf(os.Stderr, "prognosis: %v\n", err)
+ var pe *pickError
+ if errors.As(err, &pe) {
+ return 2
+ }
+ return 1
+ }
+
+ data, err := openmeteo.Forecast(geo.Lat, geo.Lon, cfg.Hours, cfg.Units, cfg.Fields())
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "prognosis: %v\n", err)
+ return 1
+ }
+ cat := i18n.For(cfg.DisplayLang)
+ if len(data.Rows) < cfg.Hours {
+ fmt.Fprintf(os.Stderr, "note: %d %s %d\n", len(data.Rows), cat.Word("hours_available"), cfg.Hours)
+ }
+
+ view := render.View{
+ Label: geo.Label, TZ: data.TZ, Rows: data.Rows,
+ Sun: data.Sun, Daily: data.Daily,
+ }
+ if len(cfg.Pollen) > 0 {
+ // Pollen is a nicety: a failure here must not cost the forecast.
+ if peaks, err := openmeteo.Pollen(geo.Lat, geo.Lon, cfg.Hours, cfg.Pollen); err == nil {
+ view.Pollen = peaks
+ }
+ }
+ if cfg.Warnings {
+ view.Warnings, view.WarnNote, view.WarnFailed = warnings(store, geo, cat)
+ }
+
+ colour := shouldColour(cfg.Color)
+ fmt.Println(render.Render(view, cfg, terminalWidth(), colour))
+ return 0
+}
+
+// warnings resolves the powiat and fetches warnings for it.
+//
+// The three outcomes are deliberately distinct: a list, a note explaining why
+// there can be none, or failed. Silence must never be read as all-clear.
+func warnings(store *cache.Cache, geo cache.Geo, cat *i18n.Catalog) ([]imgw.Warning, string, bool) {
+ if geo.Country != "" && geo.Country != "PL" {
+ return nil, cat.Word("poland_only"), false
+ }
+ // A bare "lat,lon" carries no country, so ask GUGiK where the point is
+ // rather than inferring from a code we do not have.
+ key := fmt.Sprintf("%.4f,%.4f", geo.Lat, geo.Lon)
+ code, cached := store.Teryt(key)
+ if !cached {
+ var status imgw.Status
+ var err error
+ code, status, err = imgw.Powiat(geo.Lat, geo.Lon)
+ if err != nil || status == imgw.StatusError {
+ return nil, "", true
+ }
+ _ = store.PutTeryt(key, code) // "" records "not in Poland"
+ }
+ if code == "" {
+ return nil, cat.Word("poland_only"), false
+ }
+ live, err := imgw.Warnings(code)
+ if err != nil {
+ return nil, "", true
+ }
+ return live, "", false
+}
+
+// placeFromArgs settles where the place name comes from: -l, or bare words, but
+// never both. A leftover positional next to -l means the shell split an unquoted
+// name, and accepting it silently discards half of what was typed.
+func placeFromArgs(location string, positional []string) (string, error) {
+ if location != "" {
+ if len(positional) > 0 {
+ return "", fmt.Errorf("unexpected argument %q (did you mean -l %q?)",
+ strings.Join(positional, " "),
+ strings.Join(append([]string{location}, positional...), " "))
+ }
+ return location, nil
+ }
+ // A positional argument is accepted as the place, as the Python version did.
+ return strings.Join(positional, " "), nil
+}
+
+// geocode is a variable so tests can resolve a place without the network.
+var geocode = openmeteo.Geocode
+
+// ambiguousError reports a name that matched several places with none chosen.
+// Picking the first match silently is how a request for Wiry in Poland comes
+// back as a forecast for Vyry in Ukraine, so resolve refuses and hands the
+// candidates back for the caller to list.
+type ambiguousError struct {
+ place string
+ candidates []openmeteo.Candidate
+}
+
+func (e *ambiguousError) Error() string {
+ return fmt.Sprintf("%q is ambiguous - nothing fetched", e.place)
+}
+
+// pickError reports a -pick outside the candidate list. It is a bad flag value,
+// not a runtime failure, so it exits 2 like every other one.
+type pickError struct {
+ place string
+ pick int
+ n int
+}
+
+func (e *pickError) Error() string {
+ return fmt.Sprintf("-pick %d, but %q matched %d place(s)", e.pick, e.place, e.n)
+}
+
+// ambiguousListing renders the candidates as a numbered list. It carries the
+// region, because that is the only thing telling two places of one name apart,
+// and the coordinates, because they are what a caller falls back to when it
+// wants a place in a script rather than typing -pick every time.
+func ambiguousListing(e *ambiguousError) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "%v.\n", e)
+ regions := make([]string, len(e.candidates))
+ labelW, regionW := 0, 0
+ for i, c := range e.candidates {
+ regions[i] = c.Admin1
+ if regions[i] == "" {
+ regions[i] = "?"
+ }
+ // fmt pads by runes, so measure by runes or the diacritics misalign.
+ if n := len([]rune(c.Geo.Label)); n > labelW {
+ labelW = n
+ }
+ if n := len([]rune(regions[i])); n > regionW {
+ regionW = n
+ }
+ }
+ for i, c := range e.candidates {
+ fmt.Fprintf(&b, " %d %-*s %-*s %.4f,%.4f\n",
+ i+1, labelW, c.Geo.Label, regionW, regions[i], c.Geo.Lat, c.Geo.Lon)
+ }
+ fmt.Fprint(&b, "Re-run with -pick N, or give coordinates as the place.")
+ return b.String()
+}
+
+// resolve turns a place name into coordinates. pick is 1-based and selects one
+// of the geocoder's candidates; 0 means none was requested.
+func resolve(store *cache.Cache, place string, pick int) (cache.Geo, error) {
+ if lat, lon, ok := parseCoords(place); ok {
+ // Coordinates carry no country code; callers must not assume one.
+ return cache.Geo{Lat: lat, Lon: lon, Label: place}, nil
+ }
+ // An explicit pick must re-resolve rather than read the cache: the entry
+ // sitting there is usually the wrong guess being corrected.
+ if pick == 0 {
+ if g, ok := store.Geo(place); ok {
+ return g, nil
+ }
+ }
+ cands, err := geocode(place)
+ if err != nil {
+ return cache.Geo{}, err
+ }
+ switch {
+ case pick != 0:
+ if pick < 1 || pick > len(cands) {
+ return cache.Geo{}, &pickError{place: place, pick: pick, n: len(cands)}
+ }
+ case len(cands) > 1:
+ return cache.Geo{}, &ambiguousError{place: place, candidates: cands}
+ default:
+ pick = 1
+ }
+ g := cands[pick-1].Geo
+ _ = store.PutGeo(place, g)
+ return g, nil
+}
+
+func parseCoords(s string) (float64, float64, bool) {
+ lat, lon, ok := strings.Cut(s, ",")
+ if !ok {
+ return 0, 0, false
+ }
+ a, err1 := strconv.ParseFloat(strings.TrimSpace(lat), 64)
+ b, err2 := strconv.ParseFloat(strings.TrimSpace(lon), 64)
+ if err1 != nil || err2 != nil {
+ return 0, 0, false
+ }
+ return a, b, true
+}
+
+// locationFromWegorc reads location= from wego's config, so the two tools never
+// disagree about where you are.
+func locationFromWegorc() string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return ""
+ }
+ f, err := os.Open(home + "/" + wegorc)
+ if err != nil {
+ return ""
+ }
+ defer f.Close()
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ line := strings.TrimSpace(sc.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ if k, v, ok := strings.Cut(line, "="); ok && strings.TrimSpace(k) == "location" {
+ if v = strings.TrimSpace(v); v != "" {
+ return v
+ }
+ }
+ }
+ return ""
+}
+
+func shouldColour(mode string) bool {
+ switch mode {
+ case "never":
+ return false
+ case "always":
+ return true
+ }
+ // Stdlib isatty: a terminal is a character device, a pipe or file is not.
+ fi, err := os.Stdout.Stat()
+ return err == nil && fi.Mode()&os.ModeCharDevice != 0 && os.Getenv("TERM") != "dumb"
+}
+
+func terminalWidth() int {
+ w := 80
+ if env := os.Getenv("COLUMNS"); env != "" {
+ if n, err := strconv.Atoi(env); err == nil && n > 0 {
+ w = n
+ }
+ } else if n, ok := termCols(); ok {
+ w = n
+ }
+ w -= 4
+ if w < 32 {
+ w = 32
+ }
+ if w > 96 {
+ w = 96
+ }
+ return w
+}
+
+func splitList(v string) []string {
+ var out []string
+ for _, p := range strings.Split(v, ",") {
+ if p = strings.TrimSpace(p); p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func usage() {
+ fmt.Fprintf(os.Stderr, `prognosis - hour-by-hour forecast, with IMGW warnings for Poland
+
+usage: prognosis [flags] [place]
+
+flags:
+ -l PLACE place to query (default: config, then ~/.wegorc)
+ -pick N choose the Nth place when the name matches several
+ -n N hours ahead to show
+ -d N days ahead to show, 24h each (max %d)
+ -columns LIST comma-separated columns; valid: %s
+ -icons SET nerd, emoji or none
+ -lang LANG en or pl
+ -no-graph table only
+ -weather forecast only: no sun times, summary, pollen or chart
+ -no-warnings omit IMGW warnings
+ -ascii ASCII only, so an SMS stays in GSM-7 (160 chars, not 70)
+ -no-color plain output
+ -config print the config file path and exit
+`, openmeteo.MaxForecastDays-1, strings.Join(config.ValidColumns(), ", "))
+}
diff --git a/cmd/prognosis/resolve_test.go b/cmd/prognosis/resolve_test.go
new file mode 100644
index 0000000..f7f1347
--- /dev/null
+++ b/cmd/prognosis/resolve_test.go
@@ -0,0 +1,171 @@
+package main
+
+import (
+ "errors"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/lukaszkasprzak/prognosis/internal/cache"
+ "github.com/lukaszkasprzak/prognosis/internal/openmeteo"
+)
+
+func tmpStore(t *testing.T) *cache.Cache {
+ t.Helper()
+ return cache.New(filepath.Join(t.TempDir(), "cache.json"))
+}
+
+// stubGeocoder replaces the network for the duration of one test.
+func stubGeocoder(t *testing.T, cands []openmeteo.Candidate) *int {
+ t.Helper()
+ calls := 0
+ prev := geocode
+ geocode = func(string) ([]openmeteo.Candidate, error) {
+ calls++
+ return cands, nil
+ }
+ t.Cleanup(func() { geocode = prev })
+ return &calls
+}
+
+func twoWirys() []openmeteo.Candidate {
+ return []openmeteo.Candidate{
+ {Geo: cache.Geo{Lat: 52.3205, Lon: 16.8532, Label: "Wiry, PL", Country: "PL"}, Admin1: "Greater Poland"},
+ {Geo: cache.Geo{Lat: 50.8367, Lon: 16.6467, Label: "Wiry, PL", Country: "PL"}, Admin1: "Lower Silesia"},
+ }
+}
+
+func TestResolveRefusesAnAmbiguousName(t *testing.T) {
+ stubGeocoder(t, twoWirys())
+ _, err := resolve(tmpStore(t), "Wiry, PL", 0)
+ var amb *ambiguousError
+ if !errors.As(err, &amb) {
+ t.Fatalf("got err %v, want an ambiguousError: guessing is what sent the user to the wrong country", err)
+ }
+ if len(amb.candidates) != 2 {
+ t.Errorf("error carries %d candidates, want 2 so the user can choose", len(amb.candidates))
+ }
+}
+
+func TestResolveDoesNotCacheAnAmbiguousName(t *testing.T) {
+ stubGeocoder(t, twoWirys())
+ store := tmpStore(t)
+ if _, err := resolve(store, "Wiry, PL", 0); err == nil {
+ t.Fatal("expected a refusal")
+ }
+ if g, ok := store.Geo("Wiry, PL"); ok {
+ t.Errorf("cached %+v for an ambiguous name; a wrong guess would stick forever", g)
+ }
+}
+
+func TestResolvePickSelectsTheNthCandidate(t *testing.T) {
+ stubGeocoder(t, twoWirys())
+ got, err := resolve(tmpStore(t), "Wiry, PL", 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Lat != 50.8367 {
+ t.Errorf("got lat %v, want 50.8367 (Lower Silesia, the second candidate)", got.Lat)
+ }
+}
+
+func TestResolvePickRemembersTheChoice(t *testing.T) {
+ stubGeocoder(t, twoWirys())
+ store := tmpStore(t)
+ if _, err := resolve(store, "Wiry, PL", 2); err != nil {
+ t.Fatal(err)
+ }
+ g, ok := store.Geo("Wiry, PL")
+ if !ok {
+ t.Fatal("a picked place was not cached, so the choice must be repeated every run")
+ }
+ if g.Lat != 50.8367 {
+ t.Errorf("cached lat %v, want the picked candidate's 50.8367", g.Lat)
+ }
+}
+
+func TestResolvePickOutOfRangeIsAnError(t *testing.T) {
+ stubGeocoder(t, twoWirys())
+ _, err := resolve(tmpStore(t), "Wiry, PL", 3)
+ if err == nil {
+ t.Fatal("expected an error: silently clamping would pick a place the user did not ask for")
+ }
+ // Every other bad flag value in this program exits 2; this must too, which
+ // means run() has to be able to tell it apart from a network failure.
+ var pe *pickError
+ if !errors.As(err, &pe) {
+ t.Errorf("got %T, want *pickError so run() can exit 2 rather than 1", err)
+ }
+}
+
+// A name resolved wrongly before this change is still in the cache, and the
+// cache is consulted first. Without this, --pick could never repair it.
+func TestResolvePickBypassesAPoisonedCacheEntry(t *testing.T) {
+ calls := stubGeocoder(t, twoWirys())
+ store := tmpStore(t)
+ poison := cache.Geo{Lat: 51.2417, Lon: 26.9411, Label: "Vyry, UA", Country: "UA"}
+ if err := store.PutGeo("Wiry, PL", poison); err != nil {
+ t.Fatal(err)
+ }
+ got, err := resolve(store, "Wiry, PL", 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if *calls == 0 {
+ t.Error("--pick used the cache instead of re-resolving, so a bad entry can never be corrected")
+ }
+ if got.Country != "PL" {
+ t.Errorf("got %+v, want the picked Polish candidate", got)
+ }
+ if g, _ := store.Geo("Wiry, PL"); g.Country != "PL" {
+ t.Errorf("cache still holds %+v; the pick should overwrite it", g)
+ }
+}
+
+func TestResolveCachesAnUnambiguousName(t *testing.T) {
+ only := []openmeteo.Candidate{
+ {Geo: cache.Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}, Admin1: "Subcarpathia"},
+ }
+ calls := stubGeocoder(t, only)
+ store := tmpStore(t)
+ for i := 0; i < 2; i++ {
+ got, err := resolve(store, "Krakow", 0)
+ if err != nil {
+ t.Fatalf("run %d: %v", i+1, err)
+ }
+ if got.Label != "Krakow, PL" {
+ t.Fatalf("run %d: got %+v", i+1, got)
+ }
+ }
+ if *calls != 1 {
+ t.Errorf("geocoded %d times, want 1: the second run should hit the cache", *calls)
+ }
+}
+
+func TestResolveAcceptsBareCoordinates(t *testing.T) {
+ stubGeocoder(t, nil)
+ got, err := resolve(tmpStore(t), "50.8367,16.6467", 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Lat != 50.8367 || got.Lon != 16.6467 {
+ t.Errorf("got %+v, want the coordinates parsed as given", got)
+ }
+}
+
+// The listing is the whole remedy: if it omits the region the user cannot tell
+// the duplicates apart, and if it omits coordinates there is no way to reach a
+// candidate that -pick is not being used for.
+func TestAmbiguousListingIsActionable(t *testing.T) {
+ out := ambiguousListing(&ambiguousError{place: "Wiry, PL", candidates: twoWirys()})
+ for _, want := range []string{
+ "1", "2",
+ "Greater Poland", "Lower Silesia",
+ "52.3205", "50.8367",
+ "-pick",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("listing is missing %q; user cannot act on it:\n%s", want, out)
+ }
+ }
+}
diff --git a/cmd/prognosis/resolver.go b/cmd/prognosis/resolver.go
new file mode 100644
index 0000000..095cc20
--- /dev/null
+++ b/cmd/prognosis/resolver.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// configureResolver teaches Go's resolver where the nameservers are on Android.
+//
+// Android has no /etc/resolv.conf. Go's pure-Go resolver falls back to
+// localhost, so every lookup fails with "dial tcp [::1]:53: connection
+// refused". Termux does ship one, at $PREFIX/etc/resolv.conf, which Go never
+// consults. Reading it here uses whatever nameservers are actually configured
+// rather than hardcoding any.
+//
+// A no-op everywhere /etc/resolv.conf exists, which is every other platform we
+// build for.
+func configureResolver() {
+ if _, err := os.Stat("/etc/resolv.conf"); err == nil {
+ return
+ }
+ prefix := os.Getenv("PREFIX")
+ if prefix == "" {
+ return
+ }
+ servers := nameservers(filepath.Join(prefix, "etc", "resolv.conf"))
+ if len(servers) == 0 {
+ return
+ }
+ net.DefaultResolver = &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
+ d := net.Dialer{Timeout: 5 * time.Second}
+ var err error
+ for _, s := range servers {
+ var conn net.Conn
+ conn, err = d.DialContext(ctx, network, net.JoinHostPort(s, "53"))
+ if err == nil {
+ return conn, nil
+ }
+ }
+ return nil, err
+ },
+ }
+}
+
+func nameservers(path string) []string {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil
+ }
+ defer f.Close()
+ var out []string
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ fields := strings.Fields(sc.Text())
+ if len(fields) >= 2 && fields[0] == "nameserver" {
+ out = append(out, fields[1])
+ }
+ }
+ return out
+}
diff --git a/cmd/prognosis/term_other.go b/cmd/prognosis/term_other.go
new file mode 100644
index 0000000..d1263f0
--- /dev/null
+++ b/cmd/prognosis/term_other.go
@@ -0,0 +1,6 @@
+//go:build !(linux || darwin || freebsd || netbsd || openbsd)
+
+package main
+
+// termCols has no portable implementation here; COLUMNS or the default is used.
+func termCols() (int, bool) { return 0, false }
diff --git a/cmd/prognosis/term_unix.go b/cmd/prognosis/term_unix.go
new file mode 100644
index 0000000..7cccf25
--- /dev/null
+++ b/cmd/prognosis/term_unix.go
@@ -0,0 +1,28 @@
+//go:build linux || darwin || freebsd || netbsd || openbsd
+
+package main
+
+import (
+ "os"
+ "syscall"
+ "unsafe"
+)
+
+// termCols asks the terminal for its width.
+//
+// This is an ioctl rather than golang.org/x/term because the spec keeps this
+// binary free of third-party modules; it is a dozen lines and only needs to
+// work on the platforms prognosis is built for.
+func termCols() (int, bool) {
+ var ws struct{ Row, Col, Xpixel, Ypixel uint16 }
+ _, _, errno := syscall.Syscall(
+ syscall.SYS_IOCTL,
+ os.Stdout.Fd(),
+ uintptr(syscall.TIOCGWINSZ),
+ uintptr(unsafe.Pointer(&ws)),
+ )
+ if errno != 0 || ws.Col == 0 {
+ return 0, false
+ }
+ return int(ws.Col), true
+}