From c2f76a612fe03d4612f5e804b38d45c02466241f Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 22:30:28 -0400 Subject: [PATCH] feat: adapt validate command --- internal/cli/render_validate.go | 20 ++++ internal/cli/render_validate_test.go | 57 +++++++++++ internal/cli/validate.go | 58 ++++++++++++ internal/cli/validate_test.go | 135 +++++++++++++++++++++++++++ 4 files changed, 270 insertions(+) create mode 100644 internal/cli/render_validate.go create mode 100644 internal/cli/render_validate_test.go create mode 100644 internal/cli/validate.go create mode 100644 internal/cli/validate_test.go diff --git a/internal/cli/render_validate.go b/internal/cli/render_validate.go new file mode 100644 index 0000000..9e8aae6 --- /dev/null +++ b/internal/cli/render_validate.go @@ -0,0 +1,20 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/alyraffauf/cattery/internal/application/validate" +) + +// renderValidate writes the two deterministic platform count lines of one +// validate result (PLAN.md Section 11.2). +func renderValidate(writer io.Writer, result validate.Result) error { + for _, record := range result.Platforms { + if _, err := fmt.Fprintf(writer, "%s files=%d secrets=%d aliases=%d groups=%d\n", + record.Platform, record.Files, record.Secrets, record.Aliases, record.Groups); err != nil { + return err + } + } + return nil +} diff --git a/internal/cli/render_validate_test.go b/internal/cli/render_validate_test.go new file mode 100644 index 0000000..25b4859 --- /dev/null +++ b/internal/cli/render_validate_test.go @@ -0,0 +1,57 @@ +package cli + +import ( + "bytes" + "testing" + + "github.com/alyraffauf/cattery/internal/application/validate" +) + +func TestValidateRenderer(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"two count lines", testRenderTwoLines}, + {"deterministic order", testRenderOrder}, + {"writer failure", testRenderWriterFailure}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testRenderTwoLines(t *testing.T) { + stdout := &bytes.Buffer{} + if err := renderValidate(stdout, validate.Result{Platforms: []validate.PlatformCount{ + {Platform: "linux", Files: 2, Secrets: 1, Aliases: 0, Groups: 3}, + }}); err != nil { + t.Fatalf("render: %v", err) + } + if stdout.String() != "linux files=2 secrets=1 aliases=0 groups=3\n" { + t.Fatalf("stdout = %q, want one count line", stdout.String()) + } +} + +func testRenderOrder(t *testing.T) { + stdout := &bytes.Buffer{} + if err := renderValidate(stdout, validate.Result{Platforms: []validate.PlatformCount{ + {Platform: "linux", Files: 1}, + {Platform: "darwin", Files: 2}, + }}); err != nil { + t.Fatalf("render: %v", err) + } + got := stdout.String() + want := "linux files=1 secrets=0 aliases=0 groups=0\ndarwin files=2 secrets=0 aliases=0 groups=0\n" + if got != want { + t.Fatalf("stdout = %q, want the given sorted order", got) + } +} + +func testRenderWriterFailure(t *testing.T) { + if err := renderValidate(failingWriter{}, validate.Result{Platforms: []validate.PlatformCount{ + {Platform: "linux"}, + }}); err == nil { + t.Fatal("a writer failure must surface") + } +} diff --git a/internal/cli/validate.go b/internal/cli/validate.go new file mode 100644 index 0000000..a7006ee --- /dev/null +++ b/internal/cli/validate.go @@ -0,0 +1,58 @@ +package cli + +import ( + "context" + + "github.com/alyraffauf/cattery/internal/application/validate" + "github.com/spf13/cobra" +) + +// ValidateService is the one-method role the validate adapter calls. +type ValidateService interface { + Validate(context.Context, validate.Request) (validate.Result, error) +} + +// newValidateCommand declares the validate syntax and mechanically maps +// the raw repository fields and group arguments into one validate call +// (PLAN.md Section 11.2). No group or repository semantics appear here. +func newValidateCommand(service ValidateService, runtime Runtime, options *Options) *cobra.Command { + command := &cobra.Command{ + Use: "validate [GROUP ...]", + Short: "Validate the repository and report scope counts", + Args: cobra.ArbitraryArgs, + RunE: func(command *cobra.Command, args []string) error { + explicit := *options + explicit.RepositorySet = explicit.RepositorySet || command.Flags().Changed("repo") + request := validate.Request{ + Repository: validateRepository(explicit, runtime), + Groups: append([]string(nil), args...), + } + result, err := service.Validate(command.Context(), request) + if err != nil { + return err + } + return renderValidate(runtime.Stdout(), result) + }, + } + return command +} + +// validateRepository copies the raw repository values into the validate +// request shape. +func validateRepository(options Options, runtime Runtime) validate.RepositoryInput { + env, envSet := runtime.EnvValue("CATTERY_REPO") + return validate.RepositoryInput{ + RawExplicit: options.Repository, + ExplicitSet: options.RepositorySet, + RawEnv: env, + EnvSet: envSet, + WorkingDir: runtime.WorkingDir(), + } +} + +// bindSharedFlags declares the shared repository and verbose flags over the +// option values; the composition root moves them to persistent flags. +func bindSharedFlags(command *cobra.Command, options *Options) { + command.Flags().StringVarP(&options.Repository, "repo", "r", "", "repository path") + command.Flags().BoolVarP(&options.Verbose, "verbose", "v", false, "verbose diagnostics") +} diff --git a/internal/cli/validate_test.go b/internal/cli/validate_test.go new file mode 100644 index 0000000..62f7329 --- /dev/null +++ b/internal/cli/validate_test.go @@ -0,0 +1,135 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/alyraffauf/cattery/internal/application/validate" + "github.com/spf13/cobra" +) + +func TestValidateCommand(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"argument order preserved", testValidateOrder}, + {"repository flag mapped", testValidateRepository}, + {"one call", testValidateOneCall}, + {"service error propagates", testValidateError}, + {"writer failure", testValidateWriterError}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// validateServiceFake records requests and returns fixed results. +type validateServiceFake struct { + requests []validate.Request + result validate.Result + err error +} + +func (f *validateServiceFake) Validate(ctx context.Context, request validate.Request) (validate.Result, error) { + f.requests = append(f.requests, request) + if f.err != nil { + return validate.Result{}, f.err + } + return f.result, nil +} + +// validateFixture builds one validate command over a recording service. +func validateFixture(t *testing.T, service *validateServiceFake, options Options) (*cobra.Command, *bytes.Buffer) { + t.Helper() + stdout := &bytes.Buffer{} + runtime := NewRuntime(RuntimeInput{Streams: Streams{Stdout: stdout}, WorkingDir: "/work", Environment: []string{"CATTERY_REPO=envrepo"}}) + command := newValidateCommand(service, runtime, &options) + bindSharedFlags(command, &options) + return command, stdout +} + +// validateResult freezes the two sorted platform count records. +func validateResult() validate.Result { + return validate.Result{Platforms: []validate.PlatformCount{ + {Platform: "darwin", Files: 1, Secrets: 2, Aliases: 3, Groups: 4}, + {Platform: "linux", Files: 5, Secrets: 6, Aliases: 7, Groups: 8}, + }} +} + +func testValidateOrder(t *testing.T) { + service := &validateServiceFake{result: validateResult()} + command, _ := validateFixture(t, service, Options{}) + command.SetArgs([]string{"first", "--repo", "repo", "second"}) + if err := command.Execute(); err != nil { + t.Fatalf("run: %v", err) + } + if len(service.requests) != 1 { + t.Fatalf("calls = %d, want one", len(service.requests)) + } + request := service.requests[0] + if len(request.Groups) != 2 || request.Groups[0] != "first" || request.Groups[1] != "second" { + t.Fatalf("groups = %v, want the raw interspersed order", request.Groups) + } + if request.Repository.RawExplicit != "repo" || !request.Repository.ExplicitSet { + t.Fatalf("repository = %+v, want the explicit flag value", request.Repository) + } +} + +func testValidateRepository(t *testing.T) { + service := &validateServiceFake{result: validateResult()} + command, _ := validateFixture(t, service, Options{Repository: "flagrepo", RepositorySet: true}) + command.SetArgs([]string{"-r", "flagrepo"}) + if err := command.Execute(); err != nil { + t.Fatalf("run: %v", err) + } + repository := service.requests[0].Repository + if repository.RawExplicit != "flagrepo" || !repository.ExplicitSet { + t.Fatalf("repository = %+v, want the flag value", repository) + } + if repository.RawEnv != "envrepo" || !repository.EnvSet { + t.Fatalf("repository = %+v, want the injected environment", repository) + } + if repository.WorkingDir != "/work" { + t.Fatalf("working dir = %q, want /work", repository.WorkingDir) + } +} + +func testValidateOneCall(t *testing.T) { + service := &validateServiceFake{result: validateResult()} + command, stdout := validateFixture(t, service, Options{}) + command.SetArgs([]string{"apps"}) + if err := command.Execute(); err != nil { + t.Fatalf("run: %v", err) + } + if len(service.requests) != 1 { + t.Fatalf("calls = %d, want one", len(service.requests)) + } + want := "darwin files=1 secrets=2 aliases=3 groups=4\nlinux files=5 secrets=6 aliases=7 groups=8\n" + if stdout.String() != want { + t.Fatalf("stdout = %q, want the two count lines", stdout.String()) + } +} + +func testValidateError(t *testing.T) { + service := &validateServiceFake{err: errors.New("broken")} + command, stdout := validateFixture(t, service, Options{}) + command.SetArgs([]string{}) + if err := command.Execute(); err == nil { + t.Fatal("the service error must propagate") + } + if stdout.String() != "" { + t.Fatalf("stdout = %q, want no render after an error", stdout.String()) + } +} + +func testValidateWriterError(t *testing.T) { + service := &validateServiceFake{result: validateResult()} + runtime := NewRuntime(RuntimeInput{Streams: Streams{Stdout: failingWriter{}}, WorkingDir: "/work"}) + command := newValidateCommand(service, runtime, &Options{}) + if err := command.Execute(); err == nil { + t.Fatal("a writer failure must surface") + } +} -- 2.51.2