feat: add interactive wizard, contextual errors, and code improvements

- Add interactive configuration wizard with auto-detection and multi-format export
- Implement contextual error system with 14 error codes and actionable suggestions
- Add centralized progress indicators with consistent theming
- Fix all cyclomatic complexity issues (8 functions refactored)
- Eliminate code duplication with centralized utilities and error handling
- Add comprehensive test coverage for all new components
- Update TODO.md with completed tasks and accurate completion dates
This commit is contained in:
2025-08-04 23:33:28 +03:00
parent 7a8dc8d2ba
commit f9823eef3e
17 changed files with 4104 additions and 82 deletions

182
internal/errors/errors.go Normal file
View File

@@ -0,0 +1,182 @@
// Package errors provides enhanced error types with contextual information and suggestions.
package errors
import (
"errors"
"fmt"
"strings"
)
// ErrorCode represents a category of error for providing specific help.
type ErrorCode string
// Error code constants for categorizing errors.
const (
ErrCodeFileNotFound ErrorCode = "FILE_NOT_FOUND"
ErrCodePermission ErrorCode = "PERMISSION_DENIED"
ErrCodeInvalidYAML ErrorCode = "INVALID_YAML"
ErrCodeInvalidAction ErrorCode = "INVALID_ACTION"
ErrCodeNoActionFiles ErrorCode = "NO_ACTION_FILES"
ErrCodeGitHubAPI ErrorCode = "GITHUB_API_ERROR"
ErrCodeGitHubRateLimit ErrorCode = "GITHUB_RATE_LIMIT"
ErrCodeGitHubAuth ErrorCode = "GITHUB_AUTH_ERROR"
ErrCodeConfiguration ErrorCode = "CONFIG_ERROR"
ErrCodeValidation ErrorCode = "VALIDATION_ERROR"
ErrCodeTemplateRender ErrorCode = "TEMPLATE_ERROR"
ErrCodeFileWrite ErrorCode = "FILE_WRITE_ERROR"
ErrCodeDependencyAnalysis ErrorCode = "DEPENDENCY_ERROR"
ErrCodeCacheAccess ErrorCode = "CACHE_ERROR"
ErrCodeUnknown ErrorCode = "UNKNOWN_ERROR"
)
// ContextualError provides enhanced error information with actionable suggestions.
type ContextualError struct {
Code ErrorCode
Err error
Context string
Suggestions []string
HelpURL string
Details map[string]string
}
// Error implements the error interface.
func (ce *ContextualError) Error() string {
var b strings.Builder
// Primary error message
if ce.Context != "" {
b.WriteString(fmt.Sprintf("%s: %v", ce.Context, ce.Err))
} else {
b.WriteString(ce.Err.Error())
}
// Add error code for reference
b.WriteString(fmt.Sprintf(" [%s]", ce.Code))
// Add details if available
if len(ce.Details) > 0 {
b.WriteString("\n\nDetails:")
for key, value := range ce.Details {
b.WriteString(fmt.Sprintf("\n %s: %s", key, value))
}
}
// Add suggestions
if len(ce.Suggestions) > 0 {
b.WriteString("\n\nSuggestions:")
for _, suggestion := range ce.Suggestions {
b.WriteString(fmt.Sprintf("\n • %s", suggestion))
}
}
// Add help URL
if ce.HelpURL != "" {
b.WriteString(fmt.Sprintf("\n\nFor more help: %s", ce.HelpURL))
}
return b.String()
}
// Unwrap returns the wrapped error.
func (ce *ContextualError) Unwrap() error {
return ce.Err
}
// Is implements errors.Is support.
func (ce *ContextualError) Is(target error) bool {
if target == nil {
return false
}
// Check if target is also a ContextualError with same code
if targetCE, ok := target.(*ContextualError); ok {
return ce.Code == targetCE.Code
}
// Check wrapped error
return errors.Is(ce.Err, target)
}
// New creates a new ContextualError with the given code and message.
func New(code ErrorCode, message string) *ContextualError {
return &ContextualError{
Code: code,
Err: errors.New(message),
}
}
// Wrap wraps an existing error with contextual information.
func Wrap(err error, code ErrorCode, context string) *ContextualError {
if err == nil {
return nil
}
// If already a ContextualError, preserve existing info
if ce, ok := err.(*ContextualError); ok {
// Only update if not already set
if ce.Code == ErrCodeUnknown {
ce.Code = code
}
if ce.Context == "" {
ce.Context = context
}
return ce
}
return &ContextualError{
Code: code,
Err: err,
Context: context,
}
}
// WithSuggestions adds suggestions to a ContextualError.
func (ce *ContextualError) WithSuggestions(suggestions ...string) *ContextualError {
ce.Suggestions = append(ce.Suggestions, suggestions...)
return ce
}
// WithDetails adds detail key-value pairs to a ContextualError.
func (ce *ContextualError) WithDetails(details map[string]string) *ContextualError {
if ce.Details == nil {
ce.Details = make(map[string]string)
}
for k, v := range details {
ce.Details[k] = v
}
return ce
}
// WithHelpURL adds a help URL to a ContextualError.
func (ce *ContextualError) WithHelpURL(url string) *ContextualError {
ce.HelpURL = url
return ce
}
// GetHelpURL returns a help URL for the given error code.
func GetHelpURL(code ErrorCode) string {
baseURL := "https://github.com/ivuorinen/gh-action-readme/blob/main/docs/troubleshooting.md"
anchors := map[ErrorCode]string{
ErrCodeFileNotFound: "#file-not-found",
ErrCodePermission: "#permission-denied",
ErrCodeInvalidYAML: "#invalid-yaml",
ErrCodeInvalidAction: "#invalid-action-file",
ErrCodeNoActionFiles: "#no-action-files",
ErrCodeGitHubAPI: "#github-api-errors",
ErrCodeGitHubRateLimit: "#rate-limit-exceeded",
ErrCodeGitHubAuth: "#authentication-errors",
ErrCodeConfiguration: "#configuration-errors",
ErrCodeValidation: "#validation-errors",
ErrCodeTemplateRender: "#template-errors",
ErrCodeFileWrite: "#file-write-errors",
ErrCodeDependencyAnalysis: "#dependency-analysis",
ErrCodeCacheAccess: "#cache-errors",
}
if anchor, ok := anchors[code]; ok {
return baseURL + anchor
}
return baseURL
}

View File

@@ -0,0 +1,255 @@
package errors
import (
"errors"
"strings"
"testing"
)
func TestContextualError_Error(t *testing.T) {
tests := []struct {
name string
err *ContextualError
contains []string
}{
{
name: "basic error",
err: &ContextualError{
Code: ErrCodeFileNotFound,
Err: errors.New("file not found"),
},
contains: []string{"file not found", "[FILE_NOT_FOUND]"},
},
{
name: "error with context",
err: &ContextualError{
Code: ErrCodeInvalidYAML,
Err: errors.New("invalid syntax"),
Context: "parsing action.yml",
},
contains: []string{"parsing action.yml: invalid syntax", "[INVALID_YAML]"},
},
{
name: "error with suggestions",
err: &ContextualError{
Code: ErrCodeNoActionFiles,
Err: errors.New("no files found"),
Suggestions: []string{
"Check current directory",
"Use --recursive flag",
},
},
contains: []string{
"no files found",
"Suggestions:",
"• Check current directory",
"• Use --recursive flag",
},
},
{
name: "error with details",
err: &ContextualError{
Code: ErrCodeConfiguration,
Err: errors.New("config error"),
Details: map[string]string{
"config_path": "/path/to/config",
"line": "42",
},
},
contains: []string{
"config error",
"Details:",
"config_path: /path/to/config",
"line: 42",
},
},
{
name: "error with help URL",
err: &ContextualError{
Code: ErrCodeGitHubAPI,
Err: errors.New("API error"),
HelpURL: "https://docs.github.com/api",
},
contains: []string{
"API error",
"For more help: https://docs.github.com/api",
},
},
{
name: "complete error",
err: &ContextualError{
Code: ErrCodeValidation,
Err: errors.New("validation failed"),
Context: "validating action.yml",
Details: map[string]string{"file": "action.yml"},
Suggestions: []string{
"Check required fields",
"Validate YAML syntax",
},
HelpURL: "https://example.com/help",
},
contains: []string{
"validating action.yml: validation failed",
"[VALIDATION_ERROR]",
"Details:",
"file: action.yml",
"Suggestions:",
"• Check required fields",
"• Validate YAML syntax",
"For more help: https://example.com/help",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.err.Error()
for _, expected := range tt.contains {
if !strings.Contains(result, expected) {
t.Errorf(
"Error() result missing expected content:\nExpected to contain: %q\nActual result:\n%s",
expected,
result,
)
}
}
})
}
}
func TestContextualError_Unwrap(t *testing.T) {
originalErr := errors.New("original error")
contextualErr := &ContextualError{
Code: ErrCodeFileNotFound,
Err: originalErr,
}
if unwrapped := contextualErr.Unwrap(); unwrapped != originalErr {
t.Errorf("Unwrap() = %v, want %v", unwrapped, originalErr)
}
}
func TestContextualError_Is(t *testing.T) {
originalErr := errors.New("original error")
contextualErr := &ContextualError{
Code: ErrCodeFileNotFound,
Err: originalErr,
}
// Test Is with same error code
sameCodeErr := &ContextualError{Code: ErrCodeFileNotFound}
if !contextualErr.Is(sameCodeErr) {
t.Error("Is() should return true for same error code")
}
// Test Is with different error code
differentCodeErr := &ContextualError{Code: ErrCodeInvalidYAML}
if contextualErr.Is(differentCodeErr) {
t.Error("Is() should return false for different error code")
}
// Test Is with wrapped error
if !errors.Is(contextualErr, originalErr) {
t.Error("errors.Is() should work with wrapped error")
}
}
func TestNew(t *testing.T) {
err := New(ErrCodeFileNotFound, "test message")
if err.Code != ErrCodeFileNotFound {
t.Errorf("New() code = %v, want %v", err.Code, ErrCodeFileNotFound)
}
if err.Err.Error() != "test message" {
t.Errorf("New() message = %v, want %v", err.Err.Error(), "test message")
}
}
func TestWrap(t *testing.T) {
originalErr := errors.New("original error")
// Test wrapping normal error
wrapped := Wrap(originalErr, ErrCodeFileNotFound, "test context")
if wrapped.Code != ErrCodeFileNotFound {
t.Errorf("Wrap() code = %v, want %v", wrapped.Code, ErrCodeFileNotFound)
}
if wrapped.Context != "test context" {
t.Errorf("Wrap() context = %v, want %v", wrapped.Context, "test context")
}
if wrapped.Err != originalErr {
t.Errorf("Wrap() err = %v, want %v", wrapped.Err, originalErr)
}
// Test wrapping nil error
nilWrapped := Wrap(nil, ErrCodeFileNotFound, "test context")
if nilWrapped != nil {
t.Error("Wrap(nil) should return nil")
}
// Test wrapping already contextual error
contextualErr := &ContextualError{
Code: ErrCodeUnknown,
Err: originalErr,
Context: "",
}
rewrapped := Wrap(contextualErr, ErrCodeFileNotFound, "new context")
if rewrapped.Code != ErrCodeFileNotFound {
t.Error("Wrap() should update code if it was ErrCodeUnknown")
}
if rewrapped.Context != "new context" {
t.Error("Wrap() should update context if it was empty")
}
}
func TestContextualError_WithMethods(t *testing.T) {
err := New(ErrCodeFileNotFound, "test error")
// Test WithSuggestions
err = err.WithSuggestions("suggestion 1", "suggestion 2")
if len(err.Suggestions) != 2 {
t.Errorf("WithSuggestions() length = %d, want 2", len(err.Suggestions))
}
if err.Suggestions[0] != "suggestion 1" {
t.Errorf("WithSuggestions()[0] = %s, want 'suggestion 1'", err.Suggestions[0])
}
// Test WithDetails
details := map[string]string{"key1": "value1", "key2": "value2"}
err = err.WithDetails(details)
if len(err.Details) != 2 {
t.Errorf("WithDetails() length = %d, want 2", len(err.Details))
}
if err.Details["key1"] != "value1" {
t.Errorf("WithDetails()['key1'] = %s, want 'value1'", err.Details["key1"])
}
// Test WithHelpURL
url := "https://example.com/help"
err = err.WithHelpURL(url)
if err.HelpURL != url {
t.Errorf("WithHelpURL() = %s, want %s", err.HelpURL, url)
}
}
func TestGetHelpURL(t *testing.T) {
tests := []struct {
code ErrorCode
contains string
}{
{ErrCodeFileNotFound, "#file-not-found"},
{ErrCodeInvalidYAML, "#invalid-yaml"},
{ErrCodeGitHubAPI, "#github-api-errors"},
{ErrCodeUnknown, "troubleshooting.md"}, // Should return base URL
}
for _, tt := range tests {
t.Run(string(tt.code), func(t *testing.T) {
url := GetHelpURL(tt.code)
if !strings.Contains(url, tt.contains) {
t.Errorf("GetHelpURL(%s) = %s, should contain %s", tt.code, url, tt.contains)
}
})
}
}

View File

@@ -0,0 +1,416 @@
package errors
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// GetSuggestions returns context-aware suggestions for the given error code.
func GetSuggestions(code ErrorCode, context map[string]string) []string {
if handler := getSuggestionHandler(code); handler != nil {
return handler(context)
}
return getDefaultSuggestions()
}
// getSuggestionHandler returns the appropriate suggestion function for the error code.
func getSuggestionHandler(code ErrorCode) func(map[string]string) []string {
handlers := map[ErrorCode]func(map[string]string) []string{
ErrCodeFileNotFound: getFileNotFoundSuggestions,
ErrCodePermission: getPermissionSuggestions,
ErrCodeInvalidYAML: getInvalidYAMLSuggestions,
ErrCodeInvalidAction: getInvalidActionSuggestions,
ErrCodeNoActionFiles: getNoActionFilesSuggestions,
ErrCodeGitHubAPI: getGitHubAPISuggestions,
ErrCodeConfiguration: getConfigurationSuggestions,
ErrCodeValidation: getValidationSuggestions,
ErrCodeTemplateRender: getTemplateSuggestions,
ErrCodeFileWrite: getFileWriteSuggestions,
ErrCodeDependencyAnalysis: getDependencyAnalysisSuggestions,
ErrCodeCacheAccess: getCacheAccessSuggestions,
}
// Special cases for handlers without context
switch code {
case ErrCodeGitHubRateLimit:
return func(_ map[string]string) []string { return getGitHubRateLimitSuggestions() }
case ErrCodeGitHubAuth:
return func(_ map[string]string) []string { return getGitHubAuthSuggestions() }
case ErrCodeFileNotFound, ErrCodePermission, ErrCodeInvalidYAML, ErrCodeInvalidAction,
ErrCodeNoActionFiles, ErrCodeGitHubAPI, ErrCodeConfiguration, ErrCodeValidation,
ErrCodeTemplateRender, ErrCodeFileWrite, ErrCodeDependencyAnalysis, ErrCodeCacheAccess,
ErrCodeUnknown:
// These cases are handled by the map above
}
return handlers[code]
}
// getDefaultSuggestions returns generic suggestions for unknown errors.
func getDefaultSuggestions() []string {
return []string{
"Check the error message for more details",
"Run with --verbose flag for additional debugging information",
"Visit the project documentation for help",
}
}
func getFileNotFoundSuggestions(context map[string]string) []string {
suggestions := []string{}
if path, ok := context["path"]; ok {
suggestions = append(suggestions,
fmt.Sprintf("Check if the file exists: %s", path),
"Verify the file path is correct",
)
// Check if it might be a case sensitivity issue
dir := filepath.Dir(path)
if _, err := os.Stat(dir); err == nil {
suggestions = append(suggestions,
"Check for case sensitivity in the filename",
fmt.Sprintf("Try: ls -la %s", dir),
)
}
// Suggest common file names if looking for action files
if strings.Contains(path, "action") {
suggestions = append(suggestions,
"Common action file names: action.yml, action.yaml",
"Check if the file is in a subdirectory",
)
}
}
suggestions = append(suggestions,
"Use --recursive flag to search in subdirectories",
"Ensure you have read permissions for the directory",
)
return suggestions
}
func getPermissionSuggestions(context map[string]string) []string {
suggestions := []string{}
if path, ok := context["path"]; ok {
suggestions = append(suggestions,
fmt.Sprintf("Check file permissions: ls -la %s", path),
fmt.Sprintf("Try changing permissions: chmod 644 %s", path),
)
// Check if it's a directory
if info, err := os.Stat(path); err == nil && info.IsDir() {
suggestions = append(suggestions,
fmt.Sprintf("For directories, try: chmod 755 %s", path),
)
}
}
// Add OS-specific suggestions
switch runtime.GOOS {
case "windows":
suggestions = append(suggestions,
"Run the command prompt as Administrator",
"Check Windows file permissions in Properties > Security",
)
default:
suggestions = append(suggestions,
"Check file ownership with: ls -la",
"You may need to use sudo for system directories",
"Ensure the parent directory has write permissions",
)
}
return suggestions
}
func getInvalidYAMLSuggestions(context map[string]string) []string {
suggestions := []string{
"Check YAML indentation (use spaces, not tabs)",
"Ensure all strings with special characters are quoted",
"Verify brackets and braces are properly closed",
"Use a YAML validator: https://yaml-online-parser.appspot.com/",
}
if line, ok := context["line"]; ok {
suggestions = append([]string{
fmt.Sprintf("Error near line %s - check indentation and syntax", line),
}, suggestions...)
}
if err, ok := context["error"]; ok {
if strings.Contains(err, "tab") {
suggestions = append([]string{
"YAML files must use spaces for indentation, not tabs",
"Replace all tabs with spaces (usually 2 or 4 spaces)",
}, suggestions...)
}
}
suggestions = append(suggestions,
"Common YAML issues: missing colons, incorrect nesting, invalid characters",
"Example valid action.yml structure available in documentation",
)
return suggestions
}
func getInvalidActionSuggestions(context map[string]string) []string {
suggestions := []string{
"Ensure the action file has required fields: name, description",
"Check that 'runs' section is properly configured",
"Verify the action type is valid (composite, docker, or javascript)",
}
if missingFields, ok := context["missing_fields"]; ok {
suggestions = append([]string{
fmt.Sprintf("Missing required fields: %s", missingFields),
}, suggestions...)
}
if invalidField, ok := context["invalid_field"]; ok {
suggestions = append(suggestions,
fmt.Sprintf("Invalid field '%s' - check spelling and placement", invalidField),
)
}
suggestions = append(suggestions,
"Refer to GitHub Actions documentation for action.yml schema",
"Use 'gh-action-readme schema' to see the expected format",
"Validate against the schema with 'gh-action-readme validate'",
)
return suggestions
}
func getNoActionFilesSuggestions(context map[string]string) []string {
suggestions := []string{
"Ensure you're in the correct directory",
"Look for files named 'action.yml' or 'action.yaml'",
"Use --recursive flag to search subdirectories",
}
if dir, ok := context["directory"]; ok {
suggestions = append(suggestions,
fmt.Sprintf("Current directory: %s", dir),
fmt.Sprintf("Try: find %s -name 'action.y*ml' -type f", dir),
)
}
suggestions = append(suggestions,
"GitHub Actions must have an action.yml or action.yaml file",
"Check if the file has a different extension (.yaml vs .yml)",
"Example: gh-action-readme gen --recursive",
)
return suggestions
}
func getGitHubAPISuggestions(context map[string]string) []string {
suggestions := []string{
"Check your internet connection",
"Verify GitHub's API status: https://www.githubstatus.com/",
"Ensure your GitHub token has the necessary permissions",
}
if statusCode, ok := context["status_code"]; ok {
switch statusCode {
case "401":
suggestions = append([]string{
"Authentication failed - check your GitHub token",
"Token may be expired or revoked",
}, suggestions...)
case "403":
suggestions = append([]string{
"Access forbidden - check token permissions",
"You may have hit the rate limit",
}, suggestions...)
case "404":
suggestions = append([]string{
"Repository or resource not found",
"Check if the repository is private and token has access",
}, suggestions...)
}
}
return suggestions
}
func getGitHubRateLimitSuggestions() []string {
return []string{
"GitHub API rate limit exceeded",
"Authenticate with a GitHub token to increase limits",
"Set GITHUB_TOKEN environment variable: export GITHUB_TOKEN=your_token",
"For GitHub CLI: gh auth login",
"Rate limits reset every hour",
"Consider using caching to reduce API calls",
"Use --quiet mode to reduce API usage for non-critical features",
}
}
func getGitHubAuthSuggestions() []string {
return []string{
"Set GitHub token: export GITHUB_TOKEN=your_personal_access_token",
"Or use GitHub CLI: gh auth login",
"Create a token at: https://github.com/settings/tokens",
"Token needs 'repo' scope for private repositories",
"For public repos only, 'public_repo' scope is sufficient",
"Check if token is set: echo $GITHUB_TOKEN",
"Ensure token hasn't expired",
}
}
func getConfigurationSuggestions(context map[string]string) []string {
suggestions := []string{
"Check configuration file syntax",
"Ensure configuration file exists",
"Use 'gh-action-readme config init' to create default config",
"Valid config locations: .gh-action-readme.yml, ~/.config/gh-action-readme/config.yaml",
}
if configPath, ok := context["config_path"]; ok {
suggestions = append(suggestions,
fmt.Sprintf("Config path: %s", configPath),
fmt.Sprintf("Check if file exists: ls -la %s", configPath),
)
}
if err, ok := context["error"]; ok {
if strings.Contains(err, "permission") {
suggestions = append(suggestions,
"Check file permissions for config file",
"Ensure parent directory is writable",
)
}
}
return suggestions
}
func getValidationSuggestions(context map[string]string) []string {
suggestions := []string{
"Review validation errors for specific issues",
"Check required fields are present",
"Ensure field values match expected types",
"Use 'gh-action-readme schema' to see valid structure",
}
if fields, ok := context["invalid_fields"]; ok {
suggestions = append([]string{
fmt.Sprintf("Invalid fields: %s", fields),
"Check spelling and nesting of these fields",
}, suggestions...)
}
if validationType, ok := context["validation_type"]; ok {
switch validationType {
case "required":
suggestions = append(suggestions,
"Add missing required fields to your action.yml",
"Required fields typically include: name, description, runs",
)
case "type":
suggestions = append(suggestions,
"Ensure field values match expected types",
"Strings should be quoted, booleans should be true/false",
)
}
}
return suggestions
}
func getTemplateSuggestions(context map[string]string) []string {
suggestions := []string{
"Check template syntax",
"Ensure all template variables are defined",
"Verify custom template path is correct",
}
if templatePath, ok := context["template_path"]; ok {
suggestions = append(suggestions,
fmt.Sprintf("Template path: %s", templatePath),
"Ensure template file exists and is readable",
)
}
if theme, ok := context["theme"]; ok {
suggestions = append(suggestions,
fmt.Sprintf("Current theme: %s", theme),
"Try using a different theme: --theme github",
"Available themes: default, github, gitlab, minimal, professional",
)
}
return suggestions
}
func getFileWriteSuggestions(context map[string]string) []string {
suggestions := []string{
"Check if the output directory exists",
"Ensure you have write permissions",
"Verify disk space is available",
}
if outputPath, ok := context["output_path"]; ok {
dir := filepath.Dir(outputPath)
suggestions = append(suggestions,
fmt.Sprintf("Output directory: %s", dir),
fmt.Sprintf("Check permissions: ls -la %s", dir),
fmt.Sprintf("Create directory if needed: mkdir -p %s", dir),
)
// Check if file already exists
if _, err := os.Stat(outputPath); err == nil {
suggestions = append(suggestions,
"File already exists - it will be overwritten",
"Back up existing file if needed",
)
}
}
return suggestions
}
func getDependencyAnalysisSuggestions(context map[string]string) []string {
suggestions := []string{
"Ensure GitHub token is set for dependency analysis",
"Check that the action file contains valid dependencies",
"Verify network connectivity to GitHub",
}
if action, ok := context["action"]; ok {
suggestions = append(suggestions,
fmt.Sprintf("Analyzing action: %s", action),
"Only composite actions have analyzable dependencies",
)
}
return append(suggestions,
"Dependency analysis requires 'uses' statements in composite actions",
"Example: uses: actions/checkout@v4",
)
}
func getCacheAccessSuggestions(context map[string]string) []string {
suggestions := []string{
"Check cache directory permissions",
"Ensure cache directory exists",
"Try clearing cache: gh-action-readme cache clear",
"Default cache location: ~/.cache/gh-action-readme",
}
if cachePath, ok := context["cache_path"]; ok {
suggestions = append(suggestions,
fmt.Sprintf("Cache path: %s", cachePath),
fmt.Sprintf("Check permissions: ls -la %s", cachePath),
"You can disable cache temporarily with environment variables",
)
}
return suggestions
}

View File

@@ -0,0 +1,366 @@
package errors
import (
"runtime"
"strings"
"testing"
)
func TestGetSuggestions(t *testing.T) {
tests := []struct {
name string
code ErrorCode
context map[string]string
contains []string
}{
{
name: "file not found with path",
code: ErrCodeFileNotFound,
context: map[string]string{
"path": "/path/to/action.yml",
},
contains: []string{
"Check if the file exists: /path/to/action.yml",
"Verify the file path is correct",
"--recursive flag",
},
},
{
name: "file not found action file",
code: ErrCodeFileNotFound,
context: map[string]string{
"path": "/project/action.yml",
},
contains: []string{
"Common action file names: action.yml, action.yaml",
"Check if the file is in a subdirectory",
},
},
{
name: "permission denied",
code: ErrCodePermission,
context: map[string]string{
"path": "/restricted/file.txt",
},
contains: []string{
"Check file permissions: ls -la /restricted/file.txt",
"chmod 644 /restricted/file.txt",
},
},
{
name: "invalid YAML with line number",
code: ErrCodeInvalidYAML,
context: map[string]string{
"line": "25",
},
contains: []string{
"Error near line 25",
"Check YAML indentation",
"use spaces, not tabs",
"YAML validator",
},
},
{
name: "invalid YAML with tab error",
code: ErrCodeInvalidYAML,
context: map[string]string{
"error": "found character that cannot start any token (tab)",
},
contains: []string{
"YAML files must use spaces for indentation, not tabs",
"Replace all tabs with spaces",
},
},
{
name: "invalid action with missing fields",
code: ErrCodeInvalidAction,
context: map[string]string{
"missing_fields": "name, description",
},
contains: []string{
"Missing required fields: name, description",
"required fields: name, description",
"gh-action-readme schema",
},
},
{
name: "no action files",
code: ErrCodeNoActionFiles,
context: map[string]string{
"directory": "/project",
},
contains: []string{
"Current directory: /project",
"find /project -name 'action.y*ml'",
"--recursive flag",
"action.yml or action.yaml",
},
},
{
name: "GitHub API 401 error",
code: ErrCodeGitHubAPI,
context: map[string]string{
"status_code": "401",
},
contains: []string{
"Authentication failed",
"check your GitHub token",
"Token may be expired",
},
},
{
name: "GitHub API 403 error",
code: ErrCodeGitHubAPI,
context: map[string]string{
"status_code": "403",
},
contains: []string{
"Access forbidden",
"check token permissions",
"rate limit",
},
},
{
name: "GitHub API 404 error",
code: ErrCodeGitHubAPI,
context: map[string]string{
"status_code": "404",
},
contains: []string{
"Repository or resource not found",
"repository is private",
},
},
{
name: "GitHub rate limit",
code: ErrCodeGitHubRateLimit,
context: map[string]string{},
contains: []string{
"rate limit exceeded",
"GITHUB_TOKEN",
"gh auth login",
"Rate limits reset every hour",
},
},
{
name: "GitHub auth",
code: ErrCodeGitHubAuth,
context: map[string]string{},
contains: []string{
"export GITHUB_TOKEN",
"gh auth login",
"https://github.com/settings/tokens",
"'repo' scope",
},
},
{
name: "configuration error with path",
code: ErrCodeConfiguration,
context: map[string]string{
"config_path": "~/.config/gh-action-readme/config.yaml",
},
contains: []string{
"Config path: ~/.config/gh-action-readme/config.yaml",
"ls -la ~/.config/gh-action-readme/config.yaml",
"gh-action-readme config init",
},
},
{
name: "validation error with invalid fields",
code: ErrCodeValidation,
context: map[string]string{
"invalid_fields": "runs.using, inputs.test",
},
contains: []string{
"Invalid fields: runs.using, inputs.test",
"Check spelling and nesting",
"gh-action-readme schema",
},
},
{
name: "template error with theme",
code: ErrCodeTemplateRender,
context: map[string]string{
"theme": "custom",
},
contains: []string{
"Current theme: custom",
"Try using a different theme",
"Available themes:",
},
},
{
name: "file write error with output path",
code: ErrCodeFileWrite,
context: map[string]string{
"output_path": "/output/README.md",
},
contains: []string{
"Output directory: /output",
"Check permissions: ls -la /output",
"mkdir -p /output",
},
},
{
name: "dependency analysis error",
code: ErrCodeDependencyAnalysis,
context: map[string]string{
"action": "my-action",
},
contains: []string{
"Analyzing action: my-action",
"GitHub token is set",
"composite actions",
},
},
{
name: "cache access error",
code: ErrCodeCacheAccess,
context: map[string]string{
"cache_path": "~/.cache/gh-action-readme",
},
contains: []string{
"Cache path: ~/.cache/gh-action-readme",
"gh-action-readme cache clear",
"permissions: ls -la ~/.cache/gh-action-readme",
},
},
{
name: "unknown error code",
code: "UNKNOWN_TEST_CODE",
context: map[string]string{},
contains: []string{
"Check the error message",
"--verbose flag",
"project documentation",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
suggestions := GetSuggestions(tt.code, tt.context)
if len(suggestions) == 0 {
t.Error("GetSuggestions() returned empty slice")
return
}
allSuggestions := strings.Join(suggestions, " ")
for _, expected := range tt.contains {
if !strings.Contains(allSuggestions, expected) {
t.Errorf(
"GetSuggestions() missing expected content:\nExpected to contain: %q\nSuggestions:\n%s",
expected,
strings.Join(suggestions, "\n"),
)
}
}
})
}
}
func TestGetPermissionSuggestions_OSSpecific(t *testing.T) {
context := map[string]string{"path": "/test/file"}
suggestions := getPermissionSuggestions(context)
allSuggestions := strings.Join(suggestions, " ")
switch runtime.GOOS {
case "windows":
if !strings.Contains(allSuggestions, "Administrator") {
t.Error("Windows-specific suggestions should mention Administrator")
}
if !strings.Contains(allSuggestions, "Windows file permissions") {
t.Error("Windows-specific suggestions should mention Windows file permissions")
}
default:
if !strings.Contains(allSuggestions, "sudo") {
t.Error("Unix-specific suggestions should mention sudo")
}
if !strings.Contains(allSuggestions, "ls -la") {
t.Error("Unix-specific suggestions should mention ls -la")
}
}
}
func TestGetSuggestions_EmptyContext(t *testing.T) {
// Test that all error codes work with empty context
errorCodes := []ErrorCode{
ErrCodeFileNotFound,
ErrCodePermission,
ErrCodeInvalidYAML,
ErrCodeInvalidAction,
ErrCodeNoActionFiles,
ErrCodeGitHubAPI,
ErrCodeGitHubRateLimit,
ErrCodeGitHubAuth,
ErrCodeConfiguration,
ErrCodeValidation,
ErrCodeTemplateRender,
ErrCodeFileWrite,
ErrCodeDependencyAnalysis,
ErrCodeCacheAccess,
}
for _, code := range errorCodes {
t.Run(string(code), func(t *testing.T) {
suggestions := GetSuggestions(code, map[string]string{})
if len(suggestions) == 0 {
t.Errorf("GetSuggestions(%s, {}) returned empty slice", code)
}
})
}
}
func TestGetFileNotFoundSuggestions_ActionFile(t *testing.T) {
context := map[string]string{
"path": "/project/action.yml",
}
suggestions := getFileNotFoundSuggestions(context)
allSuggestions := strings.Join(suggestions, " ")
// Should suggest common action file names when path contains "action"
if !strings.Contains(allSuggestions, "action.yml, action.yaml") {
t.Error("Should suggest common action file names for action file paths")
}
if !strings.Contains(allSuggestions, "subdirectory") {
t.Error("Should suggest checking subdirectories for action files")
}
}
func TestGetInvalidYAMLSuggestions_TabError(t *testing.T) {
context := map[string]string{
"error": "found character that cannot start any token, tab character",
}
suggestions := getInvalidYAMLSuggestions(context)
allSuggestions := strings.Join(suggestions, " ")
// Should prioritize tab-specific suggestions when error mentions tabs
if !strings.Contains(allSuggestions, "tabs with spaces") {
t.Error("Should provide tab-specific suggestions when error mentions tabs")
}
}
func TestGetGitHubAPISuggestions_StatusCodes(t *testing.T) {
statusCodes := map[string]string{
"401": "Authentication failed",
"403": "Access forbidden",
"404": "not found",
}
for code, expectedText := range statusCodes {
t.Run("status_"+code, func(t *testing.T) {
context := map[string]string{"status_code": code}
suggestions := getGitHubAPISuggestions(context)
allSuggestions := strings.Join(suggestions, " ")
if !strings.Contains(allSuggestions, expectedText) {
t.Errorf("Status code %s suggestions should contain %q", code, expectedText)
}
})
}
}