diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 13:04:10 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 13:04:10 +0200 |
| commit | e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (patch) | |
| tree | 670ef0897839871a64d3a3bb2e17e242e7d6c385 /cmd | |
| download | prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.tar.gz prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.zip | |
Initial commit: prognosis, the Go implementation
An hour-by-hour forecast for the terminal, with official IMGW warnings for
Polish locations. Replaces the Python version, whose cache file format it
keeps so the two can coexist until this reaches parity.
Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns
coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered
to that powiat rather than the whole country. Only the two lookups that
never change are cached. Forecasts never are.
Silence is never allowed to read as all-clear: "no warnings in force" and
"the check failed" are reported as distinct states.
Place names are resolved without guessing. A name matching several places
is refused with a numbered list carrying each candidate's region and
coordinates, and -pick N chooses one and remembers it. A stray positional
beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to
somewhere else.
The cache is written one entry per line with sorted keys, and treated as
disposable but not worthless: an entry that will not parse is skipped and
the rest kept, and a file that will not parse at all is moved to
cache.json.bad rather than overwritten.
No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests.
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/prognosis/args_test.go | 49 | ||||
| -rw-r--r-- | cmd/prognosis/main.go | 441 | ||||
| -rw-r--r-- | cmd/prognosis/resolve_test.go | 171 | ||||
| -rw-r--r-- | cmd/prognosis/resolver.go | 67 | ||||
| -rw-r--r-- | cmd/prognosis/term_other.go | 6 | ||||
| -rw-r--r-- | cmd/prognosis/term_unix.go | 28 |
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 +} |
