package cli import ( "context" "flag" "fmt" "io" "gitea.maximumdirect.net/eric/weatherreporter/internal/app" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) const helpText = `weatherreporter prepares weather reports from normalized forecast data. Usage: weatherreporter --help weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet] weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet] weatherreporter inspect reports [--config PATH] [--limit N] weatherreporter inspect metadata [--config PATH] RUN_ID weatherreporter inspect modules [--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 where supported by the generate command. --out-dir PATH Write extra Markdown report copies for run commands. --quiet Suppress successful generate and run output. ` type Runner struct { Clock timeutil.Clock ExecutorFactory ExecutorFactory } 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 { 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, opts, err := r.resolveGenerateAction(args[1:]) if err != nil { return err } result, err := app.GenerateDetailed(ctx, req) if result != nil { summary := newGenerateSummary(result, err) if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil { return encodeErr } } return err case "run": req, opts, err := r.resolveRunAction(args[1:]) if err != nil { return err } result, err := app.RunBatchDetailed(ctx, req) if result != nil { summary := newBatchSummary(result) if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) { writeBatchStatus(w, result) }); encodeErr != nil { return encodeErr } if summary.Status == summaryStatusFailed { 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 Quiet bool } type generateOptions struct { commonOptions Date string } type inspectOptions struct { ConfigPath string Limit int RunID string } type inspectRunCommand struct { Name string Inspect func(context.Context, app.InspectRunRequest) (any, error) } var inspectRunCommands = []inspectRunCommand{ {Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) { return app.InspectMetadata(ctx, req) }}, {Name: "modules", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) { return app.InspectModules(ctx, req) }}, {Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) { return app.InspectDataPackage(ctx, req) }}, {Name: "prior", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) { return app.InspectPriorSnapshot(ctx, req) }}, {Name: "sources", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) { return app.InspectSources(ctx, req) }}, } 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) default: for _, candidate := range inspectRunCommands { if candidate.Name == command { return runInspectRunCommand(ctx, stdout, candidate, args[1:]) } } return fmt.Errorf("unknown inspect command %q", command) } } func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspectRunCommand, args []string) error { opts, err := parseInspectRunFlags(command.Name, args) if err != nil { return err } cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) if err != nil { return err } value, err := command.Inspect(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID}) if err != nil { return err } return writeJSON(stdout, value) } func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { req, _, err := r.resolveGenerateAction(args) return req, err } func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commonOptions, error) { if r.Clock == nil { r.Clock = timeutil.SystemClock{} } if len(args) == 0 { return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate requires a report name") } if _, err := report.IDForCommandName(args[0]); err != nil { return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("unknown generate report %q", args[0]) } reportKind := app.ReportKind(args[0]) opts, err := parseGenerateFlags(reportKind, args[1:]) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } cfg, err := config.Load(config.LoadOptions{ Path: opts.ConfigPath, Units: opts.Units, Timezone: opts.Timezone, }) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } executor, err := r.promptExecutor(cfg.Promptkit) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } req := app.GenerateRequest{ Config: cfg, Report: reportKind, OutputPath: opts.Output, Now: r.Clock.Now(), Executor: executor, } switch reportKind { case app.ReportDaily: if opts.Date == "" { return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD") } req.Date, err = timeutil.ParseLocalDate(opts.Date, location) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } case app.ReportToday: 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{}, commonOptions{}, err } } } return req, opts.commonOptions, nil } func (r Runner) resolveRun(args []string) (app.BatchRequest, error) { req, _, err := r.resolveRunAction(args) return req, err } func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions, error) { if r.Clock == nil { r.Clock = timeutil.SystemClock{} } if len(args) == 0 { return app.BatchRequest{}, commonOptions{}, fmt.Errorf("run requires a batch name") } if _, err := report.BatchForCommandName(args[0]); err != nil { return app.BatchRequest{}, commonOptions{}, fmt.Errorf("unknown run batch %q", args[0]) } batch := app.BatchKind(args[0]) opts, err := parseRunFlags(args[1:]) if err != nil { return app.BatchRequest{}, commonOptions{}, err } cfg, err := config.Load(config.LoadOptions{ Path: opts.ConfigPath, Units: opts.Units, Timezone: opts.Timezone, }) if err != nil { return app.BatchRequest{}, commonOptions{}, err } return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, opts, 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) fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output") if report == app.ReportDaily || report == app.ReportToday { fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD") } 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") fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output") 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 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") } }