From 05cb597e58e8de26a608dd873b21a1e3ee056cf2 Mon Sep 17 00:00:00 2001 From: indaco Date: Tue, 27 Jan 2026 01:03:28 +0100 Subject: [PATCH] refactor(operations/bump): add BumpPre and refactor bump logic into helpers --- internal/operations/bump.go | 190 ++++++++++++++++++++------ internal/operations/bump_test.go | 224 +++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+), 42 deletions(-) diff --git a/internal/operations/bump.go b/internal/operations/bump.go index 023963f5..00b111ca 100644 --- a/internal/operations/bump.go +++ b/internal/operations/bump.go @@ -19,6 +19,7 @@ const ( BumpMajor BumpType = "major" BumpRelease BumpType = "release" BumpAuto BumpType = "auto" + BumpPre BumpType = "pre" ) // BumpOperation performs a version bump on a module. @@ -59,69 +60,174 @@ func (op *BumpOperation) Execute(ctx context.Context, mod *workspace.Module) err return fmt.Errorf("failed to read version from %s: %w", mod.Path, err) } - // Perform the bump based on type - var newVer semver.SemVersion + // Calculate the new version + newVer, err := op.calculateNewVersion(currentVer) + if err != nil { + return err + } + + // BumpPre handles its own metadata, others use common logic + if op.bumpType != BumpPre { + op.applyPreReleaseAndMetadata(&newVer, currentVer) + } + + // Write the new version + if err := vm.Save(ctx, mod.Path, newVer); err != nil { + return fmt.Errorf("failed to write version to %s: %w", mod.Path, err) + } + + // Update module's current version for display + mod.CurrentVersion = newVer.String() + + return nil +} + +// calculateNewVersion computes the new version based on bump type. +func (op *BumpOperation) calculateNewVersion(currentVer semver.SemVersion) (semver.SemVersion, error) { switch op.bumpType { case BumpPatch: - newVer = semver.SemVersion{ - Major: currentVer.Major, - Minor: currentVer.Minor, - Patch: currentVer.Patch + 1, - } + return op.bumpPatch(currentVer), nil case BumpMinor: - newVer = semver.SemVersion{ - Major: currentVer.Major, - Minor: currentVer.Minor + 1, - Patch: 0, - } + return op.bumpMinor(currentVer), nil case BumpMajor: - newVer = semver.SemVersion{ - Major: currentVer.Major + 1, - Minor: 0, - Patch: 0, - } + return op.bumpMajor(currentVer), nil case BumpRelease: - // Release removes pre-release and build metadata - newVer = semver.SemVersion{ - Major: currentVer.Major, - Minor: currentVer.Minor, - Patch: currentVer.Patch, - } + return op.bumpRelease(currentVer), nil case BumpAuto: - // Auto bump uses heuristic-based logic - autoVer, autoErr := semver.BumpNextFunc(currentVer) - if autoErr != nil { - return fmt.Errorf("auto bump failed: %w", autoErr) - } - newVer = autoVer + return op.bumpAuto(currentVer) + case BumpPre: + return op.bumpPre(currentVer) default: - return fmt.Errorf("unknown bump type: %s", op.bumpType) + return semver.SemVersion{}, fmt.Errorf("unknown bump type: %s", op.bumpType) + } +} + +func (op *BumpOperation) bumpPatch(current semver.SemVersion) semver.SemVersion { + return semver.SemVersion{ + Major: current.Major, + Minor: current.Minor, + Patch: current.Patch + 1, + } +} + +func (op *BumpOperation) bumpMinor(current semver.SemVersion) semver.SemVersion { + return semver.SemVersion{ + Major: current.Major, + Minor: current.Minor + 1, + Patch: 0, } +} - // Apply pre-release label if provided +func (op *BumpOperation) bumpMajor(current semver.SemVersion) semver.SemVersion { + return semver.SemVersion{ + Major: current.Major + 1, + Minor: 0, + Patch: 0, + } +} + +func (op *BumpOperation) bumpRelease(current semver.SemVersion) semver.SemVersion { + return semver.SemVersion{ + Major: current.Major, + Minor: current.Minor, + Patch: current.Patch, + } +} + +func (op *BumpOperation) bumpAuto(current semver.SemVersion) (semver.SemVersion, error) { + newVer, err := semver.BumpNextFunc(current) + if err != nil { + return semver.SemVersion{}, fmt.Errorf("auto bump failed: %w", err) + } + return newVer, nil +} + +func (op *BumpOperation) bumpPre(current semver.SemVersion) (semver.SemVersion, error) { + newVer := semver.SemVersion{ + Major: current.Major, + Minor: current.Minor, + Patch: current.Patch, + } + + // Determine the pre-release value + preRelease, err := op.calculatePreRelease(current) + if err != nil { + return semver.SemVersion{}, err + } + newVer.PreRelease = preRelease + + // Apply metadata for pre-release bump + op.applyMetadata(&newVer, current) + + return newVer, nil +} + +// calculatePreRelease determines the pre-release string for BumpPre. +func (op *BumpOperation) calculatePreRelease(current semver.SemVersion) (string, error) { + if op.preRelease != "" { + return semver.IncrementPreRelease(current.PreRelease, op.preRelease), nil + } + if current.PreRelease != "" { + base := extractPreReleaseBase(current.PreRelease) + return semver.IncrementPreRelease(current.PreRelease, base), nil + } + return "", fmt.Errorf("current version has no pre-release; use --label to specify one") +} + +// applyPreReleaseAndMetadata applies pre-release and metadata to the version. +func (op *BumpOperation) applyPreReleaseAndMetadata(newVer *semver.SemVersion, currentVer semver.SemVersion) { if op.preRelease != "" { newVer.PreRelease = op.preRelease } + op.applyMetadata(newVer, currentVer) +} - // Apply metadata +// applyMetadata applies build metadata to the version. +func (op *BumpOperation) applyMetadata(newVer *semver.SemVersion, currentVer semver.SemVersion) { if op.metadata != "" { newVer.Build = op.metadata } else if op.preserveMetadata && currentVer.Build != "" { newVer.Build = currentVer.Build } - - // Write the new version - if err := vm.Save(ctx, mod.Path, newVer); err != nil { - return fmt.Errorf("failed to write version to %s: %w", mod.Path, err) - } - - // Update module's current version for display - mod.CurrentVersion = newVer.String() - - return nil } // Name returns the name of this operation. func (op *BumpOperation) Name() string { return fmt.Sprintf("bump %s", op.bumpType) } + +// extractPreReleaseBase extracts the base label from a pre-release string. +// e.g., "rc.1" -> "rc", "beta.2" -> "beta", "alpha" -> "alpha", "rc1" -> "rc" +func extractPreReleaseBase(pre string) string { + // First, check for dot followed by a number + for i := len(pre) - 1; i >= 0; i-- { + if pre[i] == '.' { + // Check if everything after the dot is numeric + suffix := pre[i+1:] + isNumeric := true + for _, c := range suffix { + if c < '0' || c > '9' { + isNumeric = false + break + } + } + if isNumeric && len(suffix) > 0 { + return pre[:i] + } + } + } + + // Check for trailing digits without dot (e.g., "rc1" -> "rc") + lastNonDigit := -1 + for i := len(pre) - 1; i >= 0; i-- { + if pre[i] < '0' || pre[i] > '9' { + lastNonDigit = i + break + } + } + if lastNonDigit >= 0 && lastNonDigit < len(pre)-1 { + return pre[:lastNonDigit+1] + } + + return pre +} diff --git a/internal/operations/bump_test.go b/internal/operations/bump_test.go index 67833e3f..52b7bbfc 100644 --- a/internal/operations/bump_test.go +++ b/internal/operations/bump_test.go @@ -481,6 +481,11 @@ func TestBumpOperation_Name(t *testing.T) { bumpType: BumpAuto, expected: "bump auto", }, + { + name: "pre", + bumpType: BumpPre, + expected: "bump pre", + }, } for _, tt := range tests { @@ -495,3 +500,222 @@ func TestBumpOperation_Name(t *testing.T) { }) } } + +func TestBumpOperation_Execute_Pre_IncrementExisting(t *testing.T) { + tests := []struct { + name string + initial string + label string + expected string + }{ + { + name: "increment rc.1 to rc.2", + initial: "1.2.3-rc.1\n", + label: "", + expected: "1.2.3-rc.2\n", + }, + { + name: "increment rc.9 to rc.10", + initial: "1.2.3-rc.9\n", + label: "", + expected: "1.2.3-rc.10\n", + }, + { + name: "increment beta.1 to beta.2", + initial: "2.0.0-beta.1\n", + label: "", + expected: "2.0.0-beta.2\n", + }, + { + name: "increment alpha to alpha.1 with no number", + initial: "1.0.0-alpha\n", + label: "", + expected: "1.0.0-alpha.1\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := core.NewMockFileSystem() + fs.SetFile("/test/.version", []byte(tt.initial)) + + op := NewBumpOperation(fs, BumpPre, tt.label, "", false) + mod := &workspace.Module{ + Name: "test", + Path: "/test/.version", + } + + ctx := context.Background() + err := op.Execute(ctx, mod) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + data, ok := fs.GetFile("/test/.version") + if !ok { + t.Fatal("version file not found") + } + + if string(data) != tt.expected { + t.Errorf("version = %q, want %q", string(data), tt.expected) + } + }) + } +} + +func TestBumpOperation_Execute_Pre_WithLabel(t *testing.T) { + tests := []struct { + name string + initial string + label string + expected string + }{ + { + name: "add rc to stable version", + initial: "1.2.3\n", + label: "rc", + expected: "1.2.3-rc.1\n", + }, + { + name: "switch from alpha to beta", + initial: "1.2.3-alpha.3\n", + label: "beta", + expected: "1.2.3-beta.1\n", + }, + { + name: "switch from rc to alpha", + initial: "1.2.3-rc.1\n", + label: "alpha", + expected: "1.2.3-alpha.1\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := core.NewMockFileSystem() + fs.SetFile("/test/.version", []byte(tt.initial)) + + op := NewBumpOperation(fs, BumpPre, tt.label, "", false) + mod := &workspace.Module{ + Name: "test", + Path: "/test/.version", + } + + ctx := context.Background() + err := op.Execute(ctx, mod) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + data, ok := fs.GetFile("/test/.version") + if !ok { + t.Fatal("version file not found") + } + + if string(data) != tt.expected { + t.Errorf("version = %q, want %q", string(data), tt.expected) + } + }) + } +} + +func TestBumpOperation_Execute_Pre_NoExistingPreRelease(t *testing.T) { + fs := core.NewMockFileSystem() + fs.SetFile("/test/.version", []byte("1.2.3\n")) + + // BumpPre with no label and no existing pre-release should fail + op := NewBumpOperation(fs, BumpPre, "", "", false) + mod := &workspace.Module{ + Name: "test", + Path: "/test/.version", + } + + ctx := context.Background() + err := op.Execute(ctx, mod) + if err == nil { + t.Fatal("expected error when no pre-release exists and no label provided") + } +} + +func TestBumpOperation_Execute_Pre_PreserveMetadata(t *testing.T) { + fs := core.NewMockFileSystem() + fs.SetFile("/test/.version", []byte("1.2.3-rc.1+build.99\n")) + + op := NewBumpOperation(fs, BumpPre, "", "", true) + mod := &workspace.Module{ + Name: "test", + Path: "/test/.version", + } + + ctx := context.Background() + err := op.Execute(ctx, mod) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + data, ok := fs.GetFile("/test/.version") + if !ok { + t.Fatal("version file not found") + } + + expected := "1.2.3-rc.2+build.99\n" + if string(data) != expected { + t.Errorf("version = %q, want %q", string(data), expected) + } +} + +func TestBumpOperation_Execute_Pre_WithNewMetadata(t *testing.T) { + fs := core.NewMockFileSystem() + fs.SetFile("/test/.version", []byte("1.2.3-rc.1\n")) + + op := NewBumpOperation(fs, BumpPre, "", "ci.456", false) + mod := &workspace.Module{ + Name: "test", + Path: "/test/.version", + } + + ctx := context.Background() + err := op.Execute(ctx, mod) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + data, ok := fs.GetFile("/test/.version") + if !ok { + t.Fatal("version file not found") + } + + expected := "1.2.3-rc.2+ci.456\n" + if string(data) != expected { + t.Errorf("version = %q, want %q", string(data), expected) + } +} + +func TestExtractPreReleaseBase(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"rc.1", "rc"}, + {"rc.10", "rc"}, + {"beta.2", "beta"}, + {"alpha.1", "alpha"}, + {"rc1", "rc"}, + {"beta5", "beta"}, + {"rc-1", "rc-"}, + {"alpha", "alpha"}, + {"rc", "rc"}, + {"1", "1"}, // Pure number returns as-is (edge case) + {"123", "123"}, // Pure number returns as-is (edge case) + {"dev.1.2", "dev.1"}, // Multiple dots, extracts up to last numeric part + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := extractPreReleaseBase(tt.input) + if result != tt.expected { + t.Errorf("extractPreReleaseBase(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +}