From c4afd3982dde19f54e0b09fa28a8517fa0aeff25 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 22:37:25 -0400 Subject: [PATCH] feat: adapt status command --- internal/cli/render_status.go | 44 ++++++++++ internal/cli/render_status_test.go | 107 ++++++++++++++++++++++++ internal/cli/status.go | 61 ++++++++++++++ internal/cli/status_test.go | 125 +++++++++++++++++++++++++++++ 4 files changed, 337 insertions(+) create mode 100644 internal/cli/render_status.go create mode 100644 internal/cli/render_status_test.go create mode 100644 internal/cli/status.go create mode 100644 internal/cli/status_test.go diff --git a/internal/cli/render_status.go b/internal/cli/render_status.go new file mode 100644 index 0000000..6d0541c --- /dev/null +++ b/internal/cli/render_status.go @@ -0,0 +1,44 @@ +package cli + +import ( + "fmt" + "io" + "strconv" + + "github.com/alyraffauf/cattery/internal/application/inspect" +) + +// renderStatus writes one line per pending record and the summary line of +// one status result (PLAN.md Sections 11.3 and 11.9). +func renderStatus(writer io.Writer, result inspect.StatusResult) error { + for _, record := range result.Records() { + if _, err := fmt.Fprintf(writer, "$HOME/%s %s %s\n", + displayPath(record.TargetPath()), record.Kind(), record.Action()); 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 +} + +// displayPath escapes control characters and ambiguous whitespace with the +// stable Go-style quoted representation so a filename can never inject +// terminal lines. +func displayPath(path string) string { + if needsQuoting(path) { + return strconv.Quote(path) + } + return path +} + +// needsQuoting reports whether a path carries control characters or +// ambiguous whitespace. +func needsQuoting(path string) bool { + for _, character := range path { + if character < 0x20 || character == 0x7f || character == ' ' { + return true + } + } + return false +} diff --git a/internal/cli/render_status_test.go b/internal/cli/render_status_test.go new file mode 100644 index 0000000..6c12e0f --- /dev/null +++ b/internal/cli/render_status_test.go @@ -0,0 +1,107 @@ +package cli + +import ( + "bytes" + "testing" + + "github.com/alyraffauf/cattery/internal/application/inspect" +) + +// frozenResult freezes one status result over the given records. +func frozenResult(records []inspect.StatusRecord, converged bool) inspect.StatusResult { + return inspect.NewStatusResult(records, converged) +} + +func TestStatusRenderer(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"pending records", testRenderStatusRecords}, + {"retired records", testRenderStatusRetired}, + {"summary line", testRenderStatusSummary}, + {"escaping", testRenderStatusEscaping}, + {"writer failure", testRenderStatusWriter}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testRenderStatusRecords(t *testing.T) { + stdout := &bytes.Buffer{} + result := frozenResult([]inspect.StatusRecord{ + statusRecord("a.conf", inspect.StatusKindFile, "write-source"), + statusRecord("bin/tool", inspect.StatusKindAlias, "realize-alias"), + }, false) + if err := renderStatus(stdout, result); err != nil { + t.Fatalf("render: %v", err) + } + want := "$HOME/a.conf file write-source\n$HOME/bin/tool alias realize-alias\n" + + "summary files=1 aliases=1 retired=0 converged=false\n" + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } +} + +func testRenderStatusRetired(t *testing.T) { + stdout := &bytes.Buffer{} + result := frozenResult([]inspect.StatusRecord{ + statusRecord("gone.conf", inspect.StatusKindRetired, "retire-file"), + }, true) + if err := renderStatus(stdout, result); err != nil { + t.Fatalf("render: %v", err) + } + want := "$HOME/gone.conf retired retire-file\nsummary files=0 aliases=0 retired=1 converged=true\n" + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } +} + +func testRenderStatusSummary(t *testing.T) { + stdout := &bytes.Buffer{} + records := []inspect.StatusRecord{ + statusRecord("a", inspect.StatusKindFile, "write-source"), + statusRecord("b", inspect.StatusKindFile, "write-source"), + statusRecord("c", inspect.StatusKindAlias, "realize-alias"), + statusRecord("d", inspect.StatusKindAlias, "realize-alias"), + statusRecord("e", inspect.StatusKindAlias, "realize-alias"), + statusRecord("f", inspect.StatusKindRetired, "retire-file"), + statusRecord("g", inspect.StatusKindRetired, "retire-file"), + statusRecord("h", inspect.StatusKindRetired, "retire-file"), + statusRecord("i", inspect.StatusKindRetired, "retire-file"), + } + result := frozenResult(records, true) + if err := renderStatus(stdout, result); err != nil { + t.Fatalf("render: %v", err) + } + want := "$HOME/a file write-source\n$HOME/b file write-source\n" + + "$HOME/c alias realize-alias\n$HOME/d alias realize-alias\n$HOME/e alias realize-alias\n" + + "$HOME/f retired retire-file\n$HOME/g retired retire-file\n$HOME/h retired retire-file\n" + + "$HOME/i retired retire-file\nsummary files=2 aliases=3 retired=4 converged=true\n" + if stdout.String() != want { + t.Fatalf("stdout = %q, want the records and summary", stdout.String()) + } +} + +func testRenderStatusEscaping(t *testing.T) { + stdout := &bytes.Buffer{} + result := frozenResult([]inspect.StatusRecord{ + statusRecord("dir/weird\nname", inspect.StatusKindFile, "write-source"), + }, false) + if err := renderStatus(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 testRenderStatusWriter(t *testing.T) { + result := frozenResult([]inspect.StatusRecord{ + statusRecord("a.conf", inspect.StatusKindFile, "write-source"), + }, false) + if err := renderStatus(failingWriter{}, result); err == nil { + t.Fatal("a writer failure must surface") + } +} diff --git a/internal/cli/status.go b/internal/cli/status.go new file mode 100644 index 0000000..ab97475 --- /dev/null +++ b/internal/cli/status.go @@ -0,0 +1,61 @@ +package cli + +import ( + "context" + + "github.com/alyraffauf/cattery/internal/application/inspect" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/spf13/cobra" +) + +// StatusService is the one-method role the status adapter calls. +type StatusService interface { + Status(context.Context, inspect.Request) (inspect.StatusResult, error) +} + +// newStatusCommand declares the status syntax and mechanically maps the +// raw repository fields and group arguments into one status call (PLAN.md +// Section 11.3). No classification or state import appears here. +func newStatusCommand(service StatusService, runtime Runtime, options *Options) *cobra.Command { + command := &cobra.Command{ + Use: "status [GROUP ...]", + Short: "Compare the repository against the deployed state", + 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.Status(command.Context(), request) + if err != nil && !kindIs(err, failure.Difference) { + return err + } + if renderErr := renderStatus(runtime.Stdout(), result); renderErr != nil { + return renderErr + } + return err + }, + } + return command +} + +// kindIs reports whether err carries the given failure kind. +func kindIs(err error, want failure.Kind) bool { + kind, ok := failure.HasKind(err) + return ok && kind == want +} + +// inspectRepository copies the raw repository values into the inspection +// request shape. +func inspectRepository(options Options, runtime Runtime) inspect.RepositoryInput { + env, envSet := runtime.EnvValue("CATTERY_REPO") + return inspect.RepositoryInput{ + RawExplicit: options.Repository, + ExplicitSet: options.RepositorySet, + RawEnv: env, + EnvSet: envSet, + WorkingDir: runtime.WorkingDir(), + } +} diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go new file mode 100644 index 0000000..e01b00a --- /dev/null +++ b/internal/cli/status_test.go @@ -0,0 +1,125 @@ +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 TestStatusCommand(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"flags and args mapped", testStatusFlags}, + {"one call", testStatusOneCall}, + {"output before difference", testStatusDifference}, + {"service error", testStatusError}, + {"writer failure", testStatusWriterError}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// statusServiceFake records requests and returns fixed results. +type statusServiceFake struct { + requests []inspect.Request + result inspect.StatusResult + err error +} + +func (f *statusServiceFake) Status(ctx context.Context, request inspect.Request) (inspect.StatusResult, error) { + f.requests = append(f.requests, request) + return f.result, f.err +} + +// statusRecord freezes one pending status record. +func statusRecord(target string, kind inspect.StatusKind, action string) inspect.StatusRecord { + return inspect.NewStatusRecord(target, kind, action) +} + +// statusResult freezes one unconverged status result. +func statusResult() inspect.StatusResult { + return inspect.NewStatusResult([]inspect.StatusRecord{ + statusRecord("a.conf", inspect.StatusKindFile, "write-source"), + }, false) +} + +// statusFixture builds one status command over a recording service. +func statusFixture(t *testing.T, service *statusServiceFake, 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 := newStatusCommand(service, runtime, &options) + bindSharedFlags(command, &options) + return command, stdout +} + +func testStatusFlags(t *testing.T) { + service := &statusServiceFake{result: statusResult()} + command, _ := statusFixture(t, service, Options{}) + command.SetArgs([]string{"-r", "repo", "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 len(request.Groups) != 2 || request.Groups[0] != "apps" || request.Groups[1] != "tools" { + t.Fatalf("groups = %v, want the raw order", request.Groups) + } +} + +func testStatusOneCall(t *testing.T) { + service := &statusServiceFake{result: statusResult()} + command, stdout := statusFixture(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 file write-source\nsummary files=1 aliases=0 retired=0 converged=false\n" + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } +} + +func testStatusDifference(t *testing.T) { + service := &statusServiceFake{result: statusResult(), err: failure.New(failure.Difference, "status: selected state is not converged", nil)} + command, stdout := statusFixture(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 testStatusError(t *testing.T) { + service := &statusServiceFake{err: errors.New("broken")} + command, stdout := statusFixture(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 testStatusWriterError(t *testing.T) { + service := &statusServiceFake{result: statusResult()} + runtime := NewRuntime(RuntimeInput{Streams: Streams{Stdout: failingWriter{}}, WorkingDir: "/work"}) + command := newStatusCommand(service, runtime, &Options{}) + if err := command.Execute(); err == nil { + t.Fatal("a writer failure must surface") + } +} -- 2.51.2