Author SHA1 Message Date
clawbot 29922dba51 Move the command line from cmd/bsdaily into internal/cli (closes #8)
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
2026-10-06 19:42:41 +00:00
6 changed files with 281 additions and 122 deletions
+5
View File
@@ -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
+6 -2
View File
@@ -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`
+2 -120
View File
@@ -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))
}
+67
View File
@@ -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
}
+74
View File
@@ -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
}
+127
View File
@@ -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)
}
})
}
}