diff options
Diffstat (limited to 'cmd/prognosis')
| -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 +} |
