Move the command line from cmd/bsdaily into internal/cli (closes #8)
check / check (push) Waiting to run
check / check (push) Waiting to run
cmd/bsdaily/main.go now only passes Version to cli.Main and exits with the status it returns, as REPO_POLICIES.md requires of cmd/. The cobra command, the flag rules and the date parsing moved unchanged into internal/cli, so flags, help text, error messages, exit status and the first log line stay as they were. The fallback to dev for an empty version moved with them into cli.Main. parseTargetDates is exported as ParseTargetDates so that a table test outside the package can reach it; the test never touches the filesystem or runs the extraction. README describes the new layout and TODO.md records the step. Model: opus-5-5
This commit is contained in:
@@ -156,6 +156,11 @@ included to do exactly this and to rebuild the aggregate statistics
|
|||||||
|
|
||||||
# How It Works
|
# 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:
|
A single run proceeds as follows:
|
||||||
|
|
||||||
1. **Find the snapshot.** Scan `SnapshotBase` for directories matching
|
1. **Find the snapshot.** Scan `SnapshotBase` for directories matching
|
||||||
|
|||||||
@@ -14,11 +14,16 @@ pre-1.0
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Expand tests beyond the compilation smoke test: unit tests for the extraction,
|
Expand the `internal/bsdaily` tests beyond the compilation smoke test: unit
|
||||||
verification, and atomic-publish paths.
|
tests for the extraction, verification, and atomic-publish paths.
|
||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
or short commit into the binary, which `bsdaily` logs on the first line of
|
||||||
every run and prints with `--version`
|
every run and prints with `--version`
|
||||||
|
|||||||
+2
-120
@@ -3,21 +3,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/bsdaily/internal/bsdaily"
|
"git.eeqj.de/sneak/bsdaily/internal/cli"
|
||||||
"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")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is the git tag or short commit, set at link time with
|
// Version is the git tag or short commit, set at link time with
|
||||||
@@ -28,111 +16,5 @@ var (
|
|||||||
var Version string
|
var Version string
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if Version == "" {
|
os.Exit(cli.Main(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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,105 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/bsdaily/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseTargetDates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// want lists the expected days as YYYY-MM-DD. 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
|
||||||
|
want []string
|
||||||
|
wantErr 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: "2026-05-14", to: "2026-05-14",
|
||||||
|
want: []string{"2026-05-14"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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()
|
||||||
|
|
||||||
|
got, err := cli.ParseTargetDates(tt.date, tt.from, tt.to)
|
||||||
|
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
if err == nil || !strings.HasPrefix(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("error = %v, want one starting %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user