diff --git a/internal/application/version/service.go b/internal/application/version/service.go deleted file mode 100644 index 826cefa..0000000 --- a/internal/application/version/service.go +++ /dev/null @@ -1,18 +0,0 @@ -package version - -import "github.com/alyraffauf/cattery/internal/buildinfo" - -// Service returns the build and runtime identity of the running binary. -// Construction is side-effect-free and needs no dependencies: the invocation -// reads only the linker-populated buildinfo values and runtime constants. -type Service struct{} - -// NewService constructs the version service. -func NewService() *Service { - return &Service{} -} - -// Version returns the current build and runtime identity as typed fields. -func (service *Service) Version() Result { - return FromSnapshot(buildinfo.Current()) -} diff --git a/internal/application/version/service_test.go b/internal/application/version/service_test.go deleted file mode 100644 index 2cfb939..0000000 --- a/internal/application/version/service_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package version - -import ( - "runtime" - "testing" - - "github.com/alyraffauf/cattery/internal/buildinfo" -) - -func TestVersionService(t *testing.T) { - scenarios := []struct { - name string - run func(*testing.T) - }{ - {"construction and invocation need no dependencies", testServiceNoDependencies}, - {"returns the development defaults", testServiceDevelopmentDefaults}, - {"returns the current buildinfo snapshot", testServiceMatchesSnapshot}, - {"returns all runtime fields", testServiceRuntimeFields}, - } - for _, scenario := range scenarios { - t.Run(scenario.name, scenario.run) - } -} - -func testServiceNoDependencies(t *testing.T) { - service := NewService() - result := service.Version() - if result.GoVersion == "" { - t.Fatal("invocation returned an empty result") - } -} - -func testServiceDevelopmentDefaults(t *testing.T) { - service := NewService() - result := service.Version() - if result.Version != "dev" { - t.Fatalf("version = %q", result.Version) - } - if result.Commit != "unknown" { - t.Fatalf("commit = %q", result.Commit) - } - if result.Timestamp != "unknown" { - t.Fatalf("timestamp = %q", result.Timestamp) - } - if result.HasTimestamp || !result.BuiltAt.IsZero() { - t.Fatalf("unexpected release timestamp %v", result.BuiltAt) - } -} - -func testServiceMatchesSnapshot(t *testing.T) { - service := NewService() - snapshot := buildinfo.Current() - result := service.Version() - if result.Version != snapshot.Version { - t.Fatalf("version = %q, want %q", result.Version, snapshot.Version) - } - if result.Commit != snapshot.Commit { - t.Fatalf("commit = %q, want %q", result.Commit, snapshot.Commit) - } - if result.Timestamp != snapshot.Timestamp { - t.Fatalf("timestamp = %q, want %q", result.Timestamp, snapshot.Timestamp) - } - if result.HasTimestamp != snapshot.HasTimestamp || !result.BuiltAt.Equal(snapshot.BuiltAt) { - t.Fatalf("built-at = %v, want %v", result.BuiltAt, snapshot.BuiltAt) - } -} - -func testServiceRuntimeFields(t *testing.T) { - service := NewService() - result := service.Version() - if result.GoVersion != runtime.Version() { - t.Fatalf("go version = %q", result.GoVersion) - } - if result.OperatingSystem != runtime.GOOS { - t.Fatalf("os = %q", result.OperatingSystem) - } - if result.Architecture != runtime.GOARCH { - t.Fatalf("arch = %q", result.Architecture) - } -} diff --git a/internal/application/version/types.go b/internal/application/version/types.go deleted file mode 100644 index 49c3eef..0000000 --- a/internal/application/version/types.go +++ /dev/null @@ -1,42 +0,0 @@ -// Package version implements `cattery version` (PLAN.md Section 11.7): it -// returns the linker-populated build identity and the current runtime -// environment as typed fields. The package is Cobra-free: no CLI type -// appears here, and the CLI talks to the service through the frozen Result -// shape below. No repository, state, clock, or external dependency is -// reachable; buildinfo is the sole import. -package version - -import ( - "time" - - "github.com/alyraffauf/cattery/internal/buildinfo" -) - -// Result is the frozen outcome of one version query, projected from a -// buildinfo snapshot so the CLI renders only version-owned fields. -// Timestamp holds the raw linker value so rendering can show "unknown"; -// BuiltAt is the parsed UTC time when one exists. -type Result struct { - Version string - Commit string - Timestamp string - BuiltAt time.Time - HasTimestamp bool - GoVersion string - OperatingSystem string - Architecture string -} - -// FromSnapshot projects a buildinfo snapshot into the version Result. -func FromSnapshot(snapshot buildinfo.Snapshot) Result { - return Result{ - Version: snapshot.Version, - Commit: snapshot.Commit, - Timestamp: snapshot.Timestamp, - BuiltAt: snapshot.BuiltAt, - HasTimestamp: snapshot.HasTimestamp, - GoVersion: snapshot.GoVersion, - OperatingSystem: snapshot.OperatingSystem, - Architecture: snapshot.Architecture, - } -} diff --git a/internal/application/version/types_test.go b/internal/application/version/types_test.go deleted file mode 100644 index 393cd53..0000000 --- a/internal/application/version/types_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package version - -import ( - "go/parser" - "go/token" - "os" - "reflect" - "runtime" - "strings" - "testing" - "time" - - "github.com/alyraffauf/cattery/internal/buildinfo" -) - -func TestVersionContract(t *testing.T) { - scenarios := []struct { - name string - run func(*testing.T) - }{ - {"zero result carries no build identity", testContractZeroResult}, - {"result exposes the eight typed fields", testContractResultShape}, - {"from-snapshot copies development defaults", testContractDevelopmentDefaults}, - {"from-snapshot copies release values", testContractReleaseValues}, - {"from-snapshot normalizes input to UTC", testContractUTCInput}, - {"from-snapshot copies runtime fields", testContractRuntimeFields}, - {"service exposes one version method", testContractServiceSignature}, - {"no cli or third-party imports", testContractNoCLIImports}, - } - for _, scenario := range scenarios { - t.Run(scenario.name, scenario.run) - } -} - -func testContractZeroResult(t *testing.T) { - var result Result - if result.Version != "" || result.Commit != "" || result.Timestamp != "" { - t.Fatalf("zero Result carries build identity %+v", result) - } - if result.HasTimestamp || !result.BuiltAt.IsZero() { - t.Fatalf("zero Result carries a timestamp %+v", result.BuiltAt) - } - if result.GoVersion != "" || result.OperatingSystem != "" || result.Architecture != "" { - t.Fatalf("zero Result carries runtime identity %+v", result) - } -} - -func testContractResultShape(t *testing.T) { - result := reflect.TypeOf(Result{}) - want := map[string]reflect.Type{ - "Version": reflect.TypeOf(""), - "Commit": reflect.TypeOf(""), - "Timestamp": reflect.TypeOf(""), - "BuiltAt": reflect.TypeOf(time.Time{}), - "HasTimestamp": reflect.TypeOf(false), - "GoVersion": reflect.TypeOf(""), - "OperatingSystem": reflect.TypeOf(""), - "Architecture": reflect.TypeOf(""), - } - if result.NumField() != len(want) { - t.Fatalf("Result has %d fields, want %d", result.NumField(), len(want)) - } - for name, fieldType := range want { - field, found := result.FieldByName(name) - if !found || field.Type != fieldType { - t.Fatalf("Result.%s type = %v, want %v", name, field.Type, fieldType) - } - } -} - -func testContractDevelopmentDefaults(t *testing.T) { - result := FromSnapshot(buildinfo.FromValues("dev", "unknown", "unknown")) - if result.Version != "dev" { - t.Fatalf("version = %q", result.Version) - } - if result.Commit != "unknown" { - t.Fatalf("commit = %q", result.Commit) - } - if result.Timestamp != "unknown" { - t.Fatalf("timestamp = %q", result.Timestamp) - } - if result.HasTimestamp { - t.Fatal("development timestamp must not parse") - } - if !result.BuiltAt.IsZero() { - t.Fatal("development built-at must be zero") - } -} - -func testContractReleaseValues(t *testing.T) { - result := FromSnapshot(buildinfo.FromValues("v1.2.3", "abcdef1234567890", "2026-08-09T12:00:00Z")) - if result.Version != "v1.2.3" { - t.Fatalf("version = %q", result.Version) - } - if result.Commit != "abcdef1234567890" { - t.Fatalf("commit = %q", result.Commit) - } - if result.Timestamp != "2026-08-09T12:00:00Z" { - t.Fatalf("timestamp = %q", result.Timestamp) - } - if !result.HasTimestamp { - t.Fatal("release timestamp must parse") - } -} - -func testContractUTCInput(t *testing.T) { - result := FromSnapshot(buildinfo.FromValues("v1.2.3", "deadbeef", "2026-08-09T14:00:00+02:00")) - if !result.HasTimestamp { - t.Fatal("offset timestamp must parse") - } - if want := "2026-08-09T12:00:00Z"; result.BuiltAt.Format(time.RFC3339) != want { - t.Fatalf("built-at = %q, want %q", result.BuiltAt.Format(time.RFC3339), want) - } - if result.BuiltAt.Location().String() != "UTC" { - t.Fatalf("location = %q", result.BuiltAt.Location()) - } -} - -func testContractRuntimeFields(t *testing.T) { - result := FromSnapshot(buildinfo.FromValues("dev", "unknown", "unknown")) - if result.GoVersion != runtime.Version() { - t.Fatalf("go version = %q", result.GoVersion) - } - if result.OperatingSystem != runtime.GOOS { - t.Fatalf("os = %q", result.OperatingSystem) - } - if result.Architecture != runtime.GOARCH { - t.Fatalf("arch = %q", result.Architecture) - } -} - -func testContractServiceSignature(t *testing.T) { - method, found := reflect.TypeOf((*Service)(nil)).MethodByName("Version") - if !found { - t.Fatal("Service.Version method missing") - } - signature := method.Type - if signature.NumIn() != 1 || signature.In(0) != reflect.TypeOf((*Service)(nil)) { - t.Fatalf("Version receiver = %v, want (*Service)", signature) - } - if signature.NumOut() != 1 || signature.Out(0) != reflect.TypeOf(Result{}) { - t.Fatalf("Version results = %v, want (Result)", signature) - } - if constructor := reflect.TypeOf(NewService); constructor.NumIn() != 0 { - t.Fatalf("NewService parameters = %d, want none", constructor.NumIn()) - } -} - -func testContractNoCLIImports(t *testing.T) { - for _, name := range packageSources(t) { - assertCleanImports(t, name) - } -} - -func assertCleanImports(t *testing.T, name string) { - fileSet := token.NewFileSet() - file, err := parser.ParseFile(fileSet, name, nil, parser.ImportsOnly) - if err != nil { - t.Fatalf("parse %s: %v", name, err) - } - for _, spec := range file.Imports { - if isForbiddenImport(strings.Trim(spec.Path.Value, `"`)) { - t.Fatalf("%s imports %q", name, spec.Path.Value) - } - } -} - -func packageSources(t *testing.T) []string { - t.Helper() - entries, err := os.ReadDir(".") - if err != nil { - t.Fatal(err) - } - var sources []string - for _, entry := range entries { - name := entry.Name() - if strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") { - sources = append(sources, name) - } - } - return sources -} - -func isForbiddenImport(path string) bool { - return strings.HasPrefix(path, "github.com/spf13/cobra") || - strings.HasPrefix(path, "github.com/spf13/pflag") || - strings.HasSuffix(path, "/internal/cli") -} diff --git a/internal/bootstrap/applications.go b/internal/bootstrap/applications.go index f965732..c6a7648 100644 --- a/internal/bootstrap/applications.go +++ b/internal/bootstrap/applications.go @@ -8,7 +8,6 @@ import ( "github.com/alyraffauf/cattery/internal/application/initialize" "github.com/alyraffauf/cattery/internal/application/inspect" "github.com/alyraffauf/cattery/internal/application/validate" - "github.com/alyraffauf/cattery/internal/application/version" "github.com/alyraffauf/cattery/internal/cli" "github.com/alyraffauf/cattery/internal/deployment" "github.com/alyraffauf/cattery/internal/selection" @@ -22,7 +21,6 @@ type Applications struct { Inspect *inspect.Service Apply *apply.Service Add *add.Service - Version *version.Service } // ApplicationsInput carries the adapters, home, platform, protected @@ -82,7 +80,6 @@ func BuildApplications(input ApplicationsInput) Applications { Inspect: buildInspect(input, shared), Apply: buildApply(input, shared), Add: buildAdd(input, shared), - Version: version.NewService(), } } diff --git a/internal/bootstrap/applications_test.go b/internal/bootstrap/applications_test.go index 314cb4c..81679cf 100644 --- a/internal/bootstrap/applications_test.go +++ b/internal/bootstrap/applications_test.go @@ -43,17 +43,13 @@ func appFixture(t *testing.T) (Applications, Adapters) { func testApplicationsBuilt(t *testing.T) { applications, _ := appFixture(t) if applications.Initialize == nil || applications.Validate == nil || applications.Inspect == nil || - applications.Apply == nil || applications.Add == nil || applications.Version == nil { + applications.Apply == nil || applications.Add == nil { t.Fatal("every application service must be constructed") } } func testApplicationsSideEffects(t *testing.T) { - applications, adapters := appFixture(t) - result := applications.Version.Version() - if result.Version != "dev" { - t.Fatalf("version = %q, want the development default", result.Version) - } + _, adapters := appFixture(t) if adapters.Store.Database() != nil { t.Fatal("construction must not open the database") } diff --git a/internal/bootstrap/build.go b/internal/bootstrap/build.go index bcfd15d..d9e9d25 100644 --- a/internal/bootstrap/build.go +++ b/internal/bootstrap/build.go @@ -52,7 +52,6 @@ func Build(input BuildInput) *cli.Application { return cli.NewApplication(cli.Dependencies{ Initialize: services.Initialize, Validate: services.Validate, - Version: services.Version, Status: services.Inspect, Diff: services.Inspect, Add: services.Add, diff --git a/internal/cli/root.go b/internal/cli/root.go index 2ccd4d6..91a52d7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -12,7 +12,6 @@ import ( type Dependencies struct { Initialize InitializeService Validate ValidateService - Version VersionService Status StatusService Diff DiffService Add AddService @@ -52,7 +51,7 @@ func NewApplication(dependencies Dependencies, runtime Runtime) *Application { root.AddCommand( newInitCommand(dependencies.Initialize, runtime), newValidateCommand(dependencies.Validate, runtime, options), - newVersionCommand(dependencies.Version, runtime), + newVersionCommand(runtime), newStatusCommand(dependencies.Status, runtime, options), newDiffCommand(dependencies.Diff, runtime, options), newAddCommand(dependencies.Add, runtime, options), diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index e9787bf..f89743a 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -27,7 +27,6 @@ func TestCobraRoot(t *testing.T) { type rootFakes struct { initialize *initServiceFake validate *validateServiceFake - version *versionServiceFake status *statusServiceFake diff *diffServiceFake add *addServiceFake @@ -40,7 +39,6 @@ func rootFixture(t *testing.T) (*Application, *rootFakes, *bytes.Buffer, *bytes. fakes := &rootFakes{ initialize: &initServiceFake{}, validate: &validateServiceFake{}, - version: &versionServiceFake{}, status: &statusServiceFake{}, diff: &diffServiceFake{}, add: &addServiceFake{}, @@ -54,7 +52,6 @@ func rootFixture(t *testing.T) (*Application, *rootFakes, *bytes.Buffer, *bytes. application := NewApplication(Dependencies{ Initialize: fakes.initialize, Validate: fakes.validate, - Version: fakes.version, Status: fakes.status, Diff: fakes.diff, Add: fakes.add, @@ -66,7 +63,7 @@ func rootFixture(t *testing.T) (*Application, *rootFakes, *bytes.Buffer, *bytes. // zeroCalls reports whether no service was invoked. func zeroCalls(fakes *rootFakes) bool { return len(fakes.initialize.requests) == 0 && len(fakes.validate.requests) == 0 && - fakes.version.calls == 0 && len(fakes.status.requests) == 0 && len(fakes.diff.requests) == 0 && + len(fakes.status.requests) == 0 && len(fakes.diff.requests) == 0 && len(fakes.add.requests) == 0 && len(fakes.apply.requests) == 0 } diff --git a/internal/cli/version.go b/internal/cli/version.go index ff93726..7acd412 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -3,25 +3,20 @@ package cli import ( "fmt" - "github.com/alyraffauf/cattery/internal/application/version" + "github.com/alyraffauf/cattery/internal/buildinfo" "github.com/spf13/cobra" ) -// VersionService is the one-method role the version adapter calls. -type VersionService interface { - Version() version.Result -} - // newVersionCommand declares only the version subcommand and renders the // typed build fields in the exact Section 11.7 single-line format, without // touching the Cobra root Version field. -func newVersionCommand(service VersionService, runtime Runtime) *cobra.Command { +func newVersionCommand(runtime Runtime) *cobra.Command { command := &cobra.Command{ Use: "version", Short: "Print version information", Args: cobra.NoArgs, RunE: func(command *cobra.Command, args []string) error { - result := service.Version() + result := buildinfo.Current() _, err := fmt.Fprintf(runtime.Stdout(), "cattery %s commit=%s built=%s go=%s target=%s/%s\n", result.Version, result.Commit, result.Timestamp, diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go index 9e0d04d..f27abea 100644 --- a/internal/cli/version_test.go +++ b/internal/cli/version_test.go @@ -2,10 +2,11 @@ package cli import ( "bytes" + "runtime" "strings" "testing" - "github.com/alyraffauf/cattery/internal/application/version" + "github.com/alyraffauf/cattery/internal/buildinfo" "github.com/spf13/cobra" ) @@ -17,7 +18,7 @@ func TestVersionCommand(t *testing.T) { {"development defaults", testVersionDevelopment}, {"release values", testVersionRelease}, {"single line with newline", testVersionSingleLine}, - {"one call", testVersionOneCall}, + {"repeatable invocation", testVersionRepeatable}, {"writer error", testVersionWriterError}, {"no backend access", testVersionNoBackend}, } @@ -26,58 +27,52 @@ func TestVersionCommand(t *testing.T) { } } -// versionServiceFake returns a fixed result. -type versionServiceFake struct { - result version.Result - calls int -} - -func (f *versionServiceFake) Version() version.Result { - f.calls++ - return f.result -} - -// versionFixture builds one version command over a recording service. -func versionFixture(t *testing.T, service *versionServiceFake) (*cobra.Command, *bytes.Buffer) { +// versionFixture builds one version command over isolated output. +func versionFixture(t *testing.T) (*cobra.Command, *bytes.Buffer) { t.Helper() stdout := &bytes.Buffer{} runtime := NewRuntime(RuntimeInput{Streams: Streams{Stdout: stdout}}) - return newVersionCommand(service, runtime), stdout + return newVersionCommand(runtime), stdout +} + +func setBuildInfo(t *testing.T, version, commit, timestamp string) { + t.Helper() + previousVersion, previousCommit, previousTimestamp := buildinfo.Version, buildinfo.Commit, buildinfo.BuildTimestamp + buildinfo.Version, buildinfo.Commit, buildinfo.BuildTimestamp = version, commit, timestamp + t.Cleanup(func() { + buildinfo.Version, buildinfo.Commit, buildinfo.BuildTimestamp = previousVersion, previousCommit, previousTimestamp + }) } func testVersionDevelopment(t *testing.T) { - service := &versionServiceFake{result: version.Result{ - Version: "dev", Commit: "unknown", Timestamp: "unknown", - GoVersion: "go1.26.5", OperatingSystem: "linux", Architecture: "amd64", - }} - command, stdout := versionFixture(t, service) + setBuildInfo(t, "dev", "unknown", "unknown") + command, stdout := versionFixture(t) if err := command.Execute(); err != nil { t.Fatalf("run: %v", err) } - want := "cattery dev commit=unknown built=unknown go=go1.26.5 target=linux/amd64\n" + want := "cattery dev commit=unknown built=unknown go=" + runtime.Version() + + " target=" + runtime.GOOS + "/" + runtime.GOARCH + "\n" if stdout.String() != want { t.Fatalf("stdout = %q, want %q", stdout.String(), want) } } func testVersionRelease(t *testing.T) { - service := &versionServiceFake{result: version.Result{ - Version: "v1.0.0", Commit: "0123456789abcdef", Timestamp: "2026-08-09T00:00:00Z", - GoVersion: "go1.26.5", OperatingSystem: "darwin", Architecture: "arm64", - }} - command, stdout := versionFixture(t, service) + setBuildInfo(t, "v1.0.0", "0123456789abcdef", "2026-08-09T00:00:00Z") + command, stdout := versionFixture(t) if err := command.Execute(); err != nil { t.Fatalf("run: %v", err) } - want := "cattery v1.0.0 commit=0123456789abcdef built=2026-08-09T00:00:00Z go=go1.26.5 target=darwin/arm64\n" + want := "cattery v1.0.0 commit=0123456789abcdef built=2026-08-09T00:00:00Z go=" + runtime.Version() + + " target=" + runtime.GOOS + "/" + runtime.GOARCH + "\n" if stdout.String() != want { t.Fatalf("stdout = %q, want %q", stdout.String(), want) } } func testVersionSingleLine(t *testing.T) { - service := &versionServiceFake{result: version.Result{Version: "dev", Commit: "unknown", Timestamp: "unknown"}} - command, stdout := versionFixture(t, service) + setBuildInfo(t, "dev", "unknown", "unknown") + command, stdout := versionFixture(t) if err := command.Execute(); err != nil { t.Fatalf("run: %v", err) } @@ -87,36 +82,33 @@ func testVersionSingleLine(t *testing.T) { } } -func testVersionOneCall(t *testing.T) { - service := &versionServiceFake{result: version.Result{Version: "dev", Commit: "unknown", Timestamp: "unknown"}} - command, _ := versionFixture(t, service) +func testVersionRepeatable(t *testing.T) { + setBuildInfo(t, "dev", "unknown", "unknown") + command, stdout := versionFixture(t) if err := command.Execute(); err != nil { t.Fatalf("run: %v", err) } - if service.calls != 1 { - t.Fatalf("calls = %d, want one", service.calls) + if !strings.HasSuffix(stdout.String(), "\n") { + t.Fatal("output must be newline-terminated") } } func testVersionWriterError(t *testing.T) { - service := &versionServiceFake{result: version.Result{Version: "dev", Commit: "unknown", Timestamp: "unknown"}} + setBuildInfo(t, "dev", "unknown", "unknown") runtime := NewRuntime(RuntimeInput{Streams: Streams{Stdout: failingWriter{}}}) - command := newVersionCommand(service, runtime) + command := newVersionCommand(runtime) if err := command.Execute(); err == nil { t.Fatal("a writer failure must surface") } } func testVersionNoBackend(t *testing.T) { - service := &versionServiceFake{result: version.Result{Version: "dev", Commit: "unknown", Timestamp: "unknown"}} - command, _ := versionFixture(t, service) + setBuildInfo(t, "dev", "unknown", "unknown") + command, _ := versionFixture(t) if err := command.Execute(); err != nil { t.Fatalf("run: %v", err) } if err := command.Execute(); err != nil { t.Fatalf("second run: %v", err) } - if service.calls != 2 { - t.Fatalf("calls = %d, want two independent invocations", service.calls) - } }