Files
gh-action-readme/internal/wizard/validator.go
Ismo Vuorinen 4f12c4d3dd feat(lint): add many linters, make all the tests run fast! (#23)
* chore(lint): added nlreturn, run linting

* chore(lint): replace some fmt.Sprintf calls

* chore(lint): replace fmt.Sprintf with strconv

* chore(lint): add goconst, use http lib for status codes, and methods

* chore(lint): use errors lib, errCodes from internal/errors

* chore(lint): dupl, thelper and usetesting

* chore(lint): fmt.Errorf %v to %w, more linters

* chore(lint): paralleltest, where possible

* perf(test): optimize test performance by 78%

- Implement shared binary building with package-level cache to eliminate redundant builds
- Add strategic parallelization to 15+ tests while preserving environment variable isolation
- Implement thread-safe fixture caching with RWMutex to reduce I/O operations
- Remove unnecessary working directory changes by leveraging embedded templates
- Add embedded template system with go:embed directive for reliable template resolution
- Fix linting issues: rename sharedBinaryError to errSharedBinary, add nolint directive

Performance improvements:
- Total test execution time: 12+ seconds → 2.7 seconds (78% faster)
- Binary build overhead: 14+ separate builds → 1 shared build (93% reduction)
- Parallel execution: Limited → 15+ concurrent tests (60-70% better CPU usage)
- I/O operations: 66+ fixture reads → cached with sync.RWMutex (50% reduction)

All tests maintain 100% success rate and coverage while running nearly 4x faster.
2025-08-06 15:28:09 +03:00

508 lines
14 KiB
Go

// Package wizard provides configuration validation functionality.
package wizard
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/ivuorinen/gh-action-readme/internal"
)
// ValidationResult represents the result of configuration validation.
type ValidationResult struct {
Valid bool
Errors []ValidationError
Warnings []ValidationWarning
Suggestions []string
}
// ValidationError represents a validation error.
type ValidationError struct {
Field string
Message string
Value string
}
// ValidationWarning represents a validation warning.
type ValidationWarning struct {
Field string
Message string
Value string
}
// ConfigValidator handles configuration validation with immediate feedback.
type ConfigValidator struct {
output *internal.ColoredOutput
}
// NewConfigValidator creates a new configuration validator.
func NewConfigValidator(output *internal.ColoredOutput) *ConfigValidator {
return &ConfigValidator{
output: output,
}
}
// ValidateConfig validates a complete configuration and returns detailed results.
func (v *ConfigValidator) ValidateConfig(config *internal.AppConfig) *ValidationResult {
result := &ValidationResult{
Valid: true,
Errors: []ValidationError{},
Warnings: []ValidationWarning{},
Suggestions: []string{},
}
// Validate each field
v.validateOrganization(config.Organization, result)
v.validateRepository(config.Repository, result)
v.validateVersion(config.Version, result)
v.validateTheme(config.Theme, result)
v.validateOutputFormat(config.OutputFormat, result)
v.validateOutputDir(config.OutputDir, result)
v.validateGitHubToken(config.GitHubToken, result)
v.validatePermissions(config.Permissions, result)
v.validateRunsOn(config.RunsOn, result)
v.validateVariables(config.Variables, result)
// Set overall validity
result.Valid = len(result.Errors) == 0
return result
}
// ValidateField validates a single field and provides immediate feedback.
func (v *ConfigValidator) ValidateField(fieldName, value string) *ValidationResult {
result := &ValidationResult{
Valid: true,
Errors: []ValidationError{},
Warnings: []ValidationWarning{},
Suggestions: []string{},
}
switch fieldName {
case "organization":
v.validateOrganization(value, result)
case "repository":
v.validateRepository(value, result)
case "version":
v.validateVersion(value, result)
case "theme":
v.validateTheme(value, result)
case "output_format":
v.validateOutputFormat(value, result)
case "output_dir":
v.validateOutputDir(value, result)
case "github_token":
v.validateGitHubToken(value, result)
default:
result.Warnings = append(result.Warnings, ValidationWarning{
Field: fieldName,
Message: "Unknown field",
Value: value,
})
}
result.Valid = len(result.Errors) == 0
return result
}
// validateOrganization validates the organization field.
func (v *ConfigValidator) validateOrganization(org string, result *ValidationResult) {
if org == "" {
result.Warnings = append(result.Warnings, ValidationWarning{
Field: "organization",
Message: "Organization is empty - will use auto-detected value",
Value: org,
})
return
}
// GitHub username/organization rules
if !v.isValidGitHubName(org) {
result.Errors = append(result.Errors, ValidationError{
Field: "organization",
Message: "Invalid organization name format",
Value: org,
})
result.Suggestions = append(result.Suggestions,
"Organization names can only contain alphanumeric characters and hyphens")
}
}
// validateRepository validates the repository field.
func (v *ConfigValidator) validateRepository(repo string, result *ValidationResult) {
if repo == "" {
result.Warnings = append(result.Warnings, ValidationWarning{
Field: "repository",
Message: "Repository is empty - will use auto-detected value",
Value: repo,
})
return
}
// GitHub repository name rules
if !v.isValidGitHubName(repo) {
result.Errors = append(result.Errors, ValidationError{
Field: "repository",
Message: "Invalid repository name format",
Value: repo,
})
result.Suggestions = append(result.Suggestions,
"Repository names can only contain alphanumeric characters, hyphens, and underscores")
}
}
// validateVersion validates the version field.
func (v *ConfigValidator) validateVersion(version string, result *ValidationResult) {
if version == "" {
// Empty version is valid
return
}
// Check if it follows semantic versioning
if !v.isValidSemanticVersion(version) {
result.Warnings = append(result.Warnings, ValidationWarning{
Field: "version",
Message: "Version does not follow semantic versioning (x.y.z)",
Value: version,
})
result.Suggestions = append(result.Suggestions,
"Consider using semantic versioning format (e.g., 1.0.0)")
}
}
// validateTheme validates the theme field.
func (v *ConfigValidator) validateTheme(theme string, result *ValidationResult) {
validThemes := []string{"default", "github", "gitlab", "minimal", "professional"}
found := false
for _, validTheme := range validThemes {
if theme == validTheme {
found = true
break
}
}
if !found {
result.Errors = append(result.Errors, ValidationError{
Field: "theme",
Message: "Invalid theme",
Value: theme,
})
result.Suggestions = append(result.Suggestions,
"Valid themes: "+strings.Join(validThemes, ", "))
}
}
// validateOutputFormat validates the output format field.
func (v *ConfigValidator) validateOutputFormat(format string, result *ValidationResult) {
validFormats := []string{"md", "html", "json", "asciidoc"}
found := false
for _, validFormat := range validFormats {
if format == validFormat {
found = true
break
}
}
if !found {
result.Errors = append(result.Errors, ValidationError{
Field: "output_format",
Message: "Invalid output format",
Value: format,
})
result.Suggestions = append(result.Suggestions,
"Valid formats: "+strings.Join(validFormats, ", "))
}
}
// validateOutputDir validates the output directory field.
func (v *ConfigValidator) validateOutputDir(dir string, result *ValidationResult) {
if dir == "" {
result.Errors = append(result.Errors, ValidationError{
Field: "output_dir",
Message: "Output directory cannot be empty",
Value: dir,
})
return
}
// Check if directory exists or can be created
if !filepath.IsAbs(dir) {
// Relative path - check if parent exists
parent := filepath.Dir(dir)
if parent != "." {
if _, err := os.Stat(parent); os.IsNotExist(err) {
result.Warnings = append(result.Warnings, ValidationWarning{
Field: "output_dir",
Message: "Parent directory does not exist",
Value: dir,
})
result.Suggestions = append(result.Suggestions,
"Ensure the parent directory exists or will be created")
}
}
} else {
// Absolute path - check if it exists
if _, err := os.Stat(dir); os.IsNotExist(err) {
result.Warnings = append(result.Warnings, ValidationWarning{
Field: "output_dir",
Message: "Directory does not exist",
Value: dir,
})
result.Suggestions = append(result.Suggestions,
"Directory will be created if it doesn't exist")
}
}
}
// validateGitHubToken validates the GitHub token field.
func (v *ConfigValidator) validateGitHubToken(token string, result *ValidationResult) {
if token == "" {
// Empty token is valid (optional)
return
}
// Check token format
if !v.isValidGitHubToken(token) {
result.Warnings = append(result.Warnings, ValidationWarning{
Field: "github_token",
Message: "Token format looks unusual",
Value: "[REDACTED]",
})
result.Suggestions = append(result.Suggestions,
"GitHub tokens usually start with 'ghp_' or 'github_pat_'")
}
// Security warning
result.Warnings = append(result.Warnings, ValidationWarning{
Field: "github_token",
Message: "Tokens should be stored securely in environment variables",
Value: "[REDACTED]",
})
result.Suggestions = append(result.Suggestions,
"Consider using GITHUB_TOKEN environment variable instead")
}
// validatePermissions validates the permissions field.
func (v *ConfigValidator) validatePermissions(permissions map[string]string, result *ValidationResult) {
if len(permissions) == 0 {
return
}
validPermissions := map[string][]string{
"actions": {"read", "write"},
"checks": {"read", "write"},
"contents": {"read", "write"},
"deployments": {"read", "write"},
"id-token": {"write"},
"issues": {"read", "write"},
"discussions": {"read", "write"},
"packages": {"read", "write"},
"pull-requests": {"read", "write"},
"repository-projects": {"read", "write"},
"security-events": {"read", "write"},
"statuses": {"read", "write"},
}
for permission, value := range permissions {
// Check if permission is valid
validValues, permissionExists := validPermissions[permission]
if !permissionExists {
result.Warnings = append(result.Warnings, ValidationWarning{
Field: "permissions",
Message: "Unknown permission: " + permission,
Value: value,
})
continue
}
// Check if value is valid
validValue := false
for _, validVal := range validValues {
if value == validVal {
validValue = true
break
}
}
if !validValue {
result.Errors = append(result.Errors, ValidationError{
Field: "permissions",
Message: "Invalid value for permission " + permission,
Value: value,
})
result.Suggestions = append(result.Suggestions,
fmt.Sprintf("Valid values for %s: %s", permission, strings.Join(validValues, ", ")))
}
}
}
// validateRunsOn validates the runs-on field.
func (v *ConfigValidator) validateRunsOn(runsOn []string, result *ValidationResult) {
if len(runsOn) == 0 {
result.Warnings = append(result.Warnings, ValidationWarning{
Field: "runs_on",
Message: "No runners specified",
Value: "[]",
})
result.Suggestions = append(result.Suggestions,
"Consider specifying at least one runner (e.g., ubuntu-latest)")
return
}
validRunners := []string{
"ubuntu-latest", "ubuntu-22.04", "ubuntu-20.04",
"windows-latest", "windows-2022", "windows-2019",
"macos-latest", "macos-13", "macos-12", "macos-11",
}
for _, runner := range runsOn {
// Check if it's a GitHub-hosted runner
isValid := false
for _, validRunner := range validRunners {
if runner == validRunner {
isValid = true
break
}
}
// If not a standard runner, it might be self-hosted
if !isValid {
if !strings.HasPrefix(runner, "self-hosted") {
result.Warnings = append(result.Warnings, ValidationWarning{
Field: "runs_on",
Message: "Unknown runner: " + runner,
Value: runner,
})
result.Suggestions = append(result.Suggestions,
"Ensure the runner is available in your GitHub organization")
}
}
}
}
// validateVariables validates custom variables.
func (v *ConfigValidator) validateVariables(variables map[string]string, result *ValidationResult) {
if len(variables) == 0 {
return
}
for key, value := range variables {
// Check for reserved variable names
reservedNames := []string{"GITHUB_TOKEN", "GITHUB_ACTOR", "GITHUB_REPOSITORY", "GITHUB_SHA"}
for _, reserved := range reservedNames {
if strings.EqualFold(key, reserved) {
result.Warnings = append(result.Warnings, ValidationWarning{
Field: "variables",
Message: "Variable name conflicts with GitHub environment variable: " + key,
Value: value,
})
break
}
}
// Check for valid variable name format
if !v.isValidVariableName(key) {
result.Errors = append(result.Errors, ValidationError{
Field: "variables",
Message: "Invalid variable name: " + key,
Value: value,
})
result.Suggestions = append(result.Suggestions,
"Variable names should contain only letters, numbers, and underscores")
}
}
}
// isValidGitHubName checks if a name follows GitHub naming rules.
func (v *ConfigValidator) isValidGitHubName(name string) bool {
if len(name) == 0 || len(name) > 39 {
return false
}
// GitHub names can contain alphanumeric characters and hyphens
// Cannot start or end with hyphen
matched, _ := regexp.MatchString(`^[a-zA-Z0-9]([a-zA-Z0-9\-_]*[a-zA-Z0-9])?$`, name)
return matched
}
// isValidSemanticVersion checks if a version follows semantic versioning.
func (v *ConfigValidator) isValidSemanticVersion(version string) bool {
// Basic semantic version pattern: x.y.z with optional pre-release and build metadata
pattern := `^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)` +
`(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?` +
`(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`
matched, _ := regexp.MatchString(pattern, version)
return matched
}
// isValidGitHubToken checks if a token follows GitHub token format.
func (v *ConfigValidator) isValidGitHubToken(token string) bool {
// GitHub personal access tokens start with ghp_ or github_pat_
// Classic tokens are 40 characters after the prefix
// Fine-grained tokens have different formats
return strings.HasPrefix(token, "ghp_") ||
strings.HasPrefix(token, "github_pat_") ||
strings.HasPrefix(token, "gho_") ||
strings.HasPrefix(token, "ghu_") ||
strings.HasPrefix(token, "ghs_") ||
strings.HasPrefix(token, "ghr_")
}
// isValidVariableName checks if a variable name is valid.
func (v *ConfigValidator) isValidVariableName(name string) bool {
if len(name) == 0 {
return false
}
// Variable names should start with letter or underscore
// and contain only letters, numbers, and underscores
matched, _ := regexp.MatchString(`^[a-zA-Z_][a-zA-Z0-9_]*$`, name)
return matched
}
// DisplayValidationResult displays validation results to the user.
func (v *ConfigValidator) DisplayValidationResult(result *ValidationResult) {
if result.Valid {
v.output.Success("✅ Configuration is valid")
} else {
v.output.Error("❌ Configuration has errors")
}
// Display errors
for _, err := range result.Errors {
v.output.Error(" • %s: %s (value: %s)", err.Field, err.Message, err.Value)
}
// Display warnings
for _, warning := range result.Warnings {
v.output.Warning(" ⚠️ %s: %s", warning.Field, warning.Message)
}
// Display suggestions
if len(result.Suggestions) > 0 {
v.output.Info("\nSuggestions:")
for _, suggestion := range result.Suggestions {
v.output.Printf(" 💡 %s", suggestion)
}
}
}