diff --git a/internal/cli/add.go b/internal/cli/add.go new file mode 100644 index 0000000..caa8d99 --- /dev/null +++ b/internal/cli/add.go @@ -0,0 +1,84 @@ +package cli + +import ( + "context" + + "github.com/alyraffauf/cattery/internal/application/add" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/spf13/cobra" +) + +// AddService is the one-method role the add adapter calls. +type AddService interface { + Add(context.Context, add.Request) (add.Result, error) +} + +// newAddCommand declares the add syntax and mechanically maps the raw +// targets, repository fields, and exact group/platform/secret presence +// bits into one add call (PLAN.md Section 11.6). No ownership inference or +// filesystem access appears here. +func newAddCommand(service AddService, runtime Runtime, options *Options) *cobra.Command { + command := &cobra.Command{ + Use: "add TARGET...", + Short: "Adopt target files into the repository", + Args: cobra.MinimumNArgs(1), + RunE: func(command *cobra.Command, args []string) error { + request := addRequest(command, addRequestInput{runtime: runtime, options: *options, targets: args}) + result, err := service.Add(command.Context(), request) + if err != nil && !kindIs(err, failure.Difference) && len(result.Items) == 0 { + return err + } + if renderErr := renderAdd(runtime.Stdout(), result); renderErr != nil { + return renderErr + } + return err + }, + } + command.Flags().String("group", "", "repository group") + command.Flags().String("platform", "", "platform layer") + command.Flags().Bool("secret", false, "adopt as a SOPS secret") + command.Flags().Bool("dry-run", false, "show the plan without writing") + return command +} + +// addRequestInput bundles the runtime, options, and raw targets of one +// add mapping. +type addRequestInput struct { + runtime Runtime + options Options + targets []string +} + +// addRequest maps the raw values and exact presence bits into one request. +func addRequest(command *cobra.Command, input addRequestInput) add.Request { + options := input.options + options.RepositorySet = options.RepositorySet || command.Flags().Changed("repo") + group, _ := command.Flags().GetString("group") + platform, _ := command.Flags().GetString("platform") + secretValue, _ := command.Flags().GetBool("secret") + dryRunValue, _ := command.Flags().GetBool("dry-run") + return add.Request{ + Repository: addRepository(options, input.runtime), + Targets: append([]string(nil), input.targets...), + Group: group, + GroupSet: command.Flags().Changed("group"), + Platform: platform, + PlatformSet: command.Flags().Changed("platform"), + Secret: secretValue, + SecretSet: command.Flags().Changed("secret"), + DryRun: dryRunValue, + } +} + +// addRepository copies the raw repository values into the add request +// shape. +func addRepository(options Options, runtime Runtime) add.RepositoryInput { + env, envSet := runtime.EnvValue("CATTERY_REPO") + return add.RepositoryInput{ + RawExplicit: options.Repository, + ExplicitSet: options.RepositorySet, + RawEnv: env, + EnvSet: envSet, + WorkingDir: runtime.WorkingDir(), + } +} diff --git a/internal/cli/add_test.go b/internal/cli/add_test.go new file mode 100644 index 0000000..230e0ec --- /dev/null +++ b/internal/cli/add_test.go @@ -0,0 +1,155 @@ +package cli + +import ( + "bytes" + "context" + "testing" + + "github.com/alyraffauf/cattery/internal/application/add" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/spf13/cobra" +) + +func TestAddCommand(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"target order preserved", testAddOrder}, + {"interspersed flags", testAddInterspersed}, + {"explicit false", testAddExplicitFalse}, + {"repeated arguments", testAddRepeated}, + {"dry run flag", testAddDryRun}, + {"partial error", testAddPartial}, + {"writer failure", testAddWriterError}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// addServiceFake records requests and returns fixed results. +type addServiceFake struct { + requests []add.Request + result add.Result + err error +} + +func (f *addServiceFake) Add(ctx context.Context, request add.Request) (add.Result, error) { + f.requests = append(f.requests, request) + return f.result, f.err +} + +// addFixture builds one add command over a recording service. +func addFixture(t *testing.T, service *addServiceFake, 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 := newAddCommand(service, runtime, &options) + bindSharedFlags(command, &options) + return command, stdout +} + +func testAddOrder(t *testing.T) { + service := &addServiceFake{result: addResult()} + command, _ := addFixture(t, service, Options{}) + command.SetArgs([]string{"b.conf", "a.conf"}) + if err := command.Execute(); err != nil { + t.Fatalf("run: %v", err) + } + request := service.requests[0] + if len(request.Targets) != 2 || request.Targets[0] != "b.conf" || request.Targets[1] != "a.conf" { + t.Fatalf("targets = %v, want the raw order preserved", request.Targets) + } + if request.Repository.WorkingDir != "/work" { + t.Fatalf("working dir = %q, want /work", request.Repository.WorkingDir) + } +} + +func testAddInterspersed(t *testing.T) { + service := &addServiceFake{result: addResult()} + command, _ := addFixture(t, service, Options{}) + command.SetArgs([]string{"--group", "apps", "first", "--secret", "second", "third"}) + if err := command.Execute(); err != nil { + t.Fatalf("run: %v", err) + } + request := service.requests[0] + if request.Group != "apps" || !request.GroupSet { + t.Fatalf("group = %q set = %v, want the flag value", request.Group, request.GroupSet) + } + if !request.SecretSet || !request.Secret { + t.Fatalf("secret = %v set = %v, want the flag value", request.Secret, request.SecretSet) + } + if len(request.Targets) != 3 { + t.Fatalf("targets = %v, want all three arguments", request.Targets) + } +} + +func testAddExplicitFalse(t *testing.T) { + service := &addServiceFake{result: addResult()} + command, _ := addFixture(t, service, Options{}) + command.SetArgs([]string{"--secret=false", "--dry-run=false", "a.conf"}) + if err := command.Execute(); err != nil { + t.Fatalf("run: %v", err) + } + request := service.requests[0] + if !request.SecretSet || request.Secret { + t.Fatalf("secret = %v set = %v, want an explicit false", request.Secret, request.SecretSet) + } + if request.DryRun { + t.Fatalf("dry run = %v, want an explicit false", request.DryRun) + } +} + +func testAddRepeated(t *testing.T) { + service := &addServiceFake{result: addResult()} + command, _ := addFixture(t, service, Options{}) + command.SetArgs([]string{"a.conf", "a.conf"}) + if err := command.Execute(); err != nil { + t.Fatalf("run: %v", err) + } + if len(service.requests[0].Targets) != 2 { + t.Fatalf("repeated arguments must be preserved for the service") + } +} + +func testAddDryRun(t *testing.T) { + service := &addServiceFake{result: addResult()} + command, _ := addFixture(t, service, Options{}) + command.SetArgs([]string{"--dry-run", "a.conf"}) + if err := command.Execute(); err != nil { + t.Fatalf("run: %v", err) + } + if !service.requests[0].DryRun { + t.Fatalf("dry run = %v, want the flag value", service.requests[0].DryRun) + } +} + +func testAddPartial(t *testing.T) { + service := &addServiceFake{result: addResult(), err: failure.New(failure.Operational, "add: partial batch", nil)} + command, stdout := addFixture(t, service, Options{}) + command.SetArgs([]string{"a.conf"}) + err := command.Execute() + if err == nil || !kindIs(err, failure.Operational) { + t.Fatalf("error = %v, want an operational failure", err) + } + if !bytes.Contains(stdout.Bytes(), []byte("$HOME/a.conf")) { + t.Fatalf("stdout = %q, want the partial records rendered", stdout.String()) + } +} + +func testAddWriterError(t *testing.T) { + service := &addServiceFake{result: addResult()} + runtime := NewRuntime(RuntimeInput{Streams: Streams{Stdout: failingWriter{}}, WorkingDir: "/work"}) + command := newAddCommand(service, runtime, &Options{}) + if err := command.Execute(); err == nil { + t.Fatal("a writer failure must surface") + } +} + +// addResult freezes one completed add result. +func addResult() add.Result { + return add.Result{Items: []add.ItemResult{ + {Target: "a.conf", Source: "a.conf", Status: add.StatusCompleted}, + }, Summary: add.Summary{Completed: 1}} +} diff --git a/internal/cli/render_add.go b/internal/cli/render_add.go new file mode 100644 index 0000000..65992a0 --- /dev/null +++ b/internal/cli/render_add.go @@ -0,0 +1,22 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/alyraffauf/cattery/internal/application/add" +) + +// renderAdd writes one line per item record and the summary line of one +// add result (PLAN.md Section 11.6). +func renderAdd(writer io.Writer, result add.Result) error { + for _, item := range result.Items { + if _, err := fmt.Fprintf(writer, "$HOME/%s %s %s\n", + displayPath(item.Target), item.Status, displayPath(item.Source)); err != nil { + return err + } + } + _, err := fmt.Fprintf(writer, "summary planned=%d completed=%d partial=%d\n", + result.Summary.Planned, result.Summary.Completed, result.Summary.Partial) + return err +} diff --git a/internal/cli/render_add_test.go b/internal/cli/render_add_test.go new file mode 100644 index 0000000..7454006 --- /dev/null +++ b/internal/cli/render_add_test.go @@ -0,0 +1,87 @@ +package cli + +import ( + "bytes" + "testing" + + "github.com/alyraffauf/cattery/internal/application/add" +) + +func TestAddRenderer(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"item lines", testRenderAddItems}, + {"summary counts", testRenderAddSummary}, + {"dry run verbs", testRenderAddDryRun}, + {"escaping", testRenderAddEscaping}, + {"writer failure", testRenderAddWriter}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testRenderAddItems(t *testing.T) { + stdout := &bytes.Buffer{} + result := add.Result{Items: []add.ItemResult{ + {Target: "a.conf", Source: "a.conf", Status: add.StatusCompleted}, + {Target: "token", Source: "apps/token", Status: add.StatusCompleted, Secret: true}, + }, Summary: add.Summary{Completed: 2}} + if err := renderAdd(stdout, result); err != nil { + t.Fatalf("render: %v", err) + } + want := "$HOME/a.conf completed a.conf\n$HOME/token completed apps/token\n" + + "summary planned=0 completed=2 partial=0\n" + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } +} + +func testRenderAddSummary(t *testing.T) { + stdout := &bytes.Buffer{} + result := add.Result{Items: []add.ItemResult{ + {Target: "a", Status: add.StatusPlanned}, + {Target: "b", Status: add.StatusCompleted}, + {Target: "c", Status: add.StatusPartial}, + }, Summary: add.Summary{Planned: 1, Completed: 1, Partial: 1}} + if err := renderAdd(stdout, result); err != nil { + t.Fatalf("render: %v", err) + } + if !bytes.Contains(stdout.Bytes(), []byte("summary planned=1 completed=1 partial=1")) { + t.Fatalf("stdout = %q, want the tallied summary", stdout.String()) + } +} + +func testRenderAddDryRun(t *testing.T) { + stdout := &bytes.Buffer{} + result := add.Result{Items: []add.ItemResult{ + {Target: "a.conf", Source: "a.conf", Status: add.StatusPlanned}, + }, Summary: add.Summary{Planned: 1}} + if err := renderAdd(stdout, result); err != nil { + t.Fatalf("render: %v", err) + } + if !bytes.Contains(stdout.Bytes(), []byte("$HOME/a.conf planned a.conf")) { + t.Fatalf("stdout = %q, want the planned verb", stdout.String()) + } +} + +func testRenderAddEscaping(t *testing.T) { + stdout := &bytes.Buffer{} + result := add.Result{Items: []add.ItemResult{ + {Target: "dir/bad\nname", Source: "dir/bad\nname", Status: add.StatusCompleted}, + }} + if err := renderAdd(stdout, result); err != nil { + t.Fatalf("render: %v", err) + } + if bytes.Contains(stdout.Bytes(), []byte("\nname")) { + t.Fatalf("stdout = %q, a control character must not inject a line", stdout.String()) + } +} + +func testRenderAddWriter(t *testing.T) { + if err := renderAdd(failingWriter{}, add.Result{}); err == nil { + t.Fatal("a writer failure must surface") + } +}