diff --git a/internal/cli/apply.go b/internal/cli/apply.go new file mode 100644 index 0000000..14cbcd7 --- /dev/null +++ b/internal/cli/apply.go @@ -0,0 +1,81 @@ +package cli + +import ( + "context" + + "github.com/alyraffauf/cattery/internal/application/apply" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/spf13/cobra" +) + +// ApplyService is the one-method role the apply adapter calls. +type ApplyService interface { + Apply(context.Context, apply.Request) (apply.Result, error) +} + +// newApplyCommand declares the apply syntax and mechanically maps the raw +// repository fields, group arguments, and dry-run/noninteractive/no-hooks +// policy into one apply call (PLAN.md Section 11.5). The service carries +// the prompt resolver; no decision policy or hook order appears here. +func newApplyCommand(service ApplyService, runtime Runtime, options *Options) *cobra.Command { + command := &cobra.Command{ + Use: "apply [GROUP ...]", + Short: "Deploy the repository to HOME", + Args: cobra.ArbitraryArgs, + RunE: func(command *cobra.Command, args []string) error { + request := applyRequest(command, applyInput{runtime: runtime, options: *options, groups: args}) + result, err := service.Apply(command.Context(), request) + if err != nil && !kindIs(err, failure.Difference) && len(result.Items) == 0 { + return err + } + if renderErr := renderApply(runtime.Stdout(), result); renderErr != nil { + return renderErr + } + return err + }, + } + command.Flags().Bool("dry-run", false, "show the plan without writing") + command.Flags().Bool("non-interactive", false, "refuse unresolved decisions") + command.Flags().Bool("no-hooks", false, "skip trusted hooks") + return command +} + +// applyInput bundles the runtime, options, and group arguments of one +// apply mapping. +type applyInput struct { + runtime Runtime + options Options + groups []string +} + +// applyRequest maps the raw values and policy flags into one request. +func applyRequest(command *cobra.Command, input applyInput) apply.Request { + options := input.options + options.RepositorySet = options.RepositorySet || command.Flags().Changed("repo") + dryRun, _ := command.Flags().GetBool("dry-run") + nonInteractive, _ := command.Flags().GetBool("non-interactive") + noHooks, _ := command.Flags().GetBool("no-hooks") + return apply.Request{ + Repository: applyRepository(options, input.runtime), + Groups: append([]string(nil), input.groups...), + DryRun: dryRun, + DryRunSet: command.Flags().Changed("dry-run"), + NonInteractive: nonInteractive, + NonInteractiveSet: command.Flags().Changed("non-interactive"), + NoHooks: noHooks, + NoHooksSet: command.Flags().Changed("no-hooks"), + } +} + +// applyRepository copies the raw repository values into the apply request +// shape. +func applyRepository(options Options, runtime Runtime) apply.RepositoryInput { + env, envSet := runtime.EnvValue("CATTERY_REPO") + return apply.RepositoryInput{ + RawExplicit: options.Repository, + ExplicitSet: options.RepositorySet, + RawEnv: env, + EnvSet: envSet, + WorkingDir: runtime.WorkingDir(), + } +} diff --git a/internal/cli/apply_test.go b/internal/cli/apply_test.go new file mode 100644 index 0000000..aaefec2 --- /dev/null +++ b/internal/cli/apply_test.go @@ -0,0 +1,124 @@ +package cli + +import ( + "bytes" + "context" + "testing" + + "github.com/alyraffauf/cattery/internal/application/apply" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/spf13/cobra" +) + +func TestApplyCommand(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"flags and args mapped", testApplyFlags}, + {"one call", testApplyOneCall}, + {"dry run flag", testApplyDryRun}, + {"partial error joins", testApplyPartial}, + {"writer failure", testApplyWriterError}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// applyServiceFake records requests and returns fixed results. +type applyServiceFake struct { + requests []apply.Request + result apply.Result + err error +} + +func (f *applyServiceFake) Apply(ctx context.Context, request apply.Request) (apply.Result, error) { + f.requests = append(f.requests, request) + return f.result, f.err +} + +// applyFixture builds one apply command over a recording service. +func applyFixture(t *testing.T, service *applyServiceFake, options Options) (*cobra.Command, *bytes.Buffer) { + t.Helper() + stdout := &bytes.Buffer{} + runtime := NewRuntime(RuntimeInput{Streams: Streams{Stdout: stdout}, WorkingDir: "/work"}) + command := newApplyCommand(service, runtime, &options) + bindSharedFlags(command, &options) + return command, stdout +} + +func testApplyFlags(t *testing.T) { + service := &applyServiceFake{result: applyResult()} + command, _ := applyFixture(t, service, Options{}) + command.SetArgs([]string{"-r", "repo", "--non-interactive", "--no-hooks", "apps", "tools"}) + if err := command.Execute(); err != nil { + t.Fatalf("run: %v", err) + } + request := service.requests[0] + if request.Repository.RawExplicit != "repo" || !request.Repository.ExplicitSet { + t.Fatalf("repository = %+v, want the flag value", request.Repository) + } + if !request.NonInteractive || !request.NonInteractiveSet || !request.NoHooks || !request.NoHooksSet { + t.Fatalf("policy = %+v, want the explicit flags", request) + } + if len(request.Groups) != 2 || request.Groups[0] != "apps" || request.Groups[1] != "tools" { + t.Fatalf("groups = %v, want the raw order", request.Groups) + } +} + +func testApplyOneCall(t *testing.T) { + service := &applyServiceFake{result: applyResult()} + command, stdout := applyFixture(t, service, Options{}) + 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 := "$HOME/a.conf completed write-source\nsummary planned=0 completed=1 partial=0\n" + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } +} + +func testApplyDryRun(t *testing.T) { + service := &applyServiceFake{result: applyResult()} + command, _ := applyFixture(t, service, Options{}) + command.SetArgs([]string{"--dry-run", "apps"}) + if err := command.Execute(); err != nil { + t.Fatalf("run: %v", err) + } + request := service.requests[0] + if !request.DryRun || !request.DryRunSet { + t.Fatalf("dry run = %v set = %v, want the flag value", request.DryRun, request.DryRunSet) + } +} + +func testApplyPartial(t *testing.T) { + service := &applyServiceFake{result: applyResult(), err: failure.New(failure.Operational, "apply: partial write", nil)} + command, stdout := applyFixture(t, service, Options{}) + 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 testApplyWriterError(t *testing.T) { + service := &applyServiceFake{result: applyResult()} + runtime := NewRuntime(RuntimeInput{Streams: Streams{Stdout: failingWriter{}}, WorkingDir: "/work"}) + command := newApplyCommand(service, runtime, &Options{}) + if err := command.Execute(); err == nil { + t.Fatal("a writer failure must surface") + } +} + +// applyResult freezes one completed apply result. +func applyResult() apply.Result { + return apply.Result{Items: []apply.ItemResult{ + {TargetPath: "a.conf", Status: apply.StatusCompleted, Kind: apply.ActionKindWriteSource}, + }, Summary: apply.Summary{Completed: 1}} +} diff --git a/internal/cli/render_apply.go b/internal/cli/render_apply.go new file mode 100644 index 0000000..75438e4 --- /dev/null +++ b/internal/cli/render_apply.go @@ -0,0 +1,22 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/alyraffauf/cattery/internal/application/apply" +) + +// renderApply writes one line per item record and the summary line of one +// apply result (PLAN.md Section 11.5). +func renderApply(writer io.Writer, result apply.Result) error { + for _, item := range result.Items { + if _, err := fmt.Fprintf(writer, "$HOME/%s %s %s\n", + displayPath(item.TargetPath), item.Status, item.Kind); 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_apply_test.go b/internal/cli/render_apply_test.go new file mode 100644 index 0000000..0447424 --- /dev/null +++ b/internal/cli/render_apply_test.go @@ -0,0 +1,84 @@ +package cli + +import ( + "bytes" + "testing" + + "github.com/alyraffauf/cattery/internal/application/apply" +) + +func TestApplyRenderer(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"dry run verbs", testRenderApplyDryRun}, + {"partial items", testRenderApplyPartial}, + {"summary counts", testRenderApplySummary}, + {"escaping", testRenderApplyEscaping}, + {"writer failure", testRenderApplyWriter}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testRenderApplyDryRun(t *testing.T) { + stdout := &bytes.Buffer{} + result := apply.Result{Items: []apply.ItemResult{ + {TargetPath: "a.conf", Status: apply.StatusPlanned, Kind: apply.ActionKindWriteSource}, + }, Summary: apply.Summary{Planned: 1}} + if err := renderApply(stdout, result); err != nil { + t.Fatalf("render: %v", err) + } + want := "$HOME/a.conf planned write-source\nsummary planned=1 completed=0 partial=0\n" + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } +} + +func testRenderApplyPartial(t *testing.T) { + stdout := &bytes.Buffer{} + result := apply.Result{Items: []apply.ItemResult{ + {TargetPath: "a.conf", Status: apply.StatusCompleted, Kind: apply.ActionKindWriteSource}, + {TargetPath: "b.conf", Status: apply.StatusPartial, Kind: apply.ActionKindRealizeAlias}, + }, Summary: apply.Summary{Completed: 1, Partial: 1}} + if err := renderApply(stdout, result); err != nil { + t.Fatalf("render: %v", err) + } + want := "$HOME/a.conf completed write-source\n$HOME/b.conf partial realize-alias\n" + + "summary planned=0 completed=1 partial=1\n" + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } +} + +func testRenderApplySummary(t *testing.T) { + stdout := &bytes.Buffer{} + result := apply.Result{Summary: apply.Summary{Planned: 3, Completed: 2, Partial: 1}} + if err := renderApply(stdout, result); err != nil { + t.Fatalf("render: %v", err) + } + if stdout.String() != "summary planned=3 completed=2 partial=1\n" { + t.Fatalf("stdout = %q, want the summary line", stdout.String()) + } +} + +func testRenderApplyEscaping(t *testing.T) { + stdout := &bytes.Buffer{} + result := apply.Result{Items: []apply.ItemResult{ + {TargetPath: "dir/bad\nname", Status: apply.StatusCompleted, Kind: apply.ActionKindWriteSource}, + }} + if err := renderApply(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 testRenderApplyWriter(t *testing.T) { + if err := renderApply(failingWriter{}, apply.Result{}); err == nil { + t.Fatal("a writer failure must surface") + } +}