package cli import ( "context" "encoding/json" "flag" "fmt" "io" "gitea.maximumdirect.net/eric/weatherreporter/internal/app" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) const helpText = `weatherreporter prepares weather reports from normalized forecast data. Usage: weatherreporter --help weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] weatherreporter inspect reports [--config PATH] [--limit N] weatherreporter inspect metadata [--config PATH] RUN_ID weatherreporter inspect briefing [--config PATH] RUN_ID weatherreporter inspect data-package [--config PATH] RUN_ID weatherreporter inspect prior [--config PATH] RUN_ID weatherreporter inspect sources [--config PATH] RUN_ID Options: -h, --help Show this help message. --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml. --units VALUE Override weather API units. --tz NAME Override weather API timezone. --out PATH Write an extra Markdown report copy for generate commands. --out-dir PATH Write extra Markdown report copies for run commands. ` type Runner struct { Clock timeutil.Clock } func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { return Runner{Clock: timeutil.SystemClock{}}.Run(ctx, args, stdout, stderr) } func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { _ = stderr if r.Clock == nil { r.Clock = timeutil.SystemClock{} } if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { _, err := fmt.Fprint(stdout, helpText) return err } switch args[0] { case "generate": req, err := r.resolveGenerate(args[1:]) if err != nil { return err } return app.Generate(ctx, req) case "run": req, err := r.resolveRun(args[1:]) if err != nil { return err } result, err := app.RunBatchDetailed(ctx, req) if result != nil { writeRunLogs(stderr, result) if encodeErr := writeRunSummary(stdout, result); encodeErr != nil { return encodeErr } if result.Failed > 0 { return app.BatchError{Result: result} } } return err case "inspect": return r.runInspect(ctx, args[1:], stdout) default: return fmt.Errorf("unknown command %q", args[0]) } } type commonOptions struct { ConfigPath string Units string Timezone string Output string OutputDir string } type generateOptions struct { commonOptions Date string Start string End string } type inspectOptions struct { ConfigPath string Limit int RunID string } func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error { if len(args) == 0 { return fmt.Errorf("inspect requires a command") } command := args[0] switch command { case "reports": opts, err := parseInspectReportsFlags(args[1:]) if err != nil { return err } cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) if err != nil { return err } records, err := app.InspectReports(ctx, app.InspectReportsRequest{Config: cfg, Limit: opts.Limit}) if err != nil { return err } return writeJSON(stdout, records) case "metadata": opts, err := parseInspectRunFlags(command, args[1:]) if err != nil { return err } cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) if err != nil { return err } metadata, err := app.InspectMetadata(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID}) if err != nil { return err } return writeJSON(stdout, metadata) case "briefing": opts, err := parseInspectRunFlags(command, args[1:]) if err != nil { return err } cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) if err != nil { return err } pkg, err := app.InspectBriefing(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID}) if err != nil { return err } return writeJSON(stdout, pkg) case "data-package": opts, err := parseInspectRunFlags(command, args[1:]) if err != nil { return err } cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) if err != nil { return err } pkg, err := app.InspectDataPackage(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID}) if err != nil { return err } return writeJSON(stdout, pkg) case "prior": opts, err := parseInspectRunFlags(command, args[1:]) if err != nil { return err } cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) if err != nil { return err } prior, err := app.InspectPriorSnapshot(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID}) if err != nil { return err } return writeJSON(stdout, prior) case "sources": opts, err := parseInspectRunFlags(command, args[1:]) if err != nil { return err } cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) if err != nil { return err } sources, err := app.InspectSources(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID}) if err != nil { return err } return writeJSON(stdout, sources) default: return fmt.Errorf("unknown inspect command %q", command) } } func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { if r.Clock == nil { r.Clock = timeutil.SystemClock{} } if len(args) == 0 { return app.GenerateRequest{}, fmt.Errorf("generate requires a report name") } report, ok := reportKind(args[0]) if !ok { return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0]) } opts, err := parseGenerateFlags(report, args[1:]) if err != nil { return app.GenerateRequest{}, err } cfg, err := config.Load(config.LoadOptions{ Path: opts.ConfigPath, Units: opts.Units, Timezone: opts.Timezone, Output: opts.Output, }) if err != nil { return app.GenerateRequest{}, err } location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone) if err != nil { return app.GenerateRequest{}, err } req := app.GenerateRequest{ Config: cfg, Report: report, OutputPath: opts.Output, Now: r.Clock.Now(), } switch report { case app.ReportDaily: if opts.Date == "" { req.Date = timeutil.LocalDate(r.Clock.Now(), location) } else { req.Date, err = timeutil.ParseLocalDate(opts.Date, location) if err != nil { return app.GenerateRequest{}, err } } case app.ReportStorm: if opts.Start == "" { return app.GenerateRequest{}, fmt.Errorf("generate storm requires --start") } if opts.End == "" { return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end") } req.StormStart, err = timeutil.ParseStormTime(opts.Start, location) if err != nil { return app.GenerateRequest{}, err } req.StormEnd, err = timeutil.ParseStormTime(opts.End, location) if err != nil { return app.GenerateRequest{}, err } if !req.StormEnd.After(req.StormStart) { return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end after --start") } } return req, nil } func (r Runner) resolveRun(args []string) (app.BatchRequest, error) { if r.Clock == nil { r.Clock = timeutil.SystemClock{} } if len(args) == 0 { return app.BatchRequest{}, fmt.Errorf("run requires a batch name") } batch, ok := batchKind(args[0]) if !ok { return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0]) } opts, err := parseRunFlags(args[1:]) if err != nil { return app.BatchRequest{}, err } cfg, err := config.Load(config.LoadOptions{ Path: opts.ConfigPath, Units: opts.Units, Timezone: opts.Timezone, }) if err != nil { return app.BatchRequest{}, err } return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, nil } func resolveRun(args []string) (app.BatchRequest, error) { return Runner{Clock: timeutil.SystemClock{}}.resolveRun(args) } func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions, error) { fs := flag.NewFlagSet("generate "+string(report), flag.ContinueOnError) fs.SetOutput(io.Discard) opts := generateOptions{} addCommonFlags(fs, &opts.commonOptions, true) if report == app.ReportDaily { fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD") } if report == app.ReportStorm { fs.StringVar(&opts.Start, "start", "", "storm start time") fs.StringVar(&opts.End, "end", "", "storm end time") } if err := fs.Parse(args); err != nil { return generateOptions{}, err } if fs.NArg() > 0 { return generateOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0)) } return opts, nil } func parseRunFlags(args []string) (commonOptions, error) { fs := flag.NewFlagSet("run", flag.ContinueOnError) fs.SetOutput(io.Discard) opts := commonOptions{} addCommonFlags(fs, &opts, false) fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory") if err := fs.Parse(args); err != nil { return commonOptions{}, err } if fs.NArg() > 0 { return commonOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0)) } return opts, nil } func parseInspectReportsFlags(args []string) (inspectOptions, error) { fs := flag.NewFlagSet("inspect reports", flag.ContinueOnError) fs.SetOutput(io.Discard) opts := inspectOptions{Limit: 20} fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path") fs.IntVar(&opts.Limit, "limit", 20, "maximum reports to list") if err := fs.Parse(args); err != nil { return inspectOptions{}, err } if fs.NArg() > 0 { return inspectOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0)) } if opts.Limit < 0 { return inspectOptions{}, fmt.Errorf("limit must be zero or greater") } return opts, nil } func parseInspectRunFlags(command string, args []string) (inspectOptions, error) { fs := flag.NewFlagSet("inspect "+command, flag.ContinueOnError) fs.SetOutput(io.Discard) opts := inspectOptions{} fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path") if err := fs.Parse(args); err != nil { return inspectOptions{}, err } if fs.NArg() != 1 { return inspectOptions{}, fmt.Errorf("inspect %s requires a run id", command) } opts.RunID = fs.Arg(0) return opts, nil } func writeRunSummary(stdout io.Writer, result *app.BatchResult) error { encoder := json.NewEncoder(stdout) encoder.SetIndent("", " ") return encoder.Encode(result) } func writeJSON(stdout io.Writer, value any) error { encoder := json.NewEncoder(stdout) encoder.SetIndent("", " ") return encoder.Encode(value) } func writeRunLogs(stderr io.Writer, result *app.BatchResult) { if stderr == nil || result == nil { return } for _, item := range result.Reports { if item.Status == "failed" { _, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q\n", item.ReportID, item.Error) continue } _, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q\n", item.ReportID, item.OutputPath) } _, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed) } func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) { fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path") fs.StringVar(&opts.Units, "units", "", "weather API units") fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone") if includeOutput { fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path") } } func reportKind(value string) (app.ReportKind, bool) { switch value { case string(app.ReportDaily): return app.ReportDaily, true case string(app.ReportTomorrow): return app.ReportTomorrow, true case string(app.ReportThreeDay): return app.ReportThreeDay, true case string(app.ReportWeekend): return app.ReportWeekend, true case string(app.ReportStorm): return app.ReportStorm, true default: return "", false } } func batchKind(value string) (app.BatchKind, bool) { switch value { case string(app.BatchMorning): return app.BatchMorning, true case string(app.BatchEvening): return app.BatchEvening, true default: return "", false } }