diff --git a/README.md b/README.md index cdf50c1..c3654c5 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,11 @@ included to do exactly this and to rebuild the aggregate statistics # How It Works +`cmd/bsdaily/main.go` only passes the version to `internal/cli` and exits with +the status it returns. `internal/cli` holds the command line: the flags, the +rules for combining them and the parsing of the dates they name. +`internal/bsdaily` does the extraction. + A single run proceeds as follows: 1. **Find the snapshot.** Scan `SnapshotBase` for directories matching diff --git a/TODO.md b/TODO.md index bbc9e59..6353612 100644 --- a/TODO.md +++ b/TODO.md @@ -14,11 +14,15 @@ pre-1.0 # Next Step -Expand tests beyond the compilation smoke test: unit tests for the extraction, -verification, and atomic-publish paths. +Expand the `internal/bsdaily` tests beyond the compilation smoke test: unit +tests for the extraction, verification, and atomic-publish paths. # Completed Steps +- 2026-10-06: Moved the command line (flags, the rules for combining them, date + parsing) from `cmd/bsdaily` into `internal/cli`, with unit tests for the flag + rules and the dates; `cmd/bsdaily/main.go` is now a single call into it + (https://git.eeqj.de/sneak/bsdaily/issues/8). - 2026-10-06: A plain `docker build .` and a host `make` build stamp the git tag or short commit into the binary, which `bsdaily` logs on the first line of every run and prints with `--version` diff --git a/cmd/bsdaily/main.go b/cmd/bsdaily/main.go index 0477df5..993b125 100644 --- a/cmd/bsdaily/main.go +++ b/cmd/bsdaily/main.go @@ -3,21 +3,9 @@ package main import ( - "errors" - "fmt" - "log/slog" "os" - "time" - "git.eeqj.de/sneak/bsdaily/internal/bsdaily" - "github.com/spf13/cobra" -) - -var ( - errDateExclusive = errors.New("--date and --from/--to are mutually exclusive") - errFromRequiresTo = errors.New("--from requires --to") - errToRequiresFrom = errors.New("--to requires --from") - errFromAfterTo = errors.New("is after --to") + "git.eeqj.de/sneak/bsdaily/internal/cli" ) // Version is the git tag or short commit, set at link time with @@ -28,111 +16,5 @@ var ( var Version string func main() { - if Version == "" { - Version = "dev" - } - - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelInfo, - })) - slog.SetDefault(logger) - - var dateFlag, fromFlag, toFlag string - - rootCmd := &cobra.Command{ - Use: "bsdaily", - Short: "Extract a single day's data from the latest daily snapshot", - Version: Version, - SilenceUsage: true, - RunE: func(_ *cobra.Command, _ []string) error { - slog.Info("starting", "version", Version) - - targetDates, err := parseTargetDates(dateFlag, fromFlag, toFlag) - if err != nil { - return err - } - - err = bsdaily.Run(targetDates) - if err != nil { - return err - } - - slog.Info("completed successfully") - - return nil - }, - } - - rootCmd.Flags().StringVarP(&dateFlag, "date", "d", "", - "target date to extract (YYYY-MM-DD); "+ - "defaults to snapshot date minus one day") - rootCmd.Flags().StringVar(&fromFlag, "from", "", - "start of date range to extract (YYYY-MM-DD, inclusive); use with --to") - rootCmd.Flags().StringVar(&toFlag, "to", "", - "end of date range to extract (YYYY-MM-DD, inclusive); use with --from") - - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } -} - -// parseTargetDates turns the --date, --from and --to flags into the days -// to extract. It returns nil when none of them is set, which Run takes -// to mean the snapshot date minus one day. -func parseTargetDates(dateFlag, fromFlag, toFlag string) ([]time.Time, error) { - hasDate := dateFlag != "" - hasFrom := fromFlag != "" - hasTo := toFlag != "" - - // Validate mutual exclusivity - if hasDate && (hasFrom || hasTo) { - return nil, errDateExclusive - } - - if hasFrom != hasTo { - if hasFrom { - return nil, errFromRequiresTo - } - - return nil, errToRequiresFrom - } - - if hasDate { - t, err := time.Parse("2006-01-02", dateFlag) - if err != nil { - return nil, fmt.Errorf( - "invalid --date %q (expected YYYY-MM-DD): %w", dateFlag, err) - } - - return []time.Time{t}, nil - } - - if !hasFrom { - return nil, nil - } - - from, err := time.Parse("2006-01-02", fromFlag) - if err != nil { - return nil, fmt.Errorf( - "invalid --from %q (expected YYYY-MM-DD): %w", fromFlag, err) - } - - to, err := time.Parse("2006-01-02", toFlag) - if err != nil { - return nil, fmt.Errorf( - "invalid --to %q (expected YYYY-MM-DD): %w", toFlag, err) - } - - if from.After(to) { - return nil, fmt.Errorf("--from %s %w %s", fromFlag, errFromAfterTo, toFlag) - } - - var targetDates []time.Time - - for d := from; !d.After(to); d = d.AddDate(0, 0, 1) { - targetDates = append(targetDates, d) - } - - return targetDates, nil + os.Exit(cli.Main(Version)) } diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..b806127 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,67 @@ +// Package cli is the bsdaily command line: the command, its flags, the +// rules for combining them and the dates they name. The extraction itself +// is in package bsdaily. +package cli + +import ( + "log/slog" + "os" + + "git.eeqj.de/sneak/bsdaily/internal/bsdaily" + "github.com/spf13/cobra" +) + +// Main runs the bsdaily command on the program's command-line arguments +// and returns the status for the process to exit with. version is the +// build's version; an empty one is reported as dev. +func Main(version string) int { + if version == "" { + version = "dev" + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + slog.SetDefault(logger) + + var dateFlag, fromFlag, toFlag string + + rootCmd := &cobra.Command{ + Use: "bsdaily", + Short: "Extract a single day's data from the latest daily snapshot", + Version: version, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + slog.Info("starting", "version", version) + + targetDates, err := ParseTargetDates(dateFlag, fromFlag, toFlag) + if err != nil { + return err + } + + err = bsdaily.Run(targetDates) + if err != nil { + return err + } + + slog.Info("completed successfully") + + return nil + }, + } + + rootCmd.Flags().StringVarP(&dateFlag, "date", "d", "", + "target date to extract (YYYY-MM-DD); "+ + "defaults to snapshot date minus one day") + rootCmd.Flags().StringVar(&fromFlag, "from", "", + "start of date range to extract (YYYY-MM-DD, inclusive); use with --to") + rootCmd.Flags().StringVar(&toFlag, "to", "", + "end of date range to extract (YYYY-MM-DD, inclusive); use with --from") + + err := rootCmd.Execute() + if err != nil { + return 1 + } + + return 0 +} diff --git a/internal/cli/dates.go b/internal/cli/dates.go new file mode 100644 index 0000000..fabd51c --- /dev/null +++ b/internal/cli/dates.go @@ -0,0 +1,74 @@ +package cli + +import ( + "errors" + "fmt" + "time" +) + +var ( + errDateExclusive = errors.New("--date and --from/--to are mutually exclusive") + errFromRequiresTo = errors.New("--from requires --to") + errToRequiresFrom = errors.New("--to requires --from") + errFromAfterTo = errors.New("is after --to") +) + +// ParseTargetDates turns the --date, --from and --to flags into the days +// to extract. It returns nil when none of them is set, which bsdaily.Run +// takes to mean the snapshot date minus one day. +func ParseTargetDates(dateFlag, fromFlag, toFlag string) ([]time.Time, error) { + hasDate := dateFlag != "" + hasFrom := fromFlag != "" + hasTo := toFlag != "" + + // Validate mutual exclusivity + if hasDate && (hasFrom || hasTo) { + return nil, errDateExclusive + } + + if hasFrom != hasTo { + if hasFrom { + return nil, errFromRequiresTo + } + + return nil, errToRequiresFrom + } + + if hasDate { + t, err := time.Parse("2006-01-02", dateFlag) + if err != nil { + return nil, fmt.Errorf( + "invalid --date %q (expected YYYY-MM-DD): %w", dateFlag, err) + } + + return []time.Time{t}, nil + } + + if !hasFrom { + return nil, nil + } + + from, err := time.Parse("2006-01-02", fromFlag) + if err != nil { + return nil, fmt.Errorf( + "invalid --from %q (expected YYYY-MM-DD): %w", fromFlag, err) + } + + to, err := time.Parse("2006-01-02", toFlag) + if err != nil { + return nil, fmt.Errorf( + "invalid --to %q (expected YYYY-MM-DD): %w", toFlag, err) + } + + if from.After(to) { + return nil, fmt.Errorf("--from %s %w %s", fromFlag, errFromAfterTo, toFlag) + } + + var targetDates []time.Time + + for d := from; !d.After(to); d = d.AddDate(0, 0, 1) { + targetDates = append(targetDates, d) + } + + return targetDates, nil +} diff --git a/internal/cli/dates_test.go b/internal/cli/dates_test.go new file mode 100644 index 0000000..818c43f --- /dev/null +++ b/internal/cli/dates_test.go @@ -0,0 +1,127 @@ +package cli_test + +import ( + "slices" + "strings" + "testing" + + "git.eeqj.de/sneak/bsdaily/internal/cli" +) + +func TestParseTargetDates(t *testing.T) { + t.Parallel() + + const oneDay = "2026-05-14" + + // want lists the expected days as YYYY-MM-DD. + tests := []struct { + name string + date string + from string + to string + want []string + }{ + {name: "no flags"}, + {name: "single date", date: "2026-06-27", want: []string{"2026-06-27"}}, + { + name: "range across a month end", from: "2026-06-29", to: "2026-07-02", + want: []string{"2026-06-29", "2026-06-30", "2026-07-01", "2026-07-02"}, + }, + {name: "range of one day", from: oneDay, to: oneDay, want: []string{oneDay}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := cli.ParseTargetDates(tt.date, tt.from, tt.to) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + days := make([]string, 0, len(got)) + for _, day := range got { + days = append(days, day.Format("2006-01-02")) + } + + if !slices.Equal(days, tt.want) { + t.Errorf("days = %v, want %v", days, tt.want) + } + }) + } +} + +func TestParseTargetDatesErrors(t *testing.T) { + t.Parallel() + + const dateExclusive = "--date and --from/--to are mutually exclusive" + + // wantErr is the whole error message. With startsWith set it is only how + // the message starts: for a malformed date, the date parser's own + // explanation follows it. + tests := []struct { + name string + date string + from string + to string + wantErr string + startsWith bool + }{ + { + name: "from after to", from: "2026-03-02", to: "2026-03-01", + wantErr: "--from 2026-03-02 is after --to 2026-03-01", + }, + {name: "from without to", from: "2026-04-01", wantErr: "--from requires --to"}, + {name: "to without from", to: "2026-04-02", wantErr: "--to requires --from"}, + { + name: "date with from", date: "2026-01-05", from: "2026-01-06", + wantErr: dateExclusive, + }, + { + name: "date with to", date: "2026-01-13", to: "2026-01-14", + wantErr: dateExclusive, + }, + { + name: "date with from and to", date: "2026-01-07", + from: "2026-01-08", to: "2026-01-09", + wantErr: dateExclusive, + }, + { + name: "malformed date", date: "27.06.2026", + wantErr: `invalid --date "27.06.2026" (expected YYYY-MM-DD): `, + startsWith: true, + }, + { + name: "date that does not exist", date: "2026-02-30", + wantErr: `invalid --date "2026-02-30" (expected YYYY-MM-DD): `, + startsWith: true, + }, + { + name: "malformed from", from: "2026-1-10", to: "2026-01-11", + wantErr: `invalid --from "2026-1-10" (expected YYYY-MM-DD): `, + startsWith: true, + }, + { + name: "malformed to", from: "2026-01-12", to: "tomorrow", + wantErr: `invalid --to "tomorrow" (expected YYYY-MM-DD): `, + startsWith: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := cli.ParseTargetDates(tt.date, tt.from, tt.to) + + switch { + case err == nil: + t.Errorf("no error, want %q", tt.wantErr) + case tt.startsWith && !strings.HasPrefix(err.Error(), tt.wantErr): + t.Errorf("error = %q, want one starting %q", err, tt.wantErr) + case !tt.startsWith && err.Error() != tt.wantErr: + t.Errorf("error = %q, want %q", err, tt.wantErr) + } + }) + } +}