Move the command line from cmd/bsdaily into internal/cli (closes #8) #13
@@ -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
|
||||
|
||||
@@ -14,11 +14,16 @@ 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`
|
||||
|
||||
+2
-120
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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()
|
||||
|
||||
// wantErr is how the error 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
|
||||
}{
|
||||
{
|
||||
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: "--date and --from/--to are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "date with from and to", date: "2026-01-07",
|
||||
from: "2026-01-08", to: "2026-01-09",
|
||||
wantErr: "--date and --from/--to are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "malformed date", date: "27.06.2026",
|
||||
wantErr: `invalid --date "27.06.2026" (expected YYYY-MM-DD): `,
|
||||
},
|
||||
{
|
||||
name: "date that does not exist", date: "2026-02-30",
|
||||
wantErr: `invalid --date "2026-02-30" (expected YYYY-MM-DD): `,
|
||||
},
|
||||
{
|
||||
name: "malformed from", from: "2026-1-10", to: "2026-01-11",
|
||||
wantErr: `invalid --from "2026-1-10" (expected YYYY-MM-DD): `,
|
||||
},
|
||||
{
|
||||
name: "malformed to", from: "2026-01-12", to: "tomorrow",
|
||||
wantErr: `invalid --to "tomorrow" (expected YYYY-MM-DD): `,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := cli.ParseTargetDates(tt.date, tt.from, tt.to)
|
||||
if err == nil || !strings.HasPrefix(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error = %v, want one starting %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user