diff --git a/internal/cli/diff.go b/internal/cli/diff.go new file mode 100644 index 0000000..4e27f4a --- /dev/null +++ b/internal/cli/diff.go @@ -0,0 +1,42 @@ +package cli + +import ( + "context" + + "github.com/alyraffauf/cattery/internal/application/inspect" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/spf13/cobra" +) + +// DiffService is the one-method role the diff adapter calls. +type DiffService interface { + Diff(context.Context, inspect.Request) (inspect.DiffResult, error) +} + +// newDiffCommand declares the diff syntax and mechanically maps the raw +// repository fields and group arguments into one diff call (PLAN.md +// Section 11.4). No diff calculation or formatter import appears here. +func newDiffCommand(service DiffService, runtime Runtime, options *Options) *cobra.Command { + command := &cobra.Command{ + Use: "diff [GROUP ...]", + Short: "Show secret-safe differences", + Args: cobra.ArbitraryArgs, + RunE: func(command *cobra.Command, args []string) error { + explicit := *options + explicit.RepositorySet = explicit.RepositorySet || command.Flags().Changed("repo") + request := inspect.Request{ + Repository: inspectRepository(explicit, runtime), + Groups: append([]string(nil), args...), + } + result, err := service.Diff(command.Context(), request) + if err != nil && !kindIs(err, failure.Difference) { + return err + } + if renderErr := renderDiff(runtime.Stdout(), result); renderErr != nil { + return renderErr + } + return err + }, + } + return command +} diff --git a/internal/cli/diff_test.go b/internal/cli/diff_test.go new file mode 100644 index 0000000..a4fea80 --- /dev/null +++ b/internal/cli/diff_test.go @@ -0,0 +1,109 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/alyraffauf/cattery/internal/application/inspect" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/spf13/cobra" +) + +func TestDiffCommand(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"flags and args mapped", testDiffFlags}, + {"one call", testDiffOneCall}, + {"output before difference", testDiffDifference}, + {"service error", testDiffError}, + {"writer failure", testDiffWriterError}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// diffServiceFake records requests and returns fixed results. +type diffServiceFake struct { + requests []inspect.Request + result inspect.DiffResult + err error +} + +func (f *diffServiceFake) Diff(ctx context.Context, request inspect.Request) (inspect.DiffResult, error) { + f.requests = append(f.requests, request) + return f.result, f.err +} + +// diffFixture builds one diff command over a recording service. +func diffFixture(t *testing.T, service *diffServiceFake, options Options) (*cobra.Command, *bytes.Buffer) { + t.Helper() + stdout := &bytes.Buffer{} + runtime := NewRuntime(RuntimeInput{Streams: Streams{Stdout: stdout}, WorkingDir: "/work"}) + command := newDiffCommand(service, runtime, &options) + bindSharedFlags(command, &options) + return command, stdout +} + +func testDiffFlags(t *testing.T) { + service := &diffServiceFake{result: diffResult()} + command, _ := diffFixture(t, service, Options{}) + command.SetArgs([]string{"-r", "repo", "apps"}) + 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 len(request.Groups) != 1 || request.Groups[0] != "apps" { + t.Fatalf("groups = %v, want the raw argument", request.Groups) + } +} + +func testDiffOneCall(t *testing.T) { + service := &diffServiceFake{result: diffResult()} + command, _ := diffFixture(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)) + } +} + +func testDiffDifference(t *testing.T) { + service := &diffServiceFake{result: diffResult(diffRecord(diffSpec{target: "a.conf", kind: inspect.StatusKindFile, tag: "none", action: "write-source"})), err: failure.New(failure.Difference, "diff: selected state is not converged", nil)} + command, stdout := diffFixture(t, service, Options{}) + err := command.Execute() + if err == nil || !kindIs(err, failure.Difference) { + t.Fatalf("error = %v, want a difference failure", err) + } + if !bytes.Contains(stdout.Bytes(), []byte("$HOME/a.conf")) { + t.Fatalf("stdout = %q, want the records rendered before the difference", stdout.String()) + } +} + +func testDiffError(t *testing.T) { + service := &diffServiceFake{err: errors.New("broken")} + command, stdout := diffFixture(t, service, Options{}) + 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 testDiffWriterError(t *testing.T) { + service := &diffServiceFake{result: diffResult()} + runtime := NewRuntime(RuntimeInput{Streams: Streams{Stdout: failingWriter{}}, WorkingDir: "/work"}) + command := newDiffCommand(service, runtime, &Options{}) + if err := command.Execute(); err == nil { + t.Fatal("a writer failure must surface") + } +} diff --git a/internal/cli/render_diff.go b/internal/cli/render_diff.go new file mode 100644 index 0000000..a264c14 --- /dev/null +++ b/internal/cli/render_diff.go @@ -0,0 +1,53 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/alyraffauf/cattery/internal/application/inspect" +) + +// renderDiff writes one line per tagged safe record plus the summary line +// of one diff result (PLAN.md Section 11.4). Secret records render the +// marker only, with zero content, size, or hash fields. +func renderDiff(writer io.Writer, result inspect.DiffResult) error { + for _, record := range result.Records() { + if err := renderDiffRecord(writer, record); err != nil { + return err + } + } + _, err := fmt.Fprintf(writer, "summary files=%d aliases=%d retired=%d converged=%t\n", + result.Files(), result.Aliases(), result.Retired(), result.Converged()) + return err +} + +// renderDiffRecord writes one safe record line and its payload. +func renderDiffRecord(writer io.Writer, record inspect.DiffRecord) error { + path := "$HOME/" + displayPath(record.TargetPath()) + switch inspect.DiffTagName(record) { + case "text": + return renderTextDiff(writer, path, record) + case "binary": + _, err := fmt.Fprintf(writer, "%s %s binary size=%d/%d\n", + path, record.Kind(), record.SourceSize(), record.TargetSize()) + return err + case "secret": + _, err := fmt.Fprintf(writer, "%s %s secret\n", path, record.Kind()) + return err + } + _, err := fmt.Fprintf(writer, "%s %s %s\n", path, record.Kind(), record.Action()) + return err +} + +// renderTextDiff writes the record line, the label line, and the diff +// lines of one printable text difference. +func renderTextDiff(writer io.Writer, path string, record inspect.DiffRecord) error { + if _, err := fmt.Fprintf(writer, "%s %s %s\n", path, record.Kind(), record.Action()); err != nil { + return err + } + if _, err := fmt.Fprintf(writer, "%s\n", record.SourceLabel()); err != nil { + return err + } + _, err := fmt.Fprintf(writer, "%s", record.Lines()) + return err +} diff --git a/internal/cli/render_diff_test.go b/internal/cli/render_diff_test.go new file mode 100644 index 0000000..1d37469 --- /dev/null +++ b/internal/cli/render_diff_test.go @@ -0,0 +1,143 @@ +package cli + +import ( + "bytes" + "testing" + + "github.com/alyraffauf/cattery/internal/application/inspect" +) + +func TestDiffRenderer(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"every record tag", testRenderDiffTags}, + {"text payload", testRenderDiffText}, + {"binary payload", testRenderDiffBinary}, + {"secret marker only", testRenderDiffSecret}, + {"aliases and retirement", testRenderDiffAliases}, + {"escaping", testRenderDiffEscaping}, + {"writer failure", testRenderDiffWriter}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// diffResult freezes one unconverged diff result over the given records. +func diffResult(records ...inspect.DiffRecord) inspect.DiffResult { + return inspect.NewDiffResult(records, false) +} + +// diffSpec names one frozen diff record. +type diffSpec struct { + target string + kind inspect.StatusKind + tag string + action string +} + +// diffRecord freezes one diff record over the spec. +func diffRecord(spec diffSpec) inspect.DiffRecord { + return inspect.NewDiffRecord(inspect.DiffRecordInput{ + TargetPath: spec.target, Kind: spec.kind, Tag: spec.tag, Action: spec.action, + }) +} + +func testRenderDiffTags(t *testing.T) { + stdout := &bytes.Buffer{} + result := diffResult( + diffRecord(diffSpec{target: "a.conf", kind: inspect.StatusKindFile, tag: "none", action: "write-source"}), + diffRecord(diffSpec{target: "b.conf", kind: inspect.StatusKindFile, tag: "binary", action: "write-source"}), + diffRecord(diffSpec{target: "c.conf", kind: inspect.StatusKindFile, tag: "secret", action: "write-source"}), + ) + if err := renderDiff(stdout, result); err != nil { + t.Fatalf("render: %v", err) + } + want := "$HOME/a.conf file write-source\n" + + "$HOME/b.conf file binary size=0/0\n" + + "$HOME/c.conf file secret\n" + + "summary files=3 aliases=0 retired=0 converged=false\n" + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } +} + +func testRenderDiffText(t *testing.T) { + stdout := &bytes.Buffer{} + record := inspect.NewDiffRecord(inspect.DiffRecordInput{ + TargetPath: "a.conf", Kind: inspect.StatusKindFile, Tag: "text", Action: "write-source", + SourceLabel: "repo/a.conf", TargetLabel: "$HOME/a.conf", Lines: "-old\n+new\n", + }) + if err := renderDiff(stdout, diffResult(record)); err != nil { + t.Fatalf("render: %v", err) + } + want := "$HOME/a.conf file write-source\nrepo/a.conf\n-old\n+new\n" + + "summary files=1 aliases=0 retired=0 converged=false\n" + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } +} + +func testRenderDiffBinary(t *testing.T) { + stdout := &bytes.Buffer{} + record := inspect.NewDiffRecord(inspect.DiffRecordInput{ + TargetPath: "b.bin", Kind: inspect.StatusKindFile, Tag: "binary", Action: "write-source", + SourceSize: 3, TargetSize: 5, + }) + if err := renderDiff(stdout, diffResult(record)); err != nil { + t.Fatalf("render: %v", err) + } + if !bytes.Contains(stdout.Bytes(), []byte("binary size=3/5")) { + t.Fatalf("stdout = %q, want the binary sizes", stdout.String()) + } +} + +func testRenderDiffSecret(t *testing.T) { + stdout := &bytes.Buffer{} + record := inspect.NewDiffRecord(inspect.DiffRecordInput{ + TargetPath: "token", Kind: inspect.StatusKindFile, Tag: "secret", Action: "write-source", + SourceLabel: "secret-leak", Lines: "secret-leak", SourceSize: 99, TargetSize: 99, + }) + if err := renderDiff(stdout, diffResult(record)); err != nil { + t.Fatalf("render: %v", err) + } + if bytes.Contains(stdout.Bytes(), []byte("secret-leak")) { + t.Fatalf("stdout = %q, a secret record must render zero payload", stdout.String()) + } +} + +func testRenderDiffAliases(t *testing.T) { + stdout := &bytes.Buffer{} + result := diffResult( + diffRecord(diffSpec{target: "bin/tool", kind: inspect.StatusKindAlias, tag: "none", action: "realize-alias"}), + diffRecord(diffSpec{target: "gone.conf", kind: inspect.StatusKindRetired, tag: "none", action: "retire-file"}), + ) + if err := renderDiff(stdout, result); err != nil { + t.Fatalf("render: %v", err) + } + want := "$HOME/bin/tool alias realize-alias\n" + + "$HOME/gone.conf retired retire-file\n" + + "summary files=0 aliases=1 retired=1 converged=false\n" + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } +} + +func testRenderDiffEscaping(t *testing.T) { + stdout := &bytes.Buffer{} + record := diffRecord(diffSpec{target: "dir/bad\nname", kind: inspect.StatusKindFile, tag: "none", action: "write-source"}) + if err := renderDiff(stdout, diffResult(record)); 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 testRenderDiffWriter(t *testing.T) { + if err := renderDiff(failingWriter{}, diffResult()); err == nil { + t.Fatal("a writer failure must surface") + } +}