diff --git a/npm.go b/npm.go index c901419..5503db0 100644 --- a/npm.go +++ b/npm.go @@ -3,12 +3,13 @@ package main import ( "encoding/json" "fmt" - "github.com/charmbracelet/log" "net/http" "net/url" "os" "strings" "time" + + "github.com/charmbracelet/log" ) type PackageJSONRaw struct { @@ -16,9 +17,16 @@ type PackageJSONRaw struct { DevDependencies map[string]string `json:"devDependencies"` } +type DependencyVersion struct { + Raw string + Prefix string + Semver Semver + HasSemver bool +} + type DependencyJSON struct { Name string - Version string + Version DependencyVersion } type PackageJSONDependencies struct { @@ -26,10 +34,56 @@ type PackageJSONDependencies struct { DevDependencies []DependencyJSON } +func (v DependencyVersion) String() string { + if v.HasSemver { + return v.Prefix + v.Semver.String() + } + return v.Raw +} + +func (v DependencyVersion) WithSemver(semver Semver) DependencyVersion { + return DependencyVersion{ + Raw: v.Prefix + semver.String(), + Prefix: v.Prefix, + Semver: semver, + HasSemver: true, + } +} + +func parseDependencyVersion(value string) DependencyVersion { + trimmed := strings.TrimSpace(value) + prefix, coreVersion := extractDependencyVersionPrefix(trimmed) + semver, err := ParseSemver(coreVersion) + if err != nil { + return DependencyVersion{Raw: trimmed} + } + + return DependencyVersion{ + Raw: trimmed, + Prefix: prefix, + Semver: semver, + HasSemver: true, + } +} + +func extractDependencyVersionPrefix(value string) (string, string) { + if value == "*" { + return "", value + } + + prefixes := []string{"<=", ">=", "^", "~", ">", "<", "=", "*"} + for _, prefix := range prefixes { + if rest, ok := strings.CutPrefix(value, prefix); ok { + return prefix, rest + } + } + return "", value +} + func mapToDeps(m map[string]string) []DependencyJSON { deps := make([]DependencyJSON, 0, len(m)) for name, version := range m { - deps = append(deps, DependencyJSON{Name: name, Version: version}) + deps = append(deps, DependencyJSON{Name: name, Version: parseDependencyVersion(version)}) } return deps } @@ -62,44 +116,78 @@ func getNPMPackageLatestVersion(packageName string) (string, error) { return result.Version, nil } -func normalizeDependencyVersions(deps []DependencyJSON) { - prefixes := []string{"^", "~", ">=", "<=", ">", "<", "=", "*"} - - for i, dep := range deps { - for _, prefix := range prefixes { - if normalizedVersion, ok := strings.CutPrefix(dep.Version, prefix); ok { - dep.Version = normalizedVersion - deps[i] = dep - break - } - } - } -} - func updateDependencies(deps []DependencyJSON) error { for i, dep := range deps { - log.Infof("Dependency : %s, version : %s", dep.Name, dep.Version) + log.Infof("Dependency : %s, version : %s", dep.Name, dep.Version.String()) if dep.Name == "" { log.Warnf("Dependency name is empty, skipping...") continue } - latestVersion, err := getNPMPackageLatestVersion(dep.Name) + latestVersionString, err := getNPMPackageLatestVersion(dep.Name) if err != nil { return fmt.Errorf("failed to fetch latest version for %s: %w", dep.Name, err) } - log.Infof("Latest version of %s : %s", dep.Name, latestVersion) - dep.Version = latestVersion + latestVersion := parseDependencyVersion(latestVersionString) + log.Infof("Latest version of %s : %s", dep.Name, latestVersion.String()) + + changeType, shouldUpdate := classifyDependencyUpdate(dep.Version, latestVersion) + if changeType == SemverChangeDowngrade { + log.Warnf("Dependency %s current version %s is newer than registry latest %s, keeping current version", dep.Name, dep.Version.String(), latestVersion.String()) + continue + } + + if !shouldUpdate { + log.Infof("Dependency %s already up to date (%s)", dep.Name, changeType) + continue + } + + updatedVersion := mergeDependencyVersion(dep.Version, latestVersion) + log.Infof("Updating %s with %s change: %s -> %s", dep.Name, changeType, dep.Version.String(), updatedVersion.String()) + dep.Version = updatedVersion deps[i] = dep } return nil } +func classifyDependencyUpdate(currentVersion DependencyVersion, latestVersion DependencyVersion) (SemverChange, bool) { + if currentVersion.HasSemver && latestVersion.HasSemver { + changeType := currentVersion.Semver.ChangeType(latestVersion.Semver) + return changeType, changeType != SemverChangeNone && changeType != SemverChangeDowngrade + } + + if currentVersion.String() == latestVersion.String() { + return SemverChangeNone, false + } + + return SemverChangeInvalid, true +} + +func mergeDependencyVersion(currentVersion DependencyVersion, latestVersion DependencyVersion) DependencyVersion { + if currentVersion.HasSemver && latestVersion.HasSemver { + mergedSemver := latestVersion.Semver + // Keep a revision suffix only when both versions refer to the same + // semantic core; otherwise an old revision would be carried onto a new + // base version like 1.2.4_1. + if currentVersion.Semver.HasRevision && !latestVersion.Semver.HasRevision && semverCoreEqual(currentVersion.Semver, latestVersion.Semver) { + mergedSemver.Revision = currentVersion.Semver.Revision + mergedSemver.HasRevision = true + } + return currentVersion.WithSemver(mergedSemver) + } + + return latestVersion +} + +func semverCoreEqual(left Semver, right Semver) bool { + return left.Major == right.Major && left.Minor == right.Minor && left.Patch == right.Patch +} + func depsToMap(deps []DependencyJSON) map[string]string { depsMap := make(map[string]string, len(deps)) for _, dep := range deps { - depsMap[dep.Name] = dep.Version + depsMap[dep.Name] = dep.Version.String() } return depsMap } @@ -125,9 +213,6 @@ func processNPMPackage(packagePath string) error { DevDependencies: mapToDeps(raw.DevDependencies), } - normalizeDependencyVersions(packageJSON.Dependencies) - normalizeDependencyVersions(packageJSON.DevDependencies) - log.Infof("Dependencies number : %v", len(packageJSON.Dependencies)) if err := updateDependencies(packageJSON.Dependencies); err != nil { return fmt.Errorf("failed to update dependencies: %w", err) @@ -151,7 +236,7 @@ func processNPMPackage(packagePath string) error { return err } - if Ctx.DryRun == false { + if !Ctx.DryRun { if err := os.WriteFile(packagePath, append(finalJSON, '\n'), 0644); err != nil { return err } diff --git a/npm_test.go b/npm_test.go index c9f2d5e..c7806d7 100644 --- a/npm_test.go +++ b/npm_test.go @@ -55,7 +55,7 @@ func newRegistryClient(t *testing.T, handler http.HandlerFunc) *http.Client { func TestMapToDepsAndDepsToMapRoundTrip(t *testing.T) { depsMap := map[string]string{ - "react": "18.3.1", + "react": "^18.3.1", "vitest": "1.6.0", } @@ -64,6 +64,18 @@ func TestMapToDepsAndDepsToMapRoundTrip(t *testing.T) { t.Fatalf("expected %d dependencies, got %d", len(depsMap), len(deps)) } + for _, dep := range deps { + if dep.Name != "react" { + continue + } + if !dep.Version.HasSemver { + t.Fatal("expected react version to parse as semver") + } + if dep.Version.Prefix != "^" { + t.Fatalf("expected react prefix %q, got %q", "^", dep.Version.Prefix) + } + } + roundTrip := depsToMap(deps) if !reflect.DeepEqual(roundTrip, depsMap) { t.Fatalf("expected round trip map %v, got %v", depsMap, roundTrip) @@ -74,27 +86,35 @@ func TestMapToDepsAndDepsToMapRoundTrip(t *testing.T) { } } -func TestNormalizeDependencyVersions(t *testing.T) { - deps := []DependencyJSON{ - {Name: "caret", Version: "^1.0.0"}, - {Name: "tilde", Version: "~2.0.0"}, - {Name: "gte", Version: ">=3.0.0"}, - {Name: "lte", Version: "<=4.0.0"}, - {Name: "gt", Version: ">5.0.0"}, - {Name: "lt", Version: "<6.0.0"}, - {Name: "eq", Version: "=7.0.0"}, - {Name: "wildcard", Version: "*8.0.0"}, - {Name: "plain", Version: "9.0.0"}, - {Name: "range", Version: "1.0.0 || 2.0.0"}, +func TestParseDependencyVersion(t *testing.T) { + tests := []struct { + name string + input string + wantString string + wantPrefix string + wantSemver bool + }{ + {name: "caret", input: "^1.0.0", wantString: "^1.0.0", wantPrefix: "^", wantSemver: true}, + {name: "tilde revision", input: "~1.2.3_1", wantString: "~1.2.3_1", wantPrefix: "~", wantSemver: true}, + {name: "wildcard", input: "*", wantString: "*", wantPrefix: "", wantSemver: false}, + {name: "gte", input: ">=3.0.0", wantString: ">=3.0.0", wantPrefix: ">=", wantSemver: true}, + {name: "plain", input: "9.0.0", wantString: "9.0.0", wantPrefix: "", wantSemver: true}, + {name: "workspace", input: "workspace:*", wantString: "workspace:*", wantPrefix: "", wantSemver: false}, } - normalizeDependencyVersions(deps) - - want := []string{"1.0.0", "2.0.0", "3.0.0", "4.0.0", "5.0.0", "6.0.0", "7.0.0", "8.0.0", "9.0.0", "1.0.0 || 2.0.0"} - for i, dep := range deps { - if dep.Version != want[i] { - t.Fatalf("dependency %q: expected version %q, got %q", dep.Name, want[i], dep.Version) - } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := parseDependencyVersion(test.input) + if got.String() != test.wantString { + t.Fatalf("parseDependencyVersion().String() = %q, want %q", got.String(), test.wantString) + } + if got.Prefix != test.wantPrefix { + t.Fatalf("parseDependencyVersion().Prefix = %q, want %q", got.Prefix, test.wantPrefix) + } + if got.HasSemver != test.wantSemver { + t.Fatalf("parseDependencyVersion().HasSemver = %v, want %v", got.HasSemver, test.wantSemver) + } + }) } } @@ -110,6 +130,12 @@ func TestUpdateDependencies(t *testing.T) { switch pkgName { case "left-pad": _, _ = io.WriteString(w, `{"version":"1.3.0"}`) + case "same-version": + _, _ = io.WriteString(w, `{"version":"1.0.0"}`) + case "underscore-style": + _, _ = io.WriteString(w, `{"version":"1.2.4"}`) + case "downgrade": + _, _ = io.WriteString(w, `{"version":"1.9.9"}`) case "broken": http.Error(w, "boom", http.StatusBadGateway) default: @@ -120,26 +146,111 @@ func TestUpdateDependencies(t *testing.T) { withTestContext(t, Context{HTTPClient: client}) deps := []DependencyJSON{ - {Name: "left-pad", Version: "1.1.0"}, - {Name: "", Version: "2.0.0"}, - {Name: "broken", Version: "3.0.0"}, + {Name: "left-pad", Version: parseDependencyVersion("1.1.0")}, + {Name: "", Version: parseDependencyVersion("2.0.0")}, + {Name: "broken", Version: parseDependencyVersion("3.0.0")}, } err := updateDependencies(deps) if err == nil { t.Fatalf("expected error on broken package fetch, got nil") } - if deps[0].Version != "1.3.0" { - t.Fatalf("expected successful dependency to update, got %q", deps[0].Version) + if deps[0].Version.String() != "1.3.0" { + t.Fatalf("expected successful dependency to update, got %q", deps[0].Version.String()) } - if deps[1].Version != "2.0.0" { - t.Fatalf("expected empty-name dependency to be skipped, got %q", deps[1].Version) + if deps[1].Version.String() != "2.0.0" { + t.Fatalf("expected empty-name dependency to be skipped, got %q", deps[1].Version.String()) } // The error should be due to the third dep (broken), so value should still be the old version - if deps[2].Version != "3.0.0" { - t.Fatalf("expected failed lookup dependency to keep its version, got %q", deps[2].Version) + if deps[2].Version.String() != "3.0.0" { + t.Fatalf("expected failed lookup dependency to keep its version, got %q", deps[2].Version.String()) + } + +} + +func TestUpdateDependenciesSkipsNoopAndDowngrade(t *testing.T) { + client := newRegistryClient(t, func(w http.ResponseWriter, r *http.Request) { + pkgPath := strings.TrimPrefix(r.URL.EscapedPath(), "/") + pkgPath = strings.TrimSuffix(pkgPath, "/latest") + pkgName, err := url.PathUnescape(pkgPath) + if err != nil { + t.Fatalf("unescape package path: %v", err) + } + + versions := map[string]string{ + "same-version": "1.0.0", + "underscore-style": "1.2.4", + "same-core-revision": "1.2.3", + "downgrade": "1.9.9", + "prefixed": "1.3.0", + } + + version, ok := versions[pkgName] + if !ok { + t.Fatalf("unexpected package request: %s", pkgName) + } + + _, _ = io.WriteString(w, `{"version":"`+version+`"}`) + }) + + withTestContext(t, Context{HTTPClient: client}) + + deps := []DependencyJSON{ + {Name: "same-version", Version: parseDependencyVersion("1.0.0")}, + {Name: "underscore-style", Version: parseDependencyVersion("1.2.3_1")}, + {Name: "same-core-revision", Version: parseDependencyVersion("1.2.3_1")}, + {Name: "downgrade", Version: parseDependencyVersion("2.0.0")}, + {Name: "prefixed", Version: parseDependencyVersion("^1.2.0")}, } + if err := updateDependencies(deps); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if deps[0].Version.String() != "1.0.0" { + t.Fatalf("expected same-version dependency to stay unchanged, got %q", deps[0].Version.String()) + } + if deps[1].Version.String() != "1.2.4" { + t.Fatalf("expected new base version to drop stale revision, got %q", deps[1].Version.String()) + } + if deps[2].Version.String() != "1.2.3_1" { + t.Fatalf("expected same semantic core to preserve revision, got %q", deps[2].Version.String()) + } + if deps[3].Version.String() != "2.0.0" { + t.Fatalf("expected downgrade dependency to stay unchanged, got %q", deps[3].Version.String()) + } + if deps[4].Version.String() != "^1.3.0" { + t.Fatalf("expected prefixed dependency to preserve prefix, got %q", deps[4].Version.String()) + } +} + +func TestClassifyDependencyUpdate(t *testing.T) { + tests := []struct { + name string + current DependencyVersion + latest DependencyVersion + wantType SemverChange + wantUpdate bool + }{ + {name: "patch", current: parseDependencyVersion("1.2.3"), latest: parseDependencyVersion("1.2.4"), wantType: SemverChangePatch, wantUpdate: true}, + {name: "none", current: parseDependencyVersion("1.2.3"), latest: parseDependencyVersion("1.2.3"), wantType: SemverChangeNone, wantUpdate: false}, + {name: "downgrade", current: parseDependencyVersion("2.0.0"), latest: parseDependencyVersion("1.9.9"), wantType: SemverChangeDowngrade, wantUpdate: false}, + {name: "revision", current: parseDependencyVersion("1.2.3"), latest: parseDependencyVersion("1.2.3_1"), wantType: SemverChangeRevision, wantUpdate: true}, + {name: "invalid fallback same", current: parseDependencyVersion("workspace:*"), latest: parseDependencyVersion("workspace:*"), wantType: SemverChangeNone, wantUpdate: false}, + {name: "invalid fallback different", current: parseDependencyVersion("workspace:*"), latest: parseDependencyVersion("2.0.0"), wantType: SemverChangeInvalid, wantUpdate: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gotType, gotUpdate := classifyDependencyUpdate(test.current, test.latest) + if gotType != test.wantType { + t.Fatalf("classifyDependencyUpdate() type = %q, want %q", gotType, test.wantType) + } + if gotUpdate != test.wantUpdate { + t.Fatalf("classifyDependencyUpdate() update = %v, want %v", gotUpdate, test.wantUpdate) + } + }) + } } func TestGetNPMPackageLatestVersion(t *testing.T) { @@ -273,7 +384,7 @@ func TestProcessNPMPackage(t *testing.T) { if !ok { t.Fatalf("expected dependencies object, got %T", document["dependencies"]) } - if deps["react"] != "18.3.1" || deps["@scope/pkg"] != "1.2.3" { + if deps["react"] != "^18.3.1" || deps["@scope/pkg"] != "~1.2.3" { t.Fatalf("unexpected dependency versions: %v", deps) } @@ -281,7 +392,7 @@ func TestProcessNPMPackage(t *testing.T) { if !ok { t.Fatalf("expected devDependencies object, got %T", document["devDependencies"]) } - if devDeps["vitest"] != "1.6.0" { + if devDeps["vitest"] != ">=1.6.0" { t.Fatalf("unexpected devDependency versions: %v", devDeps) } diff --git a/semver.go b/semver.go new file mode 100644 index 0000000..1cef621 --- /dev/null +++ b/semver.go @@ -0,0 +1,393 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "unicode" +) + +type Semver struct { + Major int + Minor int + Patch int + Revision int + HasRevision bool + PreRelease []string + BuildMetadata []string +} + +type SemverChange string + +const ( + SemverChangeInvalid SemverChange = "invalid" + SemverChangeDowngrade SemverChange = "downgrade" + SemverChangeNone SemverChange = "none" + SemverChangePrerelease SemverChange = "prerelease" + SemverChangeRevision SemverChange = "revision" + SemverChangePatch SemverChange = "patch" + SemverChangeMinor SemverChange = "minor" + SemverChangeMajor SemverChange = "major" +) + +func ParseSemver(value string) (Semver, error) { + trimmed := strings.TrimSpace(value) + trimmed = strings.TrimPrefix(trimmed, "v") + if trimmed == "" { + return Semver{}, fmt.Errorf("invalid semver %q: empty value", value) + } + + coreAndPrerelease := trimmed + buildMetadata := []string(nil) + if corePart, buildPart, ok := strings.Cut(trimmed, "+"); ok { + coreAndPrerelease = corePart + parsedBuildMetadata, err := parseSemverIdentifiers(buildPart, "build metadata", false) + if err != nil { + return Semver{}, err + } + buildMetadata = parsedBuildMetadata + } + + coreVersion := coreAndPrerelease + preRelease := []string(nil) + if corePart, prereleasePart, ok := strings.Cut(coreAndPrerelease, "-"); ok { + coreVersion = corePart + parsedPreRelease, err := parseSemverIdentifiers(prereleasePart, "prerelease", true) + if err != nil { + return Semver{}, err + } + preRelease = parsedPreRelease + } + + major, minor, patch, revision, hasRevision, err := parseSemverCore(coreVersion, value) + if err != nil { + return Semver{}, err + } + + return Semver{ + Major: major, + Minor: minor, + Patch: patch, + Revision: revision, + HasRevision: hasRevision, + PreRelease: preRelease, + BuildMetadata: buildMetadata, + }, nil +} + +func parseSemverCore(coreVersion string, originalValue string) (int, int, int, int, bool, error) { + var parts []string + hasRevision := false + revisionPart := "" + + if strings.Contains(coreVersion, ".") { + parts = strings.Split(coreVersion, ".") + if len(parts) != 3 { + return 0, 0, 0, 0, false, fmt.Errorf("invalid semver %q: expected major.minor.patch", originalValue) + } + + patchPart := parts[2] + if patchCore, patchRevision, ok := strings.Cut(patchPart, "_"); ok { + parts[2] = patchCore + revisionPart = patchRevision + hasRevision = true + } + } else { + parts = strings.Split(coreVersion, "_") + switch len(parts) { + case 3: + case 4: + revisionPart = parts[3] + hasRevision = true + default: + return 0, 0, 0, 0, false, fmt.Errorf("invalid semver %q: expected major.minor.patch", originalValue) + } + } + + major, err := parseSemverPart(parts[0], "major") + if err != nil { + return 0, 0, 0, 0, false, err + } + + minor, err := parseSemverPart(parts[1], "minor") + if err != nil { + return 0, 0, 0, 0, false, err + } + + patch, err := parseSemverPart(parts[2], "patch") + if err != nil { + return 0, 0, 0, 0, false, err + } + + revision := 0 + if hasRevision { + revision, err = parseSemverPart(revisionPart, "revision") + if err != nil { + return 0, 0, 0, 0, false, err + } + } + + return major, minor, patch, revision, hasRevision, nil +} + +func parseSemverPart(value string, name string) (int, error) { + if value == "" { + return 0, fmt.Errorf("invalid semver %s: empty", name) + } + if len(value) > 1 && value[0] == '0' { + return 0, fmt.Errorf("invalid semver %s %q: leading zero", name, value) + } + + parsed, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("invalid semver %s %q: %w", name, value, err) + } + if parsed < 0 { + return 0, fmt.Errorf("invalid semver %s %q: must be non-negative", name, value) + } + + return parsed, nil +} + +func parseSemverIdentifiers(value string, label string, rejectLeadingZeroNumbers bool) ([]string, error) { + if value == "" { + return nil, fmt.Errorf("invalid semver %s: empty", label) + } + + parts := strings.Split(value, ".") + identifiers := make([]string, 0, len(parts)) + for _, part := range parts { + if part == "" { + return nil, fmt.Errorf("invalid semver %s: empty identifier", label) + } + for _, r := range part { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' { + return nil, fmt.Errorf("invalid semver %s %q: contains %q", label, part, r) + } + } + if rejectLeadingZeroNumbers && isNumericIdentifier(part) && len(part) > 1 && part[0] == '0' { + return nil, fmt.Errorf("invalid semver %s %q: leading zero", label, part) + } + identifiers = append(identifiers, part) + } + + return identifiers, nil +} + +func (s Semver) Compare(other Semver) int { + if s.Major != other.Major { + return compareInts(s.Major, other.Major) + } + if s.Minor != other.Minor { + return compareInts(s.Minor, other.Minor) + } + if s.Patch != other.Patch { + return compareInts(s.Patch, other.Patch) + } + + preReleaseComparison := comparePreRelease(s.PreRelease, other.PreRelease) + if preReleaseComparison != 0 { + return preReleaseComparison + } + + return compareRevision(s, other) +} + +func (s Semver) LessThan(other Semver) bool { + return s.Compare(other) < 0 +} + +func (s Semver) Equal(other Semver) bool { + return s.Compare(other) == 0 +} + +func (s Semver) ChangeType(other Semver) SemverChange { + comparison := s.Compare(other) + switch { + case comparison > 0: + return SemverChangeDowngrade + case comparison == 0: + return SemverChangeNone + case s.Major != other.Major: + return SemverChangeMajor + case s.Minor != other.Minor: + return SemverChangeMinor + case s.Patch != other.Patch: + return SemverChangePatch + case s.HasRevision != other.HasRevision || s.Revision != other.Revision: + return SemverChangeRevision + default: + return SemverChangePrerelease + } +} + +func (s Semver) IsPatchUpdate(other Semver) bool { + return s.ChangeType(other) == SemverChangePatch +} + +func (s Semver) IsMinorUpdate(other Semver) bool { + return s.ChangeType(other) == SemverChangeMinor +} + +func (s Semver) IsMajorUpdate(other Semver) bool { + return s.ChangeType(other) == SemverChangeMajor +} + +func (s Semver) IsRevisionUpdate(other Semver) bool { + return s.ChangeType(other) == SemverChangeRevision +} + +func (s Semver) String() string { + builder := strings.Builder{} + builder.WriteString(fmt.Sprintf("%d.%d.%d", s.Major, s.Minor, s.Patch)) + if s.HasRevision { + builder.WriteByte('_') + builder.WriteString(strconv.Itoa(s.Revision)) + } + if len(s.PreRelease) > 0 { + builder.WriteByte('-') + builder.WriteString(strings.Join(s.PreRelease, ".")) + } + if len(s.BuildMetadata) > 0 { + builder.WriteByte('+') + builder.WriteString(strings.Join(s.BuildMetadata, ".")) + } + return builder.String() +} + +func CompareSemver(left string, right string) (int, error) { + leftSemver, err := ParseSemver(left) + if err != nil { + return 0, err + } + + rightSemver, err := ParseSemver(right) + if err != nil { + return 0, err + } + + return leftSemver.Compare(rightSemver), nil +} + +func CompareSemverChange(left string, right string) (SemverChange, error) { + leftSemver, err := ParseSemver(left) + if err != nil { + return SemverChangeInvalid, err + } + + rightSemver, err := ParseSemver(right) + if err != nil { + return SemverChangeInvalid, err + } + + return leftSemver.ChangeType(rightSemver), nil +} + +func IsPatchSemverUpdate(left string, right string) (bool, error) { + return isSemverUpdateType(left, right, SemverChangePatch) +} + +func IsMinorSemverUpdate(left string, right string) (bool, error) { + return isSemverUpdateType(left, right, SemverChangeMinor) +} + +func IsMajorSemverUpdate(left string, right string) (bool, error) { + return isSemverUpdateType(left, right, SemverChangeMajor) +} + +func isSemverUpdateType(left string, right string, changeType SemverChange) (bool, error) { + actualChangeType, err := CompareSemverChange(left, right) + if err != nil { + return false, err + } + return actualChangeType == changeType, nil +} + +func compareInts(left int, right int) int { + switch { + case left < right: + return -1 + case left > right: + return 1 + default: + return 0 + } +} + +func comparePreRelease(left []string, right []string) int { + if len(left) == 0 && len(right) == 0 { + return 0 + } + if len(left) == 0 { + return 1 + } + if len(right) == 0 { + return -1 + } + + maxLen := len(left) + if len(right) > maxLen { + maxLen = len(right) + } + + for i := 0; i < maxLen; i++ { + if i >= len(left) { + return -1 + } + if i >= len(right) { + return 1 + } + + cmp := compareIdentifier(left[i], right[i]) + if cmp != 0 { + return cmp + } + } + + return 0 +} + +func compareRevision(left Semver, right Semver) int { + if !left.HasRevision && !right.HasRevision { + return 0 + } + if !left.HasRevision { + return -1 + } + if !right.HasRevision { + return 1 + } + + return compareInts(left.Revision, right.Revision) +} + +func compareIdentifier(left string, right string) int { + leftNumeric := isNumericIdentifier(left) + rightNumeric := isNumericIdentifier(right) + + if leftNumeric && rightNumeric { + leftNumber, _ := strconv.Atoi(left) + rightNumber, _ := strconv.Atoi(right) + return compareInts(leftNumber, rightNumber) + } + if leftNumeric { + return -1 + } + if rightNumeric { + return 1 + } + + return compareInts(strings.Compare(left, right), 0) +} + +func isNumericIdentifier(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if !unicode.IsDigit(r) { + return false + } + } + return true +} diff --git a/semver_test.go b/semver_test.go new file mode 100644 index 0000000..95d2cdd --- /dev/null +++ b/semver_test.go @@ -0,0 +1,163 @@ +package main + +import "testing" + +func TestParseSemver(t *testing.T) { + t.Run("parses plain prefixed prerelease and underscore variants", func(t *testing.T) { + tests := map[string]Semver{ + "1.2.3": {Major: 1, Minor: 2, Patch: 3}, + "v4.5.6": {Major: 4, Minor: 5, Patch: 6}, + " 7.8.9 ": {Major: 7, Minor: 8, Patch: 9}, + "1_2_3": {Major: 1, Minor: 2, Patch: 3}, + "1_2_3_1": {Major: 1, Minor: 2, Patch: 3, Revision: 1, HasRevision: true}, + "1.2.3_1": {Major: 1, Minor: 2, Patch: 3, Revision: 1, HasRevision: true}, + "1.2.3-beta.1": {Major: 1, Minor: 2, Patch: 3, PreRelease: []string{"beta", "1"}}, + "1.2.3+build.7": {Major: 1, Minor: 2, Patch: 3, BuildMetadata: []string{"build", "7"}}, + "1.2.3_1-rc.1+sha.abcdef": {Major: 1, Minor: 2, Patch: 3, Revision: 1, HasRevision: true, PreRelease: []string{"rc", "1"}, BuildMetadata: []string{"sha", "abcdef"}}, + } + + for input, want := range tests { + got, err := ParseSemver(input) + if err != nil { + t.Fatalf("ParseSemver(%q) returned error: %v", input, err) + } + if got.String() != want.String() { + t.Fatalf("ParseSemver(%q) = %+v, want %+v", input, got, want) + } + } + }) + + t.Run("rejects invalid versions", func(t *testing.T) { + inputs := []string{"", "1", "1.2", "1.2.3.4", "1.two.3", "1.-2.3", "01.2.3", "1.2.03", "1.2.3-", "1.2.3+", "1.2.3-beta..1", "1.2.3-01", "1.2.3_", "1_2_3_01"} + + for _, input := range inputs { + if _, err := ParseSemver(input); err == nil { + t.Fatalf("ParseSemver(%q) expected error, got nil", input) + } + } + }) +} + +func TestSemverCompare(t *testing.T) { + tests := []struct { + name string + left Semver + right Semver + want int + }{ + {name: "major", left: Semver{Major: 2, Minor: 0, Patch: 0}, right: Semver{Major: 1, Minor: 9, Patch: 9}, want: 1}, + {name: "minor", left: Semver{Major: 1, Minor: 2, Patch: 0}, right: Semver{Major: 1, Minor: 3, Patch: 0}, want: -1}, + {name: "patch", left: Semver{Major: 1, Minor: 2, Patch: 3}, right: Semver{Major: 1, Minor: 2, Patch: 2}, want: 1}, + {name: "release greater than prerelease", left: Semver{Major: 1, Minor: 2, Patch: 3}, right: Semver{Major: 1, Minor: 2, Patch: 3, PreRelease: []string{"rc", "1"}}, want: 1}, + {name: "revision greater than no revision", left: Semver{Major: 1, Minor: 2, Patch: 3, Revision: 1, HasRevision: true}, right: Semver{Major: 1, Minor: 2, Patch: 3}, want: 1}, + {name: "revision numeric", left: Semver{Major: 1, Minor: 2, Patch: 3, Revision: 1, HasRevision: true}, right: Semver{Major: 1, Minor: 2, Patch: 3, Revision: 2, HasRevision: true}, want: -1}, + {name: "equal ignores build metadata", left: Semver{Major: 1, Minor: 2, Patch: 3, BuildMetadata: []string{"a"}}, right: Semver{Major: 1, Minor: 2, Patch: 3, BuildMetadata: []string{"b"}}, want: 0}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := test.left.Compare(test.right) + if got != test.want { + t.Fatalf("Compare() = %d, want %d", got, test.want) + } + }) + } +} + +func TestSemverHelpers(t *testing.T) { + left := Semver{Major: 1, Minor: 2, Patch: 3} + right := Semver{Major: 1, Minor: 2, Patch: 4} + + if !left.LessThan(right) { + t.Fatal("expected LessThan to report true") + } + if left.Equal(right) { + t.Fatal("expected Equal to report false") + } + if !left.Equal(Semver{Major: 1, Minor: 2, Patch: 3}) { + t.Fatal("expected Equal to report true") + } + if got := (Semver{Major: 1, Minor: 2, Patch: 3, Revision: 1, HasRevision: true, PreRelease: []string{"beta", "1"}, BuildMetadata: []string{"build", "7"}}).String(); got != "1.2.3_1-beta.1+build.7" { + t.Fatalf("String() = %q, want %q", got, "1.2.3_1-beta.1+build.7") + } + if !left.IsPatchUpdate(right) { + t.Fatal("expected IsPatchUpdate to report true") + } + if left.IsMinorUpdate(right) { + t.Fatal("expected IsMinorUpdate to report false") + } + if left.IsMajorUpdate(right) { + t.Fatal("expected IsMajorUpdate to report false") + } + if !(Semver{Major: 1, Minor: 2, Patch: 3}).IsRevisionUpdate(Semver{Major: 1, Minor: 2, Patch: 3, Revision: 1, HasRevision: true}) { + t.Fatal("expected IsRevisionUpdate to report true") + } +} + +func TestCompareSemver(t *testing.T) { + got, err := CompareSemver("1.10.0", "1.2.9") + if err != nil { + t.Fatalf("CompareSemver returned error: %v", err) + } + if got != 1 { + t.Fatalf("CompareSemver() = %d, want 1", got) + } + + underscoreCompare, err := CompareSemver("1.2.3_1", "1.2.4") + if err != nil { + t.Fatalf("CompareSemver underscore returned error: %v", err) + } + if underscoreCompare != -1 { + t.Fatalf("CompareSemver underscore = %d, want -1", underscoreCompare) + } + + if _, err := CompareSemver("bad", "1.0.0"); err == nil { + t.Fatal("expected invalid left semver to return an error") + } +} + +func TestCompareSemverChange(t *testing.T) { + tests := []struct { + name string + left string + right string + want SemverChange + }{ + {name: "none", left: "1.2.3", right: "1.2.3", want: SemverChangeNone}, + {name: "patch", left: "1.2.3", right: "1.2.4", want: SemverChangePatch}, + {name: "minor", left: "1.2.3", right: "1.3.0", want: SemverChangeMinor}, + {name: "major", left: "1.2.3", right: "2.0.0", want: SemverChangeMajor}, + {name: "prerelease", left: "1.2.3-rc.1", right: "1.2.3", want: SemverChangePrerelease}, + {name: "revision", left: "1.2.3", right: "1.2.3_1", want: SemverChangeRevision}, + {name: "downgrade", left: "2.0.0", right: "1.9.9", want: SemverChangeDowngrade}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := CompareSemverChange(test.left, test.right) + if err != nil { + t.Fatalf("CompareSemverChange returned error: %v", err) + } + if got != test.want { + t.Fatalf("CompareSemverChange() = %q, want %q", got, test.want) + } + }) + } +} + +func TestStringUpdateHelpers(t *testing.T) { + isPatch, err := IsPatchSemverUpdate("1.2.3", "1.2.4") + if err != nil || !isPatch { + t.Fatalf("expected patch helper to return true, got %v, %v", isPatch, err) + } + + isMinor, err := IsMinorSemverUpdate("1.2.3", "1.3.0") + if err != nil || !isMinor { + t.Fatalf("expected minor helper to return true, got %v, %v", isMinor, err) + } + + isMajor, err := IsMajorSemverUpdate("1.2.3", "2.0.0") + if err != nil || !isMajor { + t.Fatalf("expected major helper to return true, got %v, %v", isMajor, err) + } +}