diff --git a/pkg/analyzer/commit.go b/pkg/analyzer/commit.go
new file mode 100644
index 0000000000000000000000000000000000000000..7a070ceff63c0c491a06182010411df34fae15dc
--- /dev/null
+++ b/pkg/analyzer/commit.go
@@ -0,0 +1,25 @@
+package analyzer
+
+import "strings"
+
+type parsedCommit struct {
+	Type     string
+	Scope    string
+	Modifier string
+	Message  string
+}
+
+func parseCommit(msg string) *parsedCommit {
+	found := commitPattern.FindAllStringSubmatch(msg, -1)
+	if len(found) < 1 {
+		// commit message does not match pattern
+		return nil
+	}
+
+	return &parsedCommit{
+		Type:     strings.ToLower(found[0][1]),
+		Scope:    found[0][2],
+		Modifier: found[0][3],
+		Message:  found[0][4],
+	}
+}
diff --git a/pkg/analyzer/commit_analyzer.go b/pkg/analyzer/commit_analyzer.go
index 7b1779892695e077a8d55334d061a7030c613b72..400dd5309c744f64a3ea1fe1a476707d2815c67d 100644
--- a/pkg/analyzer/commit_analyzer.go
+++ b/pkg/analyzer/commit_analyzer.go
@@ -1,33 +1,29 @@
 package analyzer
 
 import (
-	"regexp"
 	"strings"
 
 	"github.com/go-semantic-release/semantic-release/v2/pkg/semrel"
 )
 
-var (
-	CAVERSION              = "dev"
-	commitPattern          = regexp.MustCompile(`^([^\s\(\!]+)(?:\(([^\)]*)\))?(\!)?\: (.*)$`)
-	breakingPattern        = regexp.MustCompile("BREAKING CHANGES?")
-	mentionedIssuesPattern = regexp.MustCompile(`#(\d+)`)
-	mentionedUsersPattern  = regexp.MustCompile(`(?i)@([a-z\d]([a-z\d]|-[a-z\d])+)`)
-)
+var CAVERSION = "dev"
 
-func extractMentions(re *regexp.Regexp, s string) string {
-	ret := make([]string, 0)
-	for _, m := range re.FindAllStringSubmatch(s, -1) {
-		ret = append(ret, m[1])
-	}
-	return strings.Join(ret, ",")
+type DefaultCommitAnalyzer struct{}
+
+func (da *DefaultCommitAnalyzer) Init(m map[string]string) error {
+	// TODO: implement config parsing
+	return nil
+}
+
+func (da *DefaultCommitAnalyzer) Name() string {
+	return "default"
 }
 
-func matchesBreakingPattern(c *semrel.Commit) bool {
-	return breakingPattern.MatchString(strings.Join(c.Raw, "\n"))
+func (da *DefaultCommitAnalyzer) Version() string {
+	return CAVERSION
 }
 
-func setTypeAndChange(c *semrel.Commit) {
+func (da *DefaultCommitAnalyzer) setTypeAndChange(c *semrel.Commit) {
 	found := commitPattern.FindAllStringSubmatch(c.Raw[0], -1)
 	if len(found) < 1 {
 		// commit message does not match pattern
@@ -46,20 +42,6 @@ func setTypeAndChange(c *semrel.Commit) {
 	}
 }
 
-type DefaultCommitAnalyzer struct{}
-
-func (da *DefaultCommitAnalyzer) Init(_ map[string]string) error {
-	return nil
-}
-
-func (da *DefaultCommitAnalyzer) Name() string {
-	return "default"
-}
-
-func (da *DefaultCommitAnalyzer) Version() string {
-	return CAVERSION
-}
-
 func (da *DefaultCommitAnalyzer) analyzeSingleCommit(rawCommit *semrel.RawCommit) *semrel.Commit {
 	c := &semrel.Commit{
 		SHA:         rawCommit.SHA,
@@ -70,7 +52,7 @@ func (da *DefaultCommitAnalyzer) analyzeSingleCommit(rawCommit *semrel.RawCommit
 	c.Annotations["mentioned_issues"] = extractMentions(mentionedIssuesPattern, rawCommit.RawMessage)
 	c.Annotations["mentioned_users"] = extractMentions(mentionedUsersPattern, rawCommit.RawMessage)
 
-	setTypeAndChange(c)
+	da.setTypeAndChange(c)
 	return c
 }
 
diff --git a/pkg/analyzer/commit_analyzer_test.go b/pkg/analyzer/commit_analyzer_test.go
index d397fee7b52bb5bc0c3e4c6525374e2008a84f10..0173bc32f64bd52ea34eafc5830e0fba4caffd1c 100644
--- a/pkg/analyzer/commit_analyzer_test.go
+++ b/pkg/analyzer/commit_analyzer_test.go
@@ -1,7 +1,6 @@
 package analyzer
 
 import (
-	"fmt"
 	"strings"
 	"testing"
 
@@ -101,7 +100,7 @@ func TestDefaultAnalyzer(t *testing.T) {
 
 	defaultAnalyzer := &DefaultCommitAnalyzer{}
 	for _, tc := range testCases {
-		t.Run(fmt.Sprintf("AnalyzeCommitMessage: %s", tc.RawCommit.RawMessage), func(t *testing.T) {
+		t.Run(tc.RawCommit.RawMessage, func(t *testing.T) {
 			analyzedCommit := defaultAnalyzer.analyzeSingleCommit(tc.RawCommit)
 			require.Equal(t, tc.Type, analyzedCommit.Type, "Type")
 			require.Equal(t, tc.Scope, analyzedCommit.Scope, "Scope")
@@ -111,68 +110,3 @@ func TestDefaultAnalyzer(t *testing.T) {
 		})
 	}
 }
-
-func TestCommitPattern(t *testing.T) {
-	testCases := []struct {
-		message string
-		wanted  []string
-	}{
-		{
-			message: "feat: new feature",
-			wanted:  []string{"feat", "", "", "new feature"},
-		},
-		{
-			message: "feat!: new feature",
-			wanted:  []string{"feat", "", "!", "new feature"},
-		},
-		{
-			message: "feat(api): new feature",
-			wanted:  []string{"feat", "api", "", "new feature"},
-		},
-		{
-			message: "feat(api): a(b): c:",
-			wanted:  []string{"feat", "api", "", "a(b): c:"},
-		},
-		{
-			message: "feat(new cool-api): feature",
-			wanted:  []string{"feat", "new cool-api", "", "feature"},
-		},
-		{
-			message: "feat(😅): cool",
-			wanted:  []string{"feat", "😅", "", "cool"},
-		},
-		{
-			message: "this-is-also(valid): cool",
-			wanted:  []string{"this-is-also", "valid", "", "cool"},
-		},
-		{
-			message: "🚀(🦄): emojis!",
-			wanted:  []string{"🚀", "🦄", "", "emojis!"},
-		},
-		// invalid messages
-		{
-			message: "feat (new api): feature",
-			wanted:  nil,
-		},
-		{
-			message: "feat((x)): test",
-			wanted:  nil,
-		},
-		{
-			message: "feat:test",
-			wanted:  nil,
-		},
-	}
-	for _, tc := range testCases {
-		t.Run(fmt.Sprintf("CommitPattern: %s", tc.message), func(t *testing.T) {
-			found := commitPattern.FindAllStringSubmatch(tc.message, -1)
-			if len(tc.wanted) == 0 {
-				require.Len(t, found, 0)
-				return
-			}
-			require.Len(t, found, 1)
-			require.Len(t, found[0], 5)
-			require.Equal(t, tc.wanted, found[0][1:])
-		})
-	}
-}
diff --git a/pkg/analyzer/commit_test.go b/pkg/analyzer/commit_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..bb9814db5af56f24f8aa50fc326630540f9e8bd9
--- /dev/null
+++ b/pkg/analyzer/commit_test.go
@@ -0,0 +1,82 @@
+package analyzer
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseCommit(t *testing.T) {
+	testCases := []struct {
+		message string
+		wanted  *parsedCommit
+	}{
+		{
+			message: "feat: new feature",
+			wanted:  &parsedCommit{"feat", "", "", "new feature"},
+		},
+		{
+			message: "feat!: new feature",
+			wanted:  &parsedCommit{"feat", "", "!", "new feature"},
+		},
+		{
+			message: "feat(api): new feature",
+			wanted:  &parsedCommit{"feat", "api", "", "new feature"},
+		},
+		{
+			message: "feat(api): a(b): c:",
+			wanted:  &parsedCommit{"feat", "api", "", "a(b): c:"},
+		},
+		{
+			message: "feat(new cool-api): feature",
+			wanted:  &parsedCommit{"feat", "new cool-api", "", "feature"},
+		},
+		{
+			message: "feat(😅): cool",
+			wanted:  &parsedCommit{"feat", "😅", "", "cool"},
+		},
+		{
+			message: "this-is-also(valid): cool",
+			wanted:  &parsedCommit{"this-is-also", "valid", "", "cool"},
+		},
+		{
+			message: "feat((x)): test",
+			wanted:  &parsedCommit{"feat", "(x", ")", "test"},
+		},
+		{
+			message: "feat(x)?!: test",
+			wanted:  &parsedCommit{"feat", "x", "?!", "test"},
+		},
+		{
+			message: "feat(x): test",
+			wanted:  &parsedCommit{"feat", "x", "", "test"},
+		},
+		{
+			message: "feat(x): : test",
+			wanted:  &parsedCommit{"feat", "x", "", ": test"},
+		},
+		{
+			message: "feat!: test",
+			wanted:  &parsedCommit{"feat", "", "!", "test"},
+		},
+		// invalid messages
+		{
+			message: "feat (new api): feature",
+			wanted:  nil,
+		},
+		{
+			message: "feat:test",
+			wanted:  nil,
+		},
+		{
+			message: "🚀(🦄): emojis!",
+			wanted:  nil,
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.message, func(t *testing.T) {
+			c := parseCommit(tc.message)
+			require.Equal(t, tc.wanted, c)
+		})
+	}
+}
diff --git a/pkg/analyzer/patterns.go b/pkg/analyzer/patterns.go
new file mode 100644
index 0000000000000000000000000000000000000000..60174f2671be5960ffa7a49891152f342781de2d
--- /dev/null
+++ b/pkg/analyzer/patterns.go
@@ -0,0 +1,28 @@
+package analyzer
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/go-semantic-release/semantic-release/v2/pkg/semrel"
+)
+
+var (
+	releaseRulePattern     = regexp.MustCompile(`^([\w-\*]+)(?:\(([^\)]*)\))?(\S*)$`)
+	commitPattern          = regexp.MustCompile(`^([\w-]+)(?:\(([^\)]*)\))?(\S*)\: (.*)$`)
+	breakingPattern        = regexp.MustCompile("BREAKING CHANGES?")
+	mentionedIssuesPattern = regexp.MustCompile(`#(\d+)`)
+	mentionedUsersPattern  = regexp.MustCompile(`(?i)@([a-z\d]([a-z\d]|-[a-z\d])+)`)
+)
+
+func extractMentions(re *regexp.Regexp, s string) string {
+	ret := make([]string, 0)
+	for _, m := range re.FindAllStringSubmatch(s, -1) {
+		ret = append(ret, m[1])
+	}
+	return strings.Join(ret, ",")
+}
+
+func matchesBreakingPattern(c *semrel.Commit) bool {
+	return breakingPattern.MatchString(strings.Join(c.Raw, "\n"))
+}
diff --git a/pkg/analyzer/patterns_test.go b/pkg/analyzer/patterns_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..bcfe23c5e7d59b8121f462832e0a9b4f075bd1fe
--- /dev/null
+++ b/pkg/analyzer/patterns_test.go
@@ -0,0 +1,51 @@
+package analyzer
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestExtractIssues(t *testing.T) {
+	testCases := []struct {
+		message string
+		wanted  string
+	}{
+		{
+			message: "feat: new feature #123",
+			wanted:  "123",
+		},
+		{
+			message: "feat!: new feature closes #123 and #456",
+			wanted:  "123,456",
+		},
+	}
+	for _, testCase := range testCases {
+		t.Run(testCase.message, func(t *testing.T) {
+			issues := extractMentions(mentionedIssuesPattern, testCase.message)
+			require.Equal(t, testCase.wanted, issues)
+		})
+	}
+}
+
+func TestExtractMentions(t *testing.T) {
+	testCases := []struct {
+		message string
+		wanted  string
+	}{
+		{
+			message: "feat: new feature by @user",
+			wanted:  "user",
+		},
+		{
+			message: "feat!: new feature by @user and @user-2",
+			wanted:  "user,user-2",
+		},
+	}
+	for _, testCase := range testCases {
+		t.Run(testCase.message, func(t *testing.T) {
+			issues := extractMentions(mentionedUsersPattern, testCase.message)
+			require.Equal(t, testCase.wanted, issues)
+		})
+	}
+}
diff --git a/pkg/analyzer/rules.go b/pkg/analyzer/rules.go
new file mode 100644
index 0000000000000000000000000000000000000000..bb2ef7aaba984957b7ade182f128f45633b47ba2
--- /dev/null
+++ b/pkg/analyzer/rules.go
@@ -0,0 +1,77 @@
+package analyzer
+
+import (
+	"cmp"
+	"fmt"
+	"strings"
+)
+
+var (
+	defaultMajorReleaseRules = "*(*)!"
+	defaultMinorReleaseRules = "feat"
+	defaultPatchReleaseRules = "fix"
+)
+
+type releaseRule struct {
+	Type     string
+	Scope    string
+	Modifier string
+}
+
+func (r *releaseRule) String() string {
+	return fmt.Sprintf("%s(%s)%s", r.Type, r.Scope, r.Modifier)
+}
+
+func (r *releaseRule) Matches(commit *parsedCommit) bool {
+	return (r.Type == "*" || r.Type == commit.Type) &&
+		(r.Scope == "*" || r.Scope == commit.Scope) &&
+		(r.Modifier == "*" || r.Modifier == commit.Modifier)
+}
+
+func parseRule(rule string) (*releaseRule, error) {
+	foundRule := releaseRulePattern.FindAllStringSubmatch(rule, -1)
+	if len(foundRule) < 1 {
+		return nil, fmt.Errorf("cannot parse rule: %s", rule)
+	}
+	return &releaseRule{
+		Type: strings.ToLower(foundRule[0][1]),
+		// undefined scope defaults to *
+		Scope:    cmp.Or(foundRule[0][2], "*"),
+		Modifier: foundRule[0][3],
+	}, nil
+}
+
+type releaseRules []*releaseRule
+
+func (r releaseRules) String() string {
+	ret := make([]string, len(r))
+	for i, rule := range r {
+		ret[i] = rule.String()
+	}
+	return strings.Join(ret, ",")
+}
+
+func (r releaseRules) Matches(commit *parsedCommit) bool {
+	for _, rule := range r {
+		if rule.Matches(commit) {
+			return true
+		}
+	}
+	return false
+}
+
+func parseRules(rules string) (releaseRules, error) {
+	if rules == "" {
+		return nil, fmt.Errorf("no rules provided")
+	}
+	ruleStrings := strings.Split(rules, ",")
+	ret := make(releaseRules, len(ruleStrings))
+	for i, r := range ruleStrings {
+		parsed, err := parseRule(r)
+		if err != nil {
+			return nil, err
+		}
+		ret[i] = parsed
+	}
+	return ret, nil
+}
diff --git a/pkg/analyzer/rules_test.go b/pkg/analyzer/rules_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..a6ab98e6b6a35f9172c3f544a6722cb0f717d81b
--- /dev/null
+++ b/pkg/analyzer/rules_test.go
@@ -0,0 +1,62 @@
+package analyzer
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseRule(t *testing.T) {
+	testCases := []struct {
+		rule   string
+		wanted *releaseRule
+	}{
+		{
+			rule:   "feat",
+			wanted: &releaseRule{Type: "feat", Scope: "*", Modifier: ""},
+		},
+		{
+			rule:   "feat(api)",
+			wanted: &releaseRule{Type: "feat", Scope: "api", Modifier: ""},
+		},
+		{
+			rule:   "feat(*)!",
+			wanted: &releaseRule{Type: "feat", Scope: "*", Modifier: "!"},
+		},
+		{
+			rule:   "feat(api)!",
+			wanted: &releaseRule{Type: "feat", Scope: "api", Modifier: "!"},
+		},
+		{
+			rule:   "*(*)!",
+			wanted: &releaseRule{Type: "*", Scope: "*", Modifier: "!"},
+		},
+		{
+			rule:   "*(*)*",
+			wanted: &releaseRule{Type: "*", Scope: "*", Modifier: "*"},
+		},
+		{
+			rule:   "*",
+			wanted: &releaseRule{Type: "*", Scope: "*", Modifier: ""},
+		},
+		{
+			rule:   "*!",
+			wanted: &releaseRule{Type: "*", Scope: "*", Modifier: "!"},
+		},
+		{
+			rule:   "x!",
+			wanted: &releaseRule{Type: "x", Scope: "*", Modifier: "!"},
+		},
+		{
+			rule:   "x🦄",
+			wanted: &releaseRule{Type: "x", Scope: "*", Modifier: "🦄"},
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.rule, func(t *testing.T) {
+			r, err := parseRule(tc.rule)
+			require.NoError(t, err)
+			require.Equal(t, tc.wanted, r)
+		})
+	}
+}