summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-27 08:53:26 +0300
committerPaul Buetow <paul@buetow.org>2026-04-27 08:53:26 +0300
commitc6e0b5cc48dedb52477cb0060e6ebc8ca4f088f2 (patch)
treec8dfff1c65648e342a5908e8df3e660173cd3d6a
parentdee27d8f2805c9e409853462d35f9103e1a8c53e (diff)
refactor(snonux): separate CLI parsing from I/O and theme logic
Split the overloaded parseFlags into focused helpers: - parseFlags now does *only* CLI flag parsing. - resolvePaths handles ~ expansion. - validateDirs performs filesystem creation/checks. - resolveTheme selects the random theme using rng. This removes the SRP violation in parseFlags and makes each function independently testable. Tests updated accordingly. Fixes: z8
-rw-r--r--cmd/snonux/main.go51
-rw-r--r--cmd/snonux/main_test.go115
-rw-r--r--cmd/snonux/resolve.go51
3 files changed, 149 insertions, 68 deletions
diff --git a/cmd/snonux/main.go b/cmd/snonux/main.go
index a494854..75e386e 100644
--- a/cmd/snonux/main.go
+++ b/cmd/snonux/main.go
@@ -35,7 +35,7 @@ const (
)
func main() {
- cfg, mode, err := parseFlags(os.Args[1:], rand.New(rand.NewSource(time.Now().UnixNano())))
+ cfg, mode, err := parseFlags(os.Args[1:])
if err != nil {
log.Fatalf("error: %v", err)
}
@@ -49,6 +49,18 @@ func main() {
return
}
+ if err := resolveTheme(cfg, rand.New(rand.NewSource(time.Now().UnixNano()))); err != nil {
+ log.Fatalf("error: %v", err)
+ }
+
+ if err := resolvePaths(cfg); err != nil {
+ log.Fatalf("error: %v", err)
+ }
+
+ if err := validateDirs(cfg); err != nil {
+ log.Fatalf("error: %v", err)
+ }
+
if err := run(cfg); err != nil {
log.Fatalf("error: %v", err)
}
@@ -63,10 +75,9 @@ func main() {
// errParseFlags is returned when flag parsing fails (e.g. unknown flag).
var errParseFlags = errors.New("flag parse error")
-// parseFlags reads CLI flags and returns a validated Config.
-// Special theme value "random" picks a theme at random from the registry.
-// The rng parameter must be non-nil; it is used for theme selection.
-func parseFlags(args []string, rng *rand.Rand) (*config.Config, cliMode, error) {
+// parseFlags reads CLI flags and returns a Config without touching the filesystem or generators.
+// Callers should invoke resolveTheme, resolvePaths, and validateDirs separately.
+func parseFlags(args []string) (*config.Config, cliMode, error) {
cfg := &config.Config{}
fs := flag.NewFlagSet("snonux", flag.ContinueOnError)
fs.SetOutput(io.Discard)
@@ -94,36 +105,6 @@ func parseFlags(args []string, rng *rand.Rand) (*config.Config, cliMode, error)
return nil, modeListThemes, nil
}
- // Resolve the special "random" value before any further validation.
- if cfg.Theme == "random" {
- if rng == nil {
- return nil, modeRun, fmt.Errorf("theme %q requires a seeded rng", cfg.Theme)
- }
- themes := generator.ListThemes()
- cfg.Theme = themes[rng.Intn(len(themes))]
- log.Printf("random theme selected: %s", cfg.Theme)
- }
-
- var err error
-
- cfg.InputDir, err = expandHome(cfg.InputDir)
- if err != nil {
- return nil, modeRun, fmt.Errorf("input dir: %w", err)
- }
-
- cfg.OutputDir, err = expandHome(cfg.OutputDir)
- if err != nil {
- return nil, modeRun, fmt.Errorf("output dir: %w", err)
- }
-
- if err := ensureDir(cfg.InputDir); err != nil {
- return nil, modeRun, fmt.Errorf("input dir: %w", err)
- }
-
- if err := ensureDir(cfg.OutputDir); err != nil {
- return nil, modeRun, fmt.Errorf("output dir: %w", err)
- }
-
return cfg, modeRun, nil
}
diff --git a/cmd/snonux/main_test.go b/cmd/snonux/main_test.go
index a3ff292..a4fe22f 100644
--- a/cmd/snonux/main_test.go
+++ b/cmd/snonux/main_test.go
@@ -62,7 +62,7 @@ func TestEnsureDir_createsAndRejectsFile(t *testing.T) {
func TestParseFlags_version(t *testing.T) {
t.Parallel()
- _, mode, err := parseFlags([]string{"-version"}, rand.New(rand.NewSource(1)))
+ _, mode, err := parseFlags([]string{"-version"})
if err != nil {
t.Fatal(err)
}
@@ -74,7 +74,7 @@ func TestParseFlags_version(t *testing.T) {
func TestParseFlags_listThemes(t *testing.T) {
t.Parallel()
- _, mode, err := parseFlags([]string{"-list-themes"}, rand.New(rand.NewSource(1)))
+ _, mode, err := parseFlags([]string{"-list-themes"})
if err != nil {
t.Fatal(err)
}
@@ -86,14 +86,12 @@ func TestParseFlags_listThemes(t *testing.T) {
func TestParseFlags_run(t *testing.T) {
t.Parallel()
- in := t.TempDir()
- out := t.TempDir()
cfg, mode, err := parseFlags([]string{
- "-input", in,
- "-output", out,
+ "-input", "/tmp/in",
+ "-output", "/tmp/out",
"-theme", "neon",
"-base-url", "https://t.test",
- }, rand.New(rand.NewSource(1)))
+ })
if err != nil {
t.Fatal(err)
}
@@ -108,14 +106,12 @@ func TestParseFlags_run(t *testing.T) {
func TestParseFlags_sync(t *testing.T) {
t.Parallel()
- in := t.TempDir()
- out := t.TempDir()
cfg, mode, err := parseFlags([]string{
- "-input", in,
- "-output", out,
+ "-input", "./in",
+ "-output", "./out",
"-theme", "neon",
"-sync",
- }, rand.New(rand.NewSource(1)))
+ })
if err != nil {
t.Fatal(err)
}
@@ -127,15 +123,49 @@ func TestParseFlags_sync(t *testing.T) {
}
}
-func TestParseFlags_randomTheme(t *testing.T) {
+func TestResolvePaths(t *testing.T) {
t.Parallel()
- in := t.TempDir()
- out := t.TempDir()
- cfg, _, err := parseFlags([]string{"-input", in, "-output", out, "-theme", "random"}, rand.New(rand.NewSource(42)))
+ cfg := &config.Config{
+ InputDir: filepath.Join("~", "snonux-test-in"),
+ OutputDir: filepath.Join("~", "snonux-test-out"),
+ }
+
+ if err := resolvePaths(cfg); err != nil {
+ t.Fatal(err)
+ }
+
+ home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
+ if cfg.InputDir != filepath.Join(home, "snonux-test-in") {
+ t.Fatalf("input dir: got %q", cfg.InputDir)
+ }
+ if cfg.OutputDir != filepath.Join(home, "snonux-test-out") {
+ t.Fatalf("output dir: got %q", cfg.OutputDir)
+ }
+}
+
+func TestResolveTheme_fixed(t *testing.T) {
+ t.Parallel()
+
+ cfg := &config.Config{Theme: "neon"}
+ if err := resolveTheme(cfg, rand.New(rand.NewSource(1))); err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Theme != "neon" {
+ t.Fatalf("expected fixed theme, got %q", cfg.Theme)
+ }
+}
+
+func TestResolveTheme_random(t *testing.T) {
+ t.Parallel()
+
+ cfg := &config.Config{Theme: "random"}
+ if err := resolveTheme(cfg, rand.New(rand.NewSource(42))); err != nil {
+ t.Fatal(err)
+ }
names := map[string]bool{}
for _, n := range generator.ListThemes() {
names[n] = true
@@ -145,39 +175,58 @@ func TestParseFlags_randomTheme(t *testing.T) {
}
}
-// TestParseFlags_randomTheme_deterministic verifies that when a seeded
+// TestResolveTheme_random_deterministic verifies that when a seeded
// *rand.Rand is passed in, the "random" theme resolve predictably.
-// This ensures the rng parameter is actually used.
-func TestParseFlags_randomTheme_deterministic(t *testing.T) {
+func TestResolveTheme_random_deterministic(t *testing.T) {
t.Parallel()
- in := t.TempDir()
- out := t.TempDir()
+ cfg1 := &config.Config{Theme: "random"}
+ if err := resolveTheme(cfg1, rand.New(rand.NewSource(1))); err != nil {
+ t.Fatal(err)
+ }
- // NewSource(1) results in a deterministic first draw across multiple calls.
- src := rand.NewSource(1)
- rng := rand.New(src)
+ cfg2 := &config.Config{Theme: "random"}
+ if err := resolveTheme(cfg2, rand.New(rand.NewSource(1))); err != nil {
+ t.Fatal(err)
+ }
+ if cfg1.Theme != cfg2.Theme {
+ t.Fatalf("expected deterministic theme %q, got %q", cfg1.Theme, cfg2.Theme)
+ }
+}
- cfg, _, err := parseFlags([]string{"-input", in, "-output", out, "-theme", "random"}, rng)
- if err != nil {
+func TestResolveTheme_nilRng(t *testing.T) {
+ t.Parallel()
+
+ cfg := &config.Config{Theme: "random"}
+ if err := resolveTheme(cfg, nil); err == nil {
+ t.Fatal("expected error for nil rng")
+ }
+}
+
+func TestValidateDirs(t *testing.T) {
+ t.Parallel()
+
+ in := t.TempDir()
+ out := t.TempDir()
+ cfg := &config.Config{InputDir: in, OutputDir: out}
+ if err := validateDirs(cfg); err != nil {
t.Fatal(err)
}
- // Reset the source and re-invoke parseFlags to confirm the same theme
- // is selected again (determinism).
- cfg2, _, err := parseFlags([]string{"-input", in, "-output", out, "-theme", "random"}, rand.New(rand.NewSource(1)))
- if err != nil {
+ badFile := filepath.Join(t.TempDir(), "notadir")
+ if err := os.WriteFile(badFile, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
- if cfg.Theme != cfg2.Theme {
- t.Fatalf("expected deterministic theme %q, got %q", cfg.Theme, cfg2.Theme)
+ cfg = &config.Config{InputDir: badFile, OutputDir: out}
+ if err := validateDirs(cfg); err == nil {
+ t.Fatal("expected error when input is file")
}
}
func TestParseFlags_unknownFlag(t *testing.T) {
t.Parallel()
- _, _, err := parseFlags([]string{"-not-a-real-flag"}, rand.New(rand.NewSource(1)))
+ _, _, err := parseFlags([]string{"-not-a-real-flag"})
if err == nil {
t.Fatal("expected error")
}
diff --git a/cmd/snonux/resolve.go b/cmd/snonux/resolve.go
new file mode 100644
index 0000000..df16ffa
--- /dev/null
+++ b/cmd/snonux/resolve.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+ "fmt"
+ "math/rand"
+
+ "codeberg.org/snonux/snonux/internal/config"
+ "codeberg.org/snonux/snonux/internal/generator"
+)
+
+// resolvePaths expands home directories in cfg.InputDir and cfg.OutputDir.
+func resolvePaths(cfg *config.Config) error {
+ var err error
+
+ cfg.InputDir, err = expandHome(cfg.InputDir)
+ if err != nil {
+ return fmt.Errorf("input dir: %w", err)
+ }
+
+ cfg.OutputDir, err = expandHome(cfg.OutputDir)
+ if err != nil {
+ return fmt.Errorf("output dir: %w", err)
+ }
+
+ return nil
+}
+
+// validateDirs ensures cfg.InputDir and cfg.OutputDir exist as directories.
+func validateDirs(cfg *config.Config) error {
+ if err := ensureDir(cfg.InputDir); err != nil {
+ return fmt.Errorf("input dir: %w", err)
+ }
+ if err := ensureDir(cfg.OutputDir); err != nil {
+ return fmt.Errorf("output dir: %w", err)
+ }
+ return nil
+}
+
+// resolveTheme resolves the special "random" theme value by picking a registered
+// theme using rng. The rng parameter must be non-nil.
+func resolveTheme(cfg *config.Config, rng *rand.Rand) error {
+ if cfg.Theme != "random" {
+ return nil
+ }
+ if rng == nil {
+ return fmt.Errorf("theme %q requires a seeded rng", cfg.Theme)
+ }
+ themes := generator.ListThemes()
+ cfg.Theme = themes[rng.Intn(len(themes))]
+ return nil
+}