From 391fc97fca932a50557d8e0f07ea2dfa8135900a Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 25 Aug 2026 15:54:53 +0200 Subject: ci: fail the build when documentation falls behind Adding a flag and forgetting the man page was the kind of thing only a reader would catch, and the README had already drifted: it documented seven of fifteen flags, claimed three cross-compilation targets where the Makefile builds six, and its example output predated the humidity column. Flag definitions move into defineFlags(), so the test enumerates the same set run() does rather than a hand-copied list that could drift in its own right. usage() gains a writer so its output can be captured. Tests then assert that every flag reaches -h, the README and the man page; that every key written into the generated config is documented; and that every column name is explained. The man page check normalises roff's \- hyphen escape first -- without that, every multi-word flag looks undocumented when it is not. --- cmd/prognosis/main.go | 106 +++++++++++++++++++++++++++++--------------------- 1 file changed, 61 insertions(+), 45 deletions(-) (limited to 'cmd/prognosis/main.go') diff --git a/cmd/prognosis/main.go b/cmd/prognosis/main.go index 3075563..99d9918 100644 --- a/cmd/prognosis/main.go +++ b/cmd/prognosis/main.go @@ -6,6 +6,7 @@ import ( "errors" "flag" "fmt" + "io" "os" "strconv" "strings" @@ -18,6 +19,35 @@ import ( "github.com/lukaszkasprzak/prognosis/internal/render" ) +// options is every command line flag. Defined in one place so that run() and +// the test asserting each one is documented cannot disagree about the set. +type options struct { + location, columns, icons, lang, pollen *string + hours, days, pick *int + noGraph, weather, noWarn, asciiOut, noColor *bool + showCfg, showVer *bool +} + +func defineFlags(fs *flag.FlagSet) *options { + return &options{ + location: fs.String("l", "", "place to query (default: location= in the config file)"), + hours: fs.Int("n", 0, "hours ahead to show"), + days: fs.Int("d", 0, "days ahead to show, 24h each"), + columns: fs.String("columns", "", "comma-separated columns to display"), + icons: fs.String("icons", "", "icon set: nerd, emoji or none"), + lang: fs.String("lang", "", "display language: en or pl"), + pollen: fs.String("pollen", "", "pollen species to show: a list, or all / none"), + noGraph: fs.Bool("no-graph", false, "table only, no chart"), + weather: fs.Bool("weather", false, "forecast only: no sun times, summary, pollen or chart"), + noWarn: fs.Bool("no-warnings", false, "omit IMGW warnings"), + asciiOut: fs.Bool("ascii", false, "ASCII only, so an SMS stays in GSM-7"), + noColor: fs.Bool("no-color", false, "plain output"), + pick: fs.Int("pick", 0, "choose the Nth place when the name is ambiguous"), + showCfg: fs.Bool("config", false, "print the config file path and exit"), + showVer: fs.Bool("version", false, "print the version and exit"), + } +} + // version is stamped at build time: -ldflags "-X main.version=$(git describe)". // "dev" means someone built it straight from a working tree. var version = "dev" @@ -28,23 +58,7 @@ func run() int { // Android has no /etc/resolv.conf; without this every lookup fails. configureResolver() - var ( - location = flag.String("l", "", "place to query (default: location= in the config file)") - 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") - pollen = flag.String("pollen", "", "pollen species to show: a list, or all / none") - 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") - showVer = flag.Bool("version", false, "print the version and exit") - ) + opt := defineFlags(flag.CommandLine) 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. @@ -62,13 +76,13 @@ func run() int { rest = flag.Args()[1:] } - if *showVer { + if *opt.showVer { fmt.Printf("prognosis %s\n", version) return 0 } cfgPath := config.Path() - if *showCfg { + if *opt.showCfg { fmt.Println(cfgPath) return 0 } @@ -86,66 +100,66 @@ func run() int { } // Flags override the file. The place is settled later, by placeFromArgs. - if *columns != "" { - cfg.Columns = splitList(*columns) + if *opt.columns != "" { + cfg.Columns = splitList(*opt.columns) } - if *icons != "" { - cfg.Icons = *icons + if *opt.icons != "" { + cfg.Icons = *opt.icons } - if *lang != "" { - cfg.DisplayLang = *lang + if *opt.lang != "" { + cfg.DisplayLang = *opt.lang } - if *pollen != "" { - switch *pollen { + if *opt.pollen != "" { + switch *opt.pollen { case "all": cfg.Pollen, cfg.PollenExplicit = append([]string(nil), config.AllSpecies...), false case "none": cfg.Pollen, cfg.PollenExplicit = nil, false default: - cfg.Pollen, cfg.PollenExplicit = splitList(*pollen), true + cfg.Pollen, cfg.PollenExplicit = splitList(*opt.pollen), true } } - if *noGraph { + if *opt.noGraph { cfg.Graph = false } - if *weather { + if *opt.weather { // Meant for piping to someone else: the forecast and nothing else. cfg.Minimal = true cfg.Graph = false } - if *noWarn { + if *opt.noWarn { cfg.Warnings = false } - if *asciiOut { + if *opt.asciiOut { cfg.ASCII = true } if cfg.ASCII { // Both glyph sets are non-ASCII by definition. cfg.Icons = "none" } - if *noColor { + if *opt.noColor { cfg.Color = "never" } switch { - case *days > 0 && *hours > 0: + case *opt.days > 0 && *opt.hours > 0: fmt.Fprintln(os.Stderr, "prognosis: -n and -d cannot be combined") return 2 - case *days > 0: - if *days > openmeteo.MaxForecastDays-1 { + case *opt.days > 0: + if *opt.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: + cfg.Hours = *opt.days * 24 + case *opt.hours > 0: + cfg.Hours = *opt.hours + case *opt.days < 0 || *opt.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") { + if *opt.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:]...), ",")) @@ -156,7 +170,7 @@ func run() int { return 2 } - place, err := placeFromArgs(*location, positional) + place, err := placeFromArgs(*opt.location, positional) if err != nil { fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) return 2 @@ -172,7 +186,7 @@ func run() int { } store := cache.New(cache.DefaultPath()) - geo, err := resolve(store, cfg.Location, *pick) + geo, err := resolve(store, cfg.Location, *opt.pick) if err != nil { var amb *ambiguousError if errors.As(err, &amb) { @@ -419,8 +433,10 @@ func splitList(v string) []string { return out } -func usage() { - fmt.Fprintf(os.Stderr, `prognosis - hour-by-hour forecast, with IMGW warnings for Poland +func usage() { usageTo(os.Stderr) } + +func usageTo(w io.Writer) { + fmt.Fprintf(w, `prognosis - hour-by-hour forecast, with IMGW warnings for Poland usage: prognosis [flags] [place] -- cgit v1.3