diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5f27a26 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,6 @@ +version: "2" + +linters: + enable: + - gocritic + - godot diff --git a/pkg/commit/normalizer.go b/pkg/commit/normalizer.go new file mode 100644 index 0000000..3930124 --- /dev/null +++ b/pkg/commit/normalizer.go @@ -0,0 +1,325 @@ +package commit + +import ( + "fmt" + "regexp" + "strings" + "unicode" +) + +// CommitType represents valid conventional commit types. +type CommitType string + +// Supported CommitType values following the Conventional Commits specification. +// Use these constants when constructing a CommitMessage programmatically to +// avoid typos and ensure only valid types are used. +const ( + TypeFeat CommitType = "feat" + TypeFix CommitType = "fix" + TypeDocs CommitType = "docs" + TypeStyle CommitType = "style" + TypeRefactor CommitType = "refactor" + TypePerf CommitType = "perf" + TypeTest CommitType = "test" + TypeBuild CommitType = "build" + TypeCI CommitType = "ci" + TypeChore CommitType = "chore" + TypeRevert CommitType = "revert" +) + +const ( + // MaxSubjectLength is the maximum length for a commit subject line. + MaxSubjectLength = 72 + // MaxBodyLineLength is the maximum length for body lines. + MaxBodyLineLength = 100 +) + +var ( + // validTypes contains all valid commit types. + validTypes = map[string]CommitType{ + "feat": TypeFeat, + "fix": TypeFix, + "docs": TypeDocs, + "style": TypeStyle, + "refactor": TypeRefactor, + "perf": TypePerf, + "test": TypeTest, + "build": TypeBuild, + "ci": TypeCI, + "chore": TypeChore, + "revert": TypeRevert, + } + + // commitPattern matches conventional commit format: type(scope): subject + // Also supports breaking change indicator with ! + commitPattern = regexp.MustCompile(`^([a-z]+)(\(([^)]+)\))?!?: (.+)$`) +) + +// CommitMessage represents a parsed commit message. +type CommitMessage struct { + // Type is the conventional commit type (e.g. feat, fix, docs). + Type CommitType + // Scope is the optional component or area the commit affects, e.g. "api" or "auth". + Scope string + // Subject is the short imperative description of the change. + Subject string + // Body contains the optional detailed explanation of the change, separated + // from the subject by a blank line. + Body string + // Footer contains optional metadata tokens such as "BREAKING CHANGE:", + // "Fixes:", or "Closes:". + Footer string + // BreakingChange indicates that this commit introduces a breaking API change, + // either via a "!" in the subject or a "BREAKING CHANGE:" footer token. + BreakingChange bool +} + +// ValidationError represents a commit message validation error. +type ValidationError struct { + // Field is the name of the commit field that failed validation (e.g. "type", "subject"). + Field string + // Message describes why the field value is invalid. + Message string +} + +// Error implements the error interface and returns a human-readable description +// of the validation failure in the form "field: message". +func (e *ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Field, e.Message) +} + +// Parse parses a commit message string into a CommitMessage struct. +func Parse(message string) (*CommitMessage, error) { + lines := strings.Split(message, "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { + return nil, &ValidationError{Field: "message", Message: "commit message cannot be empty"} + } + + subject := strings.TrimSpace(lines[0]) + matches := commitPattern.FindStringSubmatch(subject) + + if matches == nil { + return nil, &ValidationError{ + Field: "subject", + Message: "subject must follow format: type(scope): description", + } + } + + typeStr := matches[1] + scope := matches[3] + description := matches[4] + + // Validate type + commitType, valid := validTypes[typeStr] + if !valid { + return nil, &ValidationError{ + Field: "type", + Message: fmt.Sprintf("invalid type '%s', must be one of: %s", typeStr, getValidTypesString()), + } + } + + // Parse body and footer + var body, footer string + var breakingChange bool + + if len(lines) > 1 { + // Skip the blank line after subject if present + bodyStart := 1 + if bodyStart < len(lines) && strings.TrimSpace(lines[bodyStart]) == "" { + bodyStart = 2 + } + + // Find footer start (lines starting with BREAKING CHANGE: or other tokens) + footerStart := -1 + for i := bodyStart; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + if strings.HasPrefix(line, "BREAKING CHANGE:") || + strings.HasPrefix(line, "Fixes:") || + strings.HasPrefix(line, "Closes:") || + strings.HasPrefix(line, "Refs:") { + footerStart = i + break + } + } + + switch { + case footerStart > bodyStart: + body = strings.Join(lines[bodyStart:footerStart], "\n") + footer = strings.Join(lines[footerStart:], "\n") + case footerStart == bodyStart: + footer = strings.Join(lines[footerStart:], "\n") + case bodyStart < len(lines): + body = strings.Join(lines[bodyStart:], "\n") + } + + body = strings.TrimSpace(body) + footer = strings.TrimSpace(footer) + + if strings.Contains(footer, "BREAKING CHANGE:") || strings.Contains(subject, "!") { + breakingChange = true + } + } + + return &CommitMessage{ + Type: commitType, + Scope: scope, + Subject: description, + Body: body, + Footer: footer, + BreakingChange: breakingChange, + }, nil +} + +// Normalize takes a commit message and returns a normalized version +// following conventional commits format. +func Normalize(message string) (string, error) { + cm, err := Parse(message) + if err != nil { + return "", err + } + + return cm.Format(), nil +} + +// Format formats the CommitMessage into a conventional commit string. +func (cm *CommitMessage) Format() string { + var parts []string + + // Format subject line + subject := formatSubject(cm.Type, cm.Scope, cm.Subject) + parts = append(parts, subject) + + // Add blank line before body if body exists + if cm.Body != "" { + parts = append(parts, "") + parts = append(parts, wrapText(cm.Body, MaxBodyLineLength)) + } + + // Add blank line before footer if footer exists + if cm.Footer != "" { + parts = append(parts, "") + parts = append(parts, cm.Footer) + } + + return strings.Join(parts, "\n") +} + +// Validate validates the commit message against conventional commits rules. +func (cm *CommitMessage) Validate() error { + // Validate type + if _, valid := validTypes[string(cm.Type)]; !valid { + return &ValidationError{ + Field: "type", + Message: fmt.Sprintf("invalid type '%s'", cm.Type), + } + } + + // Validate subject + if cm.Subject == "" { + return &ValidationError{Field: "subject", Message: "subject cannot be empty"} + } + + // Check subject capitalization (should not start with capital letter) + if unicode.IsUpper(rune(cm.Subject[0])) { + return &ValidationError{ + Field: "subject", + Message: "subject should start with lowercase letter", + } + } + + // Check subject doesn't end with period + if strings.HasSuffix(cm.Subject, ".") { + return &ValidationError{ + Field: "subject", + Message: "subject should not end with period", + } + } + + // Check subject length + subjectLine := formatSubject(cm.Type, cm.Scope, cm.Subject) + if len(subjectLine) > MaxSubjectLength { + return &ValidationError{ + Field: "subject", + Message: fmt.Sprintf("subject line too long (%d > %d)", len(subjectLine), MaxSubjectLength), + } + } + + return nil +} + +// formatSubject formats the subject line with type and optional scope. +func formatSubject(commitType CommitType, scope, subject string) string { + if scope != "" { + return fmt.Sprintf("%s(%s): %s", commitType, scope, subject) + } + return fmt.Sprintf("%s: %s", commitType, subject) +} + +// wrapText wraps text to the specified line length. +func wrapText(text string, maxLength int) string { + if text == "" { + return text + } + + var result []string + paragraphs := strings.Split(text, "\n\n") + + for i, paragraph := range paragraphs { + paragraph = strings.TrimSpace(paragraph) + if paragraph == "" { + continue + } + + // Handle already-formatted lines (like lists) + if strings.HasPrefix(paragraph, "- ") || strings.HasPrefix(paragraph, "* ") { + result = append(result, paragraph) + if i < len(paragraphs)-1 { + result = append(result, "") + } + continue + } + + words := strings.Fields(paragraph) + if len(words) == 0 { + continue + } + + var line string + for _, word := range words { + switch { + case line == "": + line = word + case len(line)+1+len(word) <= maxLength: + line = line + " " + word + default: + result = append(result, line) + line = word + } + } + if line != "" { + result = append(result, line) + } + + // Add blank line between paragraphs + if i < len(paragraphs)-1 { + result = append(result, "") + } + } + + return strings.Join(result, "\n") +} + +// getValidTypesString returns a comma-separated list of valid types. +func getValidTypesString() string { + types := make([]string, 0, len(validTypes)) + for t := range validTypes { + types = append(types, t) + } + return strings.Join(types, ", ") +} + +// IsValidType checks if a string is a valid commit type. +func IsValidType(t string) bool { + _, valid := validTypes[t] + return valid +} diff --git a/pkg/commit/normalizer_test.go b/pkg/commit/normalizer_test.go new file mode 100644 index 0000000..0d011fd --- /dev/null +++ b/pkg/commit/normalizer_test.go @@ -0,0 +1,464 @@ +package commit + +import ( + "strings" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + message string + want *CommitMessage + wantErr bool + errContains string + }{ + { + name: "simple feat commit", + message: "feat: add new feature", + want: &CommitMessage{ + Type: TypeFeat, + Subject: "add new feature", + }, + wantErr: false, + }, + { + name: "commit with scope", + message: "fix(api): resolve authentication bug", + want: &CommitMessage{ + Type: TypeFix, + Scope: "api", + Subject: "resolve authentication bug", + }, + wantErr: false, + }, + { + name: "commit with body", + message: "docs: update README\n\nAdd installation instructions and examples.", + want: &CommitMessage{ + Type: TypeDocs, + Subject: "update README", + Body: "Add installation instructions and examples.", + }, + wantErr: false, + }, + { + name: "commit with footer", + message: "fix(auth): patch security vulnerability\n\nFixes: CVE-2023-1234", + want: &CommitMessage{ + Type: TypeFix, + Scope: "auth", + Subject: "patch security vulnerability", + Footer: "Fixes: CVE-2023-1234", + }, + wantErr: false, + }, + { + name: "breaking change with BREAKING CHANGE footer", + message: "feat(api)!: redesign REST endpoints\n\nBREAKING CHANGE: API v1 endpoints removed", + want: &CommitMessage{ + Type: TypeFeat, + Scope: "api", + Subject: "redesign REST endpoints", + Footer: "BREAKING CHANGE: API v1 endpoints removed", + BreakingChange: true, + }, + wantErr: false, + }, + { + name: "commit with body and footer", + message: "refactor(core): improve performance\n\nOptimize database queries and caching.\n\nCloses: #123", + want: &CommitMessage{ + Type: TypeRefactor, + Scope: "core", + Subject: "improve performance", + Body: "Optimize database queries and caching.", + Footer: "Closes: #123", + }, + wantErr: false, + }, + { + name: "empty message", + message: "", + wantErr: true, + errContains: "cannot be empty", + }, + { + name: "invalid format - no colon", + message: "feat add feature", + wantErr: true, + errContains: "must follow format", + }, + { + name: "invalid type", + message: "feature: add something", + wantErr: true, + errContains: "invalid type", + }, + { + name: "missing subject", + message: "feat: ", + wantErr: true, + errContains: "must follow format", + }, + { + name: "all valid types", + message: "chore: update dependencies", + want: &CommitMessage{ + Type: TypeChore, + Subject: "update dependencies", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse(tt.message) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + if err == nil { + t.Error("Parse() expected error but got nil") + return + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("Parse() error = %v, should contain %v", err, tt.errContains) + } + return + } + + if got.Type != tt.want.Type { + t.Errorf("Parse() Type = %v, want %v", got.Type, tt.want.Type) + } + if got.Scope != tt.want.Scope { + t.Errorf("Parse() Scope = %v, want %v", got.Scope, tt.want.Scope) + } + if got.Subject != tt.want.Subject { + t.Errorf("Parse() Subject = %v, want %v", got.Subject, tt.want.Subject) + } + if got.Body != tt.want.Body { + t.Errorf("Parse() Body = %v, want %v", got.Body, tt.want.Body) + } + if got.Footer != tt.want.Footer { + t.Errorf("Parse() Footer = %v, want %v", got.Footer, tt.want.Footer) + } + if got.BreakingChange != tt.want.BreakingChange { + t.Errorf("Parse() BreakingChange = %v, want %v", got.BreakingChange, tt.want.BreakingChange) + } + }) + } +} + +func TestNormalize(t *testing.T) { + tests := []struct { + name string + message string + want string + wantErr bool + }{ + { + name: "already normalized", + message: "feat: add feature", + want: "feat: add feature", + wantErr: false, + }, + { + name: "with scope", + message: "fix(api): resolve bug", + want: "fix(api): resolve bug", + wantErr: false, + }, + { + name: "with body preserves formatting", + message: "docs: update guide\n\nAdd new examples.", + want: "docs: update guide\n\nAdd new examples.", + wantErr: false, + }, + { + name: "invalid message", + message: "not a valid commit", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Normalize(tt.message) + if (err != nil) != tt.wantErr { + t.Errorf("Normalize() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != tt.want { + t.Errorf("Normalize() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCommitMessage_Format(t *testing.T) { + tests := []struct { + name string + cm *CommitMessage + want string + }{ + { + name: "simple message", + cm: &CommitMessage{ + Type: TypeFeat, + Subject: "add feature", + }, + want: "feat: add feature", + }, + { + name: "with scope", + cm: &CommitMessage{ + Type: TypeFix, + Scope: "api", + Subject: "fix bug", + }, + want: "fix(api): fix bug", + }, + { + name: "with body", + cm: &CommitMessage{ + Type: TypeDocs, + Subject: "update docs", + Body: "Add examples", + }, + want: "docs: update docs\n\nAdd examples", + }, + { + name: "with footer", + cm: &CommitMessage{ + Type: TypeFix, + Subject: "security patch", + Footer: "Fixes: #123", + }, + want: "fix: security patch\n\nFixes: #123", + }, + { + name: "complete message", + cm: &CommitMessage{ + Type: TypeFeat, + Scope: "core", + Subject: "new capability", + Body: "Detailed description", + Footer: "Closes: #456", + }, + want: "feat(core): new capability\n\nDetailed description\n\nCloses: #456", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cm.Format(); got != tt.want { + t.Errorf("CommitMessage.Format() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCommitMessage_Validate(t *testing.T) { + tests := []struct { + name string + cm *CommitMessage + wantErr bool + errContains string + }{ + { + name: "valid commit", + cm: &CommitMessage{ + Type: TypeFeat, + Subject: "add feature", + }, + wantErr: false, + }, + { + name: "valid with scope", + cm: &CommitMessage{ + Type: TypeFix, + Scope: "api", + Subject: "resolve issue", + }, + wantErr: false, + }, + { + name: "subject starts with capital", + cm: &CommitMessage{ + Type: TypeFeat, + Subject: "Add feature", + }, + wantErr: true, + errContains: "lowercase", + }, + { + name: "subject ends with period", + cm: &CommitMessage{ + Type: TypeFix, + Subject: "fix bug.", + }, + wantErr: true, + errContains: "period", + }, + { + name: "empty subject", + cm: &CommitMessage{ + Type: TypeDocs, + Subject: "", + }, + wantErr: true, + errContains: "cannot be empty", + }, + { + name: "subject too long", + cm: &CommitMessage{ + Type: TypeFeat, + Subject: strings.Repeat("a", 100), + }, + wantErr: true, + errContains: "too long", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cm.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("CommitMessage.Validate() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr && tt.errContains != "" { + if err == nil { + t.Error("CommitMessage.Validate() expected error but got nil") + return + } + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("CommitMessage.Validate() error = %v, should contain %v", err, tt.errContains) + } + } + }) + } +} + +func TestIsValidType(t *testing.T) { + tests := []struct { + name string + t string + want bool + }{ + {"feat", "feat", true}, + {"fix", "fix", true}, + {"docs", "docs", true}, + {"style", "style", true}, + {"refactor", "refactor", true}, + {"perf", "perf", true}, + {"test", "test", true}, + {"build", "build", true}, + {"ci", "ci", true}, + {"chore", "chore", true}, + {"revert", "revert", true}, + {"invalid", "feature", false}, + {"empty", "", false}, + {"uppercase", "FEAT", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsValidType(tt.t); got != tt.want { + t.Errorf("IsValidType() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestWrapText(t *testing.T) { + tests := []struct { + name string + text string + maxLength int + want string + }{ + { + name: "short text", + text: "short", + maxLength: 100, + want: "short", + }, + { + name: "text needs wrapping", + text: "This is a very long line that needs to be wrapped at a reasonable length", + maxLength: 30, + want: "This is a very long line that\nneeds to be wrapped at a\nreasonable length", + }, + { + name: "preserve paragraphs", + text: "First paragraph.\n\nSecond paragraph.", + maxLength: 100, + want: "First paragraph.\n\nSecond paragraph.", + }, + { + name: "preserve lists", + text: "- Item 1\n- Item 2", + maxLength: 100, + want: "- Item 1\n- Item 2", + }, + { + name: "empty text", + text: "", + maxLength: 100, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := wrapText(tt.text, tt.maxLength); got != tt.want { + t.Errorf("wrapText() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFormatSubject(t *testing.T) { + tests := []struct { + name string + commitType CommitType + scope string + subject string + want string + }{ + { + name: "no scope", + commitType: TypeFeat, + scope: "", + subject: "add feature", + want: "feat: add feature", + }, + { + name: "with scope", + commitType: TypeFix, + scope: "api", + subject: "fix bug", + want: "fix(api): fix bug", + }, + { + name: "docs type", + commitType: TypeDocs, + scope: "readme", + subject: "update instructions", + want: "docs(readme): update instructions", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatSubject(tt.commitType, tt.scope, tt.subject); got != tt.want { + t.Errorf("formatSubject() = %v, want %v", got, tt.want) + } + }) + } +}