Files
semver-generator/cmd/utils/config_test.go
T
lukaszraczylo e90191de4c feat(matching): add an opt-in regex keyword matching mode
Fuzzy matching compares each keyword with every word of the commit
message. A word matches if it contains the keyword's letters in order.
Thus "feat" matches "fix(delegation):" and "major" matches
"deps(majors):", which gives false minor and major versions.

The new top-level config key "matching" accepts "fuzzy" (the default)
or "regex". In regex mode every wording keyword is a Go regular
expression. The tool compiles all of them when it loads the config and
runs them against the full commit message, so "(?m)^" anchors a
pattern to the start of a line. An invalid value, an invalid pattern,
or any other config parse error in regex mode stops the tool with a
non-zero exit code. It never falls back to fuzzy matching.

Fuzzy mode does not change. The blacklist check moves into one helper
that both modes use. A replay of 15 repositories gives the same
versions as before. This change improves version precision for the
repositories that opt in.

Claude-Session: https://claude.ai/code/session_01Qq1gbqQek1Zjtofo4VDRdW
2026-09-25 19:35:48 +01:00

400 lines
10 KiB
Go

package utils
import (
"errors"
"os"
"testing"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplyForcedVersioning(t *testing.T) {
tests := []struct {
name string
force Force
semver SemVer
want SemVer
}{
{
name: "No forced versioning",
force: Force{
Major: 0,
Minor: 0,
Patch: 0,
},
semver: SemVer{
Major: 1,
Minor: 2,
Patch: 3,
},
want: SemVer{
Major: 1,
Minor: 2,
Patch: 3,
},
},
{
name: "Force major version",
force: Force{
Major: 5,
Minor: 0,
Patch: 0,
},
semver: SemVer{
Major: 1,
Minor: 2,
Patch: 3,
},
want: SemVer{
Major: 5,
Minor: 2,
Patch: 3,
},
},
{
name: "Force minor version",
force: Force{
Major: 0,
Minor: 7,
Patch: 0,
},
semver: SemVer{
Major: 1,
Minor: 2,
Patch: 3,
},
want: SemVer{
Major: 1,
Minor: 7,
Patch: 3,
},
},
{
name: "Force patch version",
force: Force{
Major: 0,
Minor: 0,
Patch: 9,
},
semver: SemVer{
Major: 1,
Minor: 2,
Patch: 3,
},
want: SemVer{
Major: 1,
Minor: 2,
Patch: 9,
},
},
{
name: "Force all versions",
force: Force{
Major: 5,
Minor: 7,
Patch: 9,
},
semver: SemVer{
Major: 1,
Minor: 2,
Patch: 3,
},
want: SemVer{
Major: 5,
Minor: 7,
Patch: 9,
},
},
}
// Initialize logger for tests
InitLogger(false)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
semver := tt.semver
ApplyForcedVersioning(tt.force, &semver)
assert.Equal(t, tt.want.Major, semver.Major, "Major version mismatch")
assert.Equal(t, tt.want.Minor, semver.Minor, "Minor version mismatch")
assert.Equal(t, tt.want.Patch, semver.Patch, "Patch version mismatch")
})
}
}
func TestReadConfig(t *testing.T) {
// Create a temporary config file for testing
configContent := `
version: 1
force:
major: 2
minor: 3
patch: 4
commit: abcdef1234567890
existing: true
strict: false
blacklist:
- "Merge branch"
- "Merge pull request"
wording:
patch:
- update
- fix
minor:
- change
- feature
major:
- breaking
release:
- release-candidate
`
tempFile, err := os.CreateTemp("", "semver-config-*.yaml")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer os.Remove(tempFile.Name())
if _, err := tempFile.Write([]byte(configContent)); err != nil {
t.Fatalf("Failed to write to temp file: %v", err)
}
if err := tempFile.Close(); err != nil {
t.Fatalf("Failed to close temp file: %v", err)
}
// Initialize logger for tests
InitLogger(false)
// Test reading the config
config, err := ReadConfig(tempFile.Name())
assert.NoError(t, err)
assert.NotNil(t, config)
// Verify force settings
assert.Equal(t, 2, config.Force.Major)
assert.Equal(t, 3, config.Force.Minor)
assert.Equal(t, 4, config.Force.Patch)
assert.Equal(t, "abcdef1234567890", config.Force.Commit)
assert.True(t, config.Force.Existing)
assert.False(t, config.Force.Strict)
// Verify blacklist
assert.Len(t, config.Blacklist, 2)
assert.Contains(t, config.Blacklist, "Merge branch")
assert.Contains(t, config.Blacklist, "Merge pull request")
// Verify wording
assert.Len(t, config.Wording.Patch, 2)
assert.Contains(t, config.Wording.Patch, "update")
assert.Contains(t, config.Wording.Patch, "fix")
assert.Len(t, config.Wording.Minor, 2)
assert.Contains(t, config.Wording.Minor, "change")
assert.Contains(t, config.Wording.Minor, "feature")
assert.Len(t, config.Wording.Major, 1)
assert.Contains(t, config.Wording.Major, "breaking")
assert.Len(t, config.Wording.Release, 1)
assert.Contains(t, config.Wording.Release, "release-candidate")
// Test reading a non-existent config
_, err = ReadConfig("non-existent-file.yaml")
assert.Error(t, err)
}
func writeTempConfig(t *testing.T, content string) string {
t.Helper()
tempFile, err := os.CreateTemp("", "semver-config-matching-*.yaml")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
if _, err := tempFile.Write([]byte(content)); err != nil {
t.Fatalf("Failed to write to temp file: %v", err)
}
if err := tempFile.Close(); err != nil {
t.Fatalf("Failed to close temp file: %v", err)
}
t.Cleanup(func() {
if err := os.Remove(tempFile.Name()); err != nil && !os.IsNotExist(err) {
t.Logf("failed to remove temp config %s: %v", tempFile.Name(), err)
}
})
return tempFile.Name()
}
func TestReadConfigMatching(t *testing.T) {
InitLogger(false)
t.Run("absent matching key defaults to fuzzy", func(t *testing.T) {
path := writeTempConfig(t, `
wording:
patch:
- feat
`)
config, err := ReadConfig(path)
assert.NoError(t, err)
assert.Equal(t, MatchingFuzzy, config.Matching)
assert.IsType(t, FuzzyMatcher{}, config.Matcher)
// Behavioural check, not just the type: absent/fuzzy mode means
// "fix(delegation): x" fuzzy-matches keyword "feat", using the real
// fuzzy library (the same one main.go wires up), not a stand-in.
originalFuzzyFind := FuzzyFind
t.Cleanup(func() { FuzzyFind = originalFuzzyFind })
FuzzyFind = fuzzy.FindNormalizedFold
assert.True(t, config.Matcher.Matches("fix(delegation): x", config.Wording.Patch, nil))
})
t.Run("explicit fuzzy", func(t *testing.T) {
path := writeTempConfig(t, `
matching: fuzzy
wording:
patch:
- update
`)
config, err := ReadConfig(path)
assert.NoError(t, err)
assert.Equal(t, MatchingFuzzy, config.Matching)
assert.IsType(t, FuzzyMatcher{}, config.Matcher)
// Behavioural check, not just the type: same as the absent-key
// subtest above, using the real fuzzy library.
originalFuzzyFind := FuzzyFind
t.Cleanup(func() { FuzzyFind = originalFuzzyFind })
FuzzyFind = fuzzy.FindNormalizedFold
assert.True(t, config.Matcher.Matches("fix(delegation): x", []string{"feat"}, nil))
})
t.Run("explicit regex compiles keywords", func(t *testing.T) {
path := writeTempConfig(t, `
matching: regex
wording:
patch:
- "^fix:"
minor:
- "(?m)^feat(\\([^)]*\\))?!?:"
major:
- "(?mi)^semver-major:"
`)
config, err := ReadConfig(path)
assert.NoError(t, err)
assert.Equal(t, MatchingRegex, config.Matching)
assert.IsType(t, RegexMatcher{}, config.Matcher)
// Behavioural check: "fix(delegation): x" matches fuzzy keyword "feat"
// (see the absent/fuzzy subtest below) but must NOT match this
// anchored regex pattern.
assert.False(t, config.Matcher.Matches("fix(delegation): x", config.Wording.Minor, nil))
})
t.Run("unknown matching value errors naming value and allowed values", func(t *testing.T) {
path := writeTempConfig(t, `
matching: substring
wording:
patch:
- update
`)
_, err := ReadConfig(path)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
assert.Contains(t, err.Error(), "substring")
assert.Contains(t, err.Error(), MatchingFuzzy)
assert.Contains(t, err.Error(), MatchingRegex)
})
// Fix 1: an invalid "matching" value must be rejected immediately, before
// wording/force/blacklist/tag_prefixes are ever parsed. Previously a
// mistyped value like "substring" was accepted as-is by the case string
// branch, so a malformed wording section below it returned an unwrapped
// parse error instead of ErrInvalidMatchingConfig, and main() silently
// fell back to fuzzy mode instead of failing loudly.
t.Run("invalid matching value combined with malformed wording section still errors as ErrInvalidMatchingConfig", func(t *testing.T) {
path := writeTempConfig(t, `
matching: substring
wording: "not-a-map"
`)
_, err := ReadConfig(path)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
assert.Contains(t, err.Error(), "substring")
})
t.Run("invalid regex errors naming wording list and keyword", func(t *testing.T) {
path := writeTempConfig(t, `
matching: regex
wording:
major:
- "[unterminated("
`)
_, err := ReadConfig(path)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
assert.Contains(t, err.Error(), "major")
assert.Contains(t, err.Error(), "[unterminated(")
})
// Fix 2: no silent fuzzy fallback in regex mode. A malformed "wording"
// section (a scalar instead of a map) fails viper.UnmarshalKey, not
// NewKeywordMatcher. In regex mode that must still be a fatal,
// ErrInvalidMatchingConfig-wrapped error — main() exits non-zero on it
// (see cmd/main.go's errors.Is(err, utils.ErrInvalidMatchingConfig)
// check) instead of silently logging and falling back to fuzzy matching.
t.Run("regex mode + malformed wording section is a fatal error, not a fuzzy fallback", func(t *testing.T) {
path := writeTempConfig(t, `
matching: regex
wording: "not-a-map"
`)
_, err := ReadConfig(path)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig, "main() only treats ErrInvalidMatchingConfig as fatal; anything else falls back to fuzzy and continues")
assert.Contains(t, err.Error(), "wording")
})
// Contrast case: the exact same malformed "wording" section in fuzzy mode
// (the default) must behave exactly as it did before regex mode existed —
// an error is returned, but it is NOT wrapped in ErrInvalidMatchingConfig,
// so main() logs it and continues with defaults rather than exiting.
t.Run("fuzzy mode + malformed wording section stays non-fatal, same as today", func(t *testing.T) {
path := writeTempConfig(t, `
wording: "not-a-map"
`)
_, err := ReadConfig(path)
assert.Error(t, err)
assert.False(t, errors.Is(err, ErrInvalidMatchingConfig), "fuzzy mode must not fail loudly for non-matching config errors")
})
// Fix 4: a non-scalar "matching" value (a YAML list or map) must be an
// ErrInvalidMatchingConfig error naming the value, never a silent fuzzy
// fallback.
t.Run("matching as a YAML list is an error naming the value", func(t *testing.T) {
path := writeTempConfig(t, `
matching:
- fuzzy
- regex
wording:
patch:
- update
`)
_, err := ReadConfig(path)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
assert.Contains(t, err.Error(), "fuzzy")
assert.Contains(t, err.Error(), "regex")
})
t.Run("matching as a YAML map is an error naming the value", func(t *testing.T) {
path := writeTempConfig(t, `
matching:
mode: regex
wording:
patch:
- update
`)
_, err := ReadConfig(path)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
assert.Contains(t, err.Error(), "mode")
})
}