refactor: major codebase improvements and test framework overhaul

This commit represents a comprehensive refactoring of the codebase focused on
improving code quality, testability, and maintainability.

Key improvements:
- Implement dependency injection and interface-based architecture
- Add comprehensive test framework with fixtures and test suites
- Fix all linting issues (errcheck, gosec, staticcheck, goconst, etc.)
- Achieve full EditorConfig compliance across all files
- Replace hardcoded test data with proper fixture files
- Add configuration loader with hierarchical config support
- Improve error handling with contextual information
- Add progress indicators for better user feedback
- Enhance Makefile with help system and improved editorconfig commands
- Consolidate constants and remove deprecated code
- Strengthen validation logic for GitHub Actions
- Add focused consumer interfaces for better separation of concerns

Testing improvements:
- Add comprehensive integration tests
- Implement test executor pattern for better test organization
- Create extensive YAML fixture library for testing
- Fix all failing tests and improve test coverage
- Add validation test fixtures to avoid embedded YAML in Go files

Build and tooling:
- Update Makefile to show help by default
- Fix editorconfig commands to use eclint properly
- Add comprehensive help documentation to all make targets
- Improve file selection patterns to avoid glob errors

This refactoring maintains backward compatibility while significantly
improving the internal architecture and developer experience.
This commit is contained in:
2025-08-05 23:20:58 +03:00
parent f9823eef3e
commit f94967713a
93 changed files with 8845 additions and 1224 deletions

View File

@@ -27,6 +27,7 @@ type Cache struct {
ticker *time.Ticker // Cleanup ticker
done chan bool // Cleanup shutdown
defaultTTL time.Duration // Default TTL for entries
saveWG sync.WaitGroup // Wait group for pending save operations
}
// Config represents cache configuration.
@@ -58,7 +59,7 @@ func NewCache(config *Config) (*Cache, error) {
}
// Ensure cache directory exists
if err := os.MkdirAll(filepath.Dir(cacheDir), 0755); err != nil {
if err := os.MkdirAll(filepath.Dir(cacheDir), 0750); err != nil { // #nosec G301 -- cache directory permissions
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
@@ -188,6 +189,9 @@ func (c *Cache) Close() error {
default:
}
// Wait for any pending async save operations to complete
c.saveWG.Wait()
// Save final state to disk
return c.saveToDisk()
}
@@ -224,7 +228,7 @@ func (c *Cache) cleanup() {
func (c *Cache) loadFromDisk() error {
cacheFile := filepath.Join(c.path, "cache.json")
data, err := os.ReadFile(cacheFile)
data, err := os.ReadFile(cacheFile) // #nosec G304 -- cache file path constructed internally
if err != nil {
if os.IsNotExist(err) {
return nil // No cache file is fine
@@ -257,7 +261,7 @@ func (c *Cache) saveToDisk() error {
}
cacheFile := filepath.Join(c.path, "cache.json")
if err := os.WriteFile(cacheFile, jsonData, 0644); err != nil {
if err := os.WriteFile(cacheFile, jsonData, 0600); err != nil { // #nosec G306 -- cache file permissions
return fmt.Errorf("failed to write cache file: %w", err)
}
@@ -267,7 +271,9 @@ func (c *Cache) saveToDisk() error {
// saveToDiskAsync saves the cache to disk asynchronously.
// Cache save failures are non-critical and silently ignored.
func (c *Cache) saveToDiskAsync() {
c.saveWG.Add(1)
go func() {
defer c.saveWG.Done()
_ = c.saveToDisk() // Ignore errors - cache save failures are non-critical
}()
}

View File

@@ -313,6 +313,43 @@ func TestCache_Clear(t *testing.T) {
}
}
func TestCache_Delete(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
cache := createTestCache(t, tmpDir)
defer func() { _ = cache.Close() }()
// Add some data
_ = cache.Set("key1", "value1")
_ = cache.Set("key2", "value2")
_ = cache.Set("key3", "value3")
// Verify data exists
_, exists := cache.Get("key1")
if !exists {
t.Fatal("expected key1 to exist before delete")
}
// Delete specific key
cache.Delete("key1")
// Verify deleted key is gone but others remain
_, exists1 := cache.Get("key1")
_, exists2 := cache.Get("key2")
_, exists3 := cache.Get("key3")
if exists1 {
t.Error("expected key1 to be deleted")
}
if !exists2 || !exists3 {
t.Error("expected key2 and key3 to still exist")
}
// Test deleting non-existent key (should not panic)
cache.Delete("nonexistent")
}
func TestCache_Stats(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
@@ -320,6 +357,9 @@ func TestCache_Stats(t *testing.T) {
cache := createTestCache(t, tmpDir)
defer func() { _ = cache.Close() }()
// Ensure cache starts clean
_ = cache.Clear()
// Add some data
_ = cache.Set("key1", "value1")
_ = cache.Set("key2", "larger-value-with-more-content")

View File

@@ -78,12 +78,12 @@ type GitHubClient struct {
// GetGitHubToken returns the GitHub token from environment variables or config.
func GetGitHubToken(config *AppConfig) string {
// Priority 1: Tool-specific env var
if token := os.Getenv("GH_README_GITHUB_TOKEN"); token != "" {
if token := os.Getenv(EnvGitHubToken); token != "" {
return token
}
// Priority 2: Standard GitHub env var
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
if token := os.Getenv(EnvGitHubTokenStandard); token != "" {
return token
}
@@ -174,16 +174,16 @@ func resolveThemeTemplate(theme string) string {
var templatePath string
switch theme {
case "default":
templatePath = "templates/readme.tmpl"
case "github":
templatePath = "templates/themes/github/readme.tmpl"
case "gitlab":
templatePath = "templates/themes/gitlab/readme.tmpl"
case "minimal":
templatePath = "templates/themes/minimal/readme.tmpl"
case "professional":
templatePath = "templates/themes/professional/readme.tmpl"
case ThemeDefault:
templatePath = TemplatePathDefault
case ThemeGitHub:
templatePath = TemplatePathGitHub
case ThemeGitLab:
templatePath = TemplatePathGitLab
case ThemeMinimal:
templatePath = TemplatePathMinimal
case ThemeProfessional:
templatePath = TemplatePathProfessional
case "":
// Empty theme should return empty path
return ""
@@ -451,9 +451,9 @@ func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, erro
// 6. Apply environment variable overrides for GitHub token
// Check environment variables directly with higher priority
if token := os.Getenv("GH_README_GITHUB_TOKEN"); token != "" {
if token := os.Getenv(EnvGitHubToken); token != "" {
config.GitHubToken = token
} else if token := os.Getenv("GITHUB_TOKEN"); token != "" {
} else if token := os.Getenv(EnvGitHubTokenStandard); token != "" {
config.GitHubToken = token
}
@@ -465,7 +465,7 @@ func InitConfig(configFile string) (*AppConfig, error) {
v := viper.New()
// Set configuration file name and type
v.SetConfigName("config")
v.SetConfigName(ConfigFileName)
v.SetConfigType("yaml")
// Add XDG-compliant configuration directory
@@ -542,7 +542,7 @@ func WriteDefaultConfig() error {
}
// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(configFile), 0755); err != nil {
if err := os.MkdirAll(filepath.Dir(configFile), 0750); err != nil { // #nosec G301 -- config directory permissions
return fmt.Errorf("failed to create config directory: %w", err)
}

View File

@@ -50,7 +50,7 @@ func TestInitConfig(t *testing.T) {
configFile: "custom-config.yml",
setupFunc: func(t *testing.T, tempDir string) {
configPath := filepath.Join(tempDir, "custom-config.yml")
testutil.WriteTestFile(t, configPath, testutil.CustomConfigYAML)
testutil.WriteTestFile(t, configPath, testutil.MustReadFixture("professional-config.yml"))
},
expected: &AppConfig{
Theme: "professional",
@@ -134,7 +134,7 @@ func TestLoadConfiguration(t *testing.T) {
setupFunc: func(t *testing.T, tempDir string) (string, string, string) {
// Create global config
globalConfigDir := filepath.Join(tempDir, ".config", "gh-action-readme")
_ = os.MkdirAll(globalConfigDir, 0755)
_ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions
globalConfigPath := filepath.Join(globalConfigDir, "config.yaml")
testutil.WriteTestFile(t, globalConfigPath, `
theme: default
@@ -144,7 +144,7 @@ github_token: global-token
// Create repo root with repo-specific config
repoRoot := filepath.Join(tempDir, "repo")
_ = os.MkdirAll(repoRoot, 0755)
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
theme: github
output_format: html
@@ -152,7 +152,7 @@ output_format: html
// Create current directory with action-specific config
currentDir := filepath.Join(repoRoot, "action")
_ = os.MkdirAll(currentDir, 0755)
_ = os.MkdirAll(currentDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(currentDir, "config.yaml"), `
theme: professional
output_dir: output
@@ -206,7 +206,7 @@ github_token: config-token
// Create XDG-compliant config
configDir := filepath.Join(xdgConfigHome, "gh-action-readme")
_ = os.MkdirAll(configDir, 0755)
_ = os.MkdirAll(configDir, 0750) // #nosec G301 -- test directory permissions
configPath := filepath.Join(configDir, "config.yaml")
testutil.WriteTestFile(t, configPath, `
theme: github
@@ -228,7 +228,7 @@ verbose: true
name: "hidden config file discovery",
setupFunc: func(t *testing.T, tempDir string) (string, string, string) {
repoRoot := filepath.Join(tempDir, "repo")
_ = os.MkdirAll(repoRoot, 0755)
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
// Create multiple hidden config files
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
@@ -530,7 +530,7 @@ func TestConfigMerging(t *testing.T) {
// Test config merging by creating config files and seeing the result
globalConfigDir := filepath.Join(tmpDir, ".config", "gh-action-readme")
_ = os.MkdirAll(globalConfigDir, 0755)
_ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(globalConfigDir, "config.yaml"), `
theme: default
output_format: md
@@ -539,7 +539,7 @@ verbose: false
`)
repoRoot := filepath.Join(tmpDir, "repo")
_ = os.MkdirAll(repoRoot, 0755)
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
theme: github
output_format: html
@@ -576,3 +576,251 @@ verbose: true
testutil.AssertEqual(t, "base-token", config.GitHubToken) // from global config
testutil.AssertEqual(t, "schemas/schema.json", config.Schema) // default value
}
// TestGetGitHubToken tests GitHub token resolution with different priority levels.
func TestGetGitHubToken(t *testing.T) {
// Save and restore original environment
originalToolToken := os.Getenv(EnvGitHubToken)
originalStandardToken := os.Getenv(EnvGitHubTokenStandard)
defer func() {
if originalToolToken != "" {
_ = os.Setenv(EnvGitHubToken, originalToolToken)
} else {
_ = os.Unsetenv(EnvGitHubToken)
}
if originalStandardToken != "" {
_ = os.Setenv(EnvGitHubTokenStandard, originalStandardToken)
} else {
_ = os.Unsetenv(EnvGitHubTokenStandard)
}
}()
tests := []struct {
name string
toolEnvToken string
stdEnvToken string
configToken string
expectedToken string
}{
{
name: "tool-specific env var has highest priority",
toolEnvToken: "tool-token",
stdEnvToken: "std-token",
configToken: "config-token",
expectedToken: "tool-token",
},
{
name: "standard env var when tool env not set",
toolEnvToken: "",
stdEnvToken: "std-token",
configToken: "config-token",
expectedToken: "std-token",
},
{
name: "config token when env vars not set",
toolEnvToken: "",
stdEnvToken: "",
configToken: "config-token",
expectedToken: "config-token",
},
{
name: "empty string when nothing set",
toolEnvToken: "",
stdEnvToken: "",
configToken: "",
expectedToken: "",
},
{
name: "empty env var does not override config",
toolEnvToken: "",
stdEnvToken: "",
configToken: "config-token",
expectedToken: "config-token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up environment
if tt.toolEnvToken != "" {
_ = os.Setenv(EnvGitHubToken, tt.toolEnvToken)
} else {
_ = os.Unsetenv(EnvGitHubToken)
}
if tt.stdEnvToken != "" {
_ = os.Setenv(EnvGitHubTokenStandard, tt.stdEnvToken)
} else {
_ = os.Unsetenv(EnvGitHubTokenStandard)
}
config := &AppConfig{GitHubToken: tt.configToken}
result := GetGitHubToken(config)
testutil.AssertEqual(t, tt.expectedToken, result)
})
}
}
// TestMergeMapFields tests the merging of map fields in configuration.
func TestMergeMapFields(t *testing.T) {
tests := []struct {
name string
dst *AppConfig
src *AppConfig
expected *AppConfig
}{
{
name: "merge permissions into empty dst",
dst: &AppConfig{},
src: &AppConfig{
Permissions: map[string]string{"read": "read", "write": "write"},
},
expected: &AppConfig{
Permissions: map[string]string{"read": "read", "write": "write"},
},
},
{
name: "merge permissions into existing dst",
dst: &AppConfig{
Permissions: map[string]string{"read": "existing"},
},
src: &AppConfig{
Permissions: map[string]string{"read": "new", "write": "write"},
},
expected: &AppConfig{
Permissions: map[string]string{"read": "new", "write": "write"},
},
},
{
name: "merge variables into empty dst",
dst: &AppConfig{},
src: &AppConfig{
Variables: map[string]string{"VAR1": "value1", "VAR2": "value2"},
},
expected: &AppConfig{
Variables: map[string]string{"VAR1": "value1", "VAR2": "value2"},
},
},
{
name: "merge variables into existing dst",
dst: &AppConfig{
Variables: map[string]string{"VAR1": "existing"},
},
src: &AppConfig{
Variables: map[string]string{"VAR1": "new", "VAR2": "value2"},
},
expected: &AppConfig{
Variables: map[string]string{"VAR1": "new", "VAR2": "value2"},
},
},
{
name: "merge both permissions and variables",
dst: &AppConfig{
Permissions: map[string]string{"read": "existing"},
},
src: &AppConfig{
Permissions: map[string]string{"write": "write"},
Variables: map[string]string{"VAR1": "value1"},
},
expected: &AppConfig{
Permissions: map[string]string{"read": "existing", "write": "write"},
Variables: map[string]string{"VAR1": "value1"},
},
},
{
name: "empty src does not affect dst",
dst: &AppConfig{
Permissions: map[string]string{"read": "read"},
Variables: map[string]string{"VAR1": "value1"},
},
src: &AppConfig{},
expected: &AppConfig{
Permissions: map[string]string{"read": "read"},
Variables: map[string]string{"VAR1": "value1"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Deep copy dst to avoid modifying test data
dst := &AppConfig{}
if tt.dst.Permissions != nil {
dst.Permissions = make(map[string]string)
for k, v := range tt.dst.Permissions {
dst.Permissions[k] = v
}
}
if tt.dst.Variables != nil {
dst.Variables = make(map[string]string)
for k, v := range tt.dst.Variables {
dst.Variables[k] = v
}
}
mergeMapFields(dst, tt.src)
testutil.AssertEqual(t, tt.expected.Permissions, dst.Permissions)
testutil.AssertEqual(t, tt.expected.Variables, dst.Variables)
})
}
}
// TestMergeSliceFields tests the merging of slice fields in configuration.
func TestMergeSliceFields(t *testing.T) {
tests := []struct {
name string
dst *AppConfig
src *AppConfig
expected []string
}{
{
name: "merge runsOn into empty dst",
dst: &AppConfig{},
src: &AppConfig{RunsOn: []string{"ubuntu-latest", "windows-latest"}},
expected: []string{"ubuntu-latest", "windows-latest"},
},
{
name: "merge runsOn replaces existing dst",
dst: &AppConfig{RunsOn: []string{"macos-latest"}},
src: &AppConfig{RunsOn: []string{"ubuntu-latest", "windows-latest"}},
expected: []string{"ubuntu-latest", "windows-latest"},
},
{
name: "empty src does not affect dst",
dst: &AppConfig{RunsOn: []string{"ubuntu-latest"}},
src: &AppConfig{},
expected: []string{"ubuntu-latest"},
},
{
name: "empty src slice does not affect dst",
dst: &AppConfig{RunsOn: []string{"ubuntu-latest"}},
src: &AppConfig{RunsOn: []string{}},
expected: []string{"ubuntu-latest"},
},
{
name: "single item slice",
dst: &AppConfig{},
src: &AppConfig{RunsOn: []string{"self-hosted"}},
expected: []string{"self-hosted"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mergeSliceFields(tt.dst, tt.src)
// Compare slices manually since they can't be compared directly
if len(tt.expected) != len(tt.dst.RunsOn) {
t.Errorf("expected slice length %d, got %d", len(tt.expected), len(tt.dst.RunsOn))
return
}
for i, expected := range tt.expected {
if i >= len(tt.dst.RunsOn) || tt.dst.RunsOn[i] != expected {
t.Errorf("expected %v, got %v", tt.expected, tt.dst.RunsOn)
return
}
}
})
}
}

View File

@@ -0,0 +1,446 @@
package internal
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/adrg/xdg"
"github.com/spf13/viper"
)
// ConfigurationSource represents different sources of configuration.
type ConfigurationSource int
// Configuration source priority order (lowest to highest priority).
const (
// SourceDefaults represents default configuration values.
SourceDefaults ConfigurationSource = iota
SourceGlobal
SourceRepoOverride
SourceRepoConfig
SourceActionConfig
SourceEnvironment
SourceCLIFlags
)
// ConfigurationLoader handles loading and merging configuration from multiple sources.
type ConfigurationLoader struct {
// sources tracks which sources are enabled
sources map[ConfigurationSource]bool
// viper instance for global configuration
viper *viper.Viper
}
// ConfigurationOptions configures how configuration loading behaves.
type ConfigurationOptions struct {
// ConfigFile specifies a custom global config file path
ConfigFile string
// AllowTokens controls whether security-sensitive fields can be loaded
AllowTokens bool
// EnabledSources controls which configuration sources are used
EnabledSources []ConfigurationSource
}
// NewConfigurationLoader creates a new configuration loader with default options.
func NewConfigurationLoader() *ConfigurationLoader {
return &ConfigurationLoader{
sources: map[ConfigurationSource]bool{
SourceDefaults: true,
SourceGlobal: true,
SourceRepoOverride: true,
SourceRepoConfig: true,
SourceActionConfig: true,
SourceEnvironment: true,
SourceCLIFlags: false, // CLI flags are applied separately
},
viper: viper.New(),
}
}
// NewConfigurationLoaderWithOptions creates a configuration loader with custom options.
func NewConfigurationLoaderWithOptions(opts ConfigurationOptions) *ConfigurationLoader {
loader := &ConfigurationLoader{
sources: make(map[ConfigurationSource]bool),
viper: viper.New(),
}
// Set default sources if none specified
if len(opts.EnabledSources) == 0 {
opts.EnabledSources = []ConfigurationSource{
SourceDefaults, SourceGlobal, SourceRepoOverride,
SourceRepoConfig, SourceActionConfig, SourceEnvironment,
}
}
// Configure enabled sources
for _, source := range opts.EnabledSources {
loader.sources[source] = true
}
return loader
}
// LoadConfiguration loads configuration with multi-level hierarchy.
func (cl *ConfigurationLoader) LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, error) {
config := &AppConfig{}
cl.loadDefaultsStep(config)
if err := cl.loadGlobalStep(config, configFile); err != nil {
return nil, err
}
cl.loadRepoOverrideStep(config, repoRoot)
if err := cl.loadRepoConfigStep(config, repoRoot); err != nil {
return nil, err
}
if err := cl.loadActionConfigStep(config, actionDir); err != nil {
return nil, err
}
cl.loadEnvironmentStep(config)
return config, nil
}
// loadDefaultsStep loads default configuration values.
func (cl *ConfigurationLoader) loadDefaultsStep(config *AppConfig) {
if cl.sources[SourceDefaults] {
defaults := DefaultAppConfig()
*config = *defaults
}
}
// loadGlobalStep loads global configuration.
func (cl *ConfigurationLoader) loadGlobalStep(config *AppConfig, configFile string) error {
if !cl.sources[SourceGlobal] {
return nil
}
globalConfig, err := cl.loadGlobalConfig(configFile)
if err != nil {
return fmt.Errorf("failed to load global config: %w", err)
}
cl.mergeConfigs(config, globalConfig, true) // Allow tokens for global config
return nil
}
// loadRepoOverrideStep applies repo-specific overrides from global config.
func (cl *ConfigurationLoader) loadRepoOverrideStep(config *AppConfig, repoRoot string) {
if !cl.sources[SourceRepoOverride] || repoRoot == "" {
return
}
cl.applyRepoOverrides(config, repoRoot)
}
// loadRepoConfigStep loads repository root configuration.
func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot string) error {
if !cl.sources[SourceRepoConfig] || repoRoot == "" {
return nil
}
repoConfig, err := cl.loadRepoConfig(repoRoot)
if err != nil {
return fmt.Errorf("failed to load repo config: %w", err)
}
cl.mergeConfigs(config, repoConfig, false) // No tokens in repo config
return nil
}
// loadActionConfigStep loads action-specific configuration.
func (cl *ConfigurationLoader) loadActionConfigStep(config *AppConfig, actionDir string) error {
if !cl.sources[SourceActionConfig] || actionDir == "" {
return nil
}
actionConfig, err := cl.loadActionConfig(actionDir)
if err != nil {
return fmt.Errorf("failed to load action config: %w", err)
}
cl.mergeConfigs(config, actionConfig, false) // No tokens in action config
return nil
}
// loadEnvironmentStep applies environment variable overrides.
func (cl *ConfigurationLoader) loadEnvironmentStep(config *AppConfig) {
if cl.sources[SourceEnvironment] {
cl.applyEnvironmentOverrides(config)
}
}
// LoadGlobalConfig loads only the global configuration.
func (cl *ConfigurationLoader) LoadGlobalConfig(configFile string) (*AppConfig, error) {
return cl.loadGlobalConfig(configFile)
}
// ValidateConfiguration validates a configuration for consistency and required values.
func (cl *ConfigurationLoader) ValidateConfiguration(config *AppConfig) error {
if config == nil {
return fmt.Errorf("configuration cannot be nil")
}
// Validate output format
validFormats := []string{"md", "html", "json", "asciidoc"}
if !containsString(validFormats, config.OutputFormat) {
return fmt.Errorf("invalid output format '%s', must be one of: %s",
config.OutputFormat, strings.Join(validFormats, ", "))
}
// Validate theme (if set)
if config.Theme != "" {
if err := cl.validateTheme(config.Theme); err != nil {
return fmt.Errorf("invalid theme: %w", err)
}
}
// Validate output directory
if config.OutputDir == "" {
return fmt.Errorf("output directory cannot be empty")
}
// Validate mutually exclusive flags
if config.Verbose && config.Quiet {
return fmt.Errorf("verbose and quiet flags are mutually exclusive")
}
return nil
}
// loadGlobalConfig initializes and loads the global configuration using Viper.
func (cl *ConfigurationLoader) loadGlobalConfig(configFile string) (*AppConfig, error) {
v := viper.New()
// Set configuration file name and type
v.SetConfigName(ConfigFileName)
v.SetConfigType("yaml")
// Add XDG-compliant configuration directory
configDir, err := xdg.ConfigFile("gh-action-readme")
if err != nil {
return nil, fmt.Errorf("failed to get XDG config directory: %w", err)
}
v.AddConfigPath(filepath.Dir(configDir))
// Add additional search paths
v.AddConfigPath(".") // current directory
v.AddConfigPath("$HOME/.config/gh-action-readme") // fallback
v.AddConfigPath("/etc/gh-action-readme") // system-wide
// Set environment variable prefix
v.SetEnvPrefix("GH_ACTION_README")
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
v.AutomaticEnv()
// Set defaults
cl.setViperDefaults(v)
// Use specific config file if provided
if configFile != "" {
v.SetConfigFile(configFile)
}
// Read configuration
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Config file not found is not an error - we'll use defaults and env vars
}
// Unmarshal configuration into struct
var config AppConfig
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Resolve template paths relative to binary if they're not absolute
config.Template = resolveTemplatePath(config.Template)
config.Header = resolveTemplatePath(config.Header)
config.Footer = resolveTemplatePath(config.Footer)
config.Schema = resolveTemplatePath(config.Schema)
return &config, nil
}
// loadRepoConfig loads repository-level configuration from hidden config files.
func (cl *ConfigurationLoader) loadRepoConfig(repoRoot string) (*AppConfig, error) {
// Hidden config file paths in priority order
configPaths := []string{
".ghreadme.yaml", // Primary hidden config
".config/ghreadme.yaml", // Secondary hidden config
".github/ghreadme.yaml", // GitHub ecosystem standard
}
for _, configName := range configPaths {
configPath := filepath.Join(repoRoot, configName)
if _, err := os.Stat(configPath); err == nil {
// Config file found, load it
return cl.loadConfigFromFile(configPath)
}
}
// No config found, return empty config
return &AppConfig{}, nil
}
// loadActionConfig loads action-level configuration from config.yaml.
func (cl *ConfigurationLoader) loadActionConfig(actionDir string) (*AppConfig, error) {
configPath := filepath.Join(actionDir, "config.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return &AppConfig{}, nil // No action config is fine
}
return cl.loadConfigFromFile(configPath)
}
// loadConfigFromFile loads configuration from a specific file.
func (cl *ConfigurationLoader) loadConfigFromFile(configPath string) (*AppConfig, error) {
v := viper.New()
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config %s: %w", configPath, err)
}
var config AppConfig
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return &config, nil
}
// applyRepoOverrides applies repository-specific overrides from global config.
func (cl *ConfigurationLoader) applyRepoOverrides(config *AppConfig, repoRoot string) {
repoName := DetectRepositoryName(repoRoot)
if repoName == "" {
return // No repository detected
}
if config.RepoOverrides == nil {
return // No overrides configured
}
if repoOverride, exists := config.RepoOverrides[repoName]; exists {
cl.mergeConfigs(config, &repoOverride, false) // No tokens in overrides
}
}
// applyEnvironmentOverrides applies environment variable overrides.
func (cl *ConfigurationLoader) applyEnvironmentOverrides(config *AppConfig) {
// Check environment variables directly with higher priority
if token := os.Getenv(EnvGitHubToken); token != "" {
config.GitHubToken = token
} else if token := os.Getenv(EnvGitHubTokenStandard); token != "" {
config.GitHubToken = token
}
}
// mergeConfigs merges a source config into a destination config.
func (cl *ConfigurationLoader) mergeConfigs(dst *AppConfig, src *AppConfig, allowTokens bool) {
MergeConfigs(dst, src, allowTokens)
}
// setViperDefaults sets default values in viper.
func (cl *ConfigurationLoader) setViperDefaults(v *viper.Viper) {
defaults := DefaultAppConfig()
v.SetDefault("organization", defaults.Organization)
v.SetDefault("repository", defaults.Repository)
v.SetDefault("version", defaults.Version)
v.SetDefault("theme", defaults.Theme)
v.SetDefault("output_format", defaults.OutputFormat)
v.SetDefault("output_dir", defaults.OutputDir)
v.SetDefault("template", defaults.Template)
v.SetDefault("header", defaults.Header)
v.SetDefault("footer", defaults.Footer)
v.SetDefault("schema", defaults.Schema)
v.SetDefault("analyze_dependencies", defaults.AnalyzeDependencies)
v.SetDefault("show_security_info", defaults.ShowSecurityInfo)
v.SetDefault("verbose", defaults.Verbose)
v.SetDefault("quiet", defaults.Quiet)
v.SetDefault("defaults.name", defaults.Defaults.Name)
v.SetDefault("defaults.description", defaults.Defaults.Description)
v.SetDefault("defaults.branding.icon", defaults.Defaults.Branding.Icon)
v.SetDefault("defaults.branding.color", defaults.Defaults.Branding.Color)
}
// validateTheme validates that a theme exists and is supported.
func (cl *ConfigurationLoader) validateTheme(theme string) error {
if theme == "" {
return fmt.Errorf("theme cannot be empty")
}
// Check if it's a built-in theme
supportedThemes := []string{"default", "github", "gitlab", "minimal", "professional"}
if containsString(supportedThemes, theme) {
return nil
}
// Check if it's a custom template path
if filepath.IsAbs(theme) || strings.Contains(theme, "/") {
// Assume it's a custom template path - we can't easily validate without filesystem access
return nil
}
return fmt.Errorf("unsupported theme '%s', must be one of: %s",
theme, strings.Join(supportedThemes, ", "))
}
// containsString checks if a slice contains a string.
func containsString(slice []string, str string) bool {
for _, s := range slice {
if s == str {
return true
}
}
return false
}
// GetConfigurationSources returns the currently enabled configuration sources.
func (cl *ConfigurationLoader) GetConfigurationSources() []ConfigurationSource {
var sources []ConfigurationSource
for source, enabled := range cl.sources {
if enabled {
sources = append(sources, source)
}
}
return sources
}
// EnableSource enables a specific configuration source.
func (cl *ConfigurationLoader) EnableSource(source ConfigurationSource) {
cl.sources[source] = true
}
// DisableSource disables a specific configuration source.
func (cl *ConfigurationLoader) DisableSource(source ConfigurationSource) {
cl.sources[source] = false
}
// String returns a string representation of a ConfigurationSource.
func (s ConfigurationSource) String() string {
switch s {
case SourceDefaults:
return "defaults"
case SourceGlobal:
return "global"
case SourceRepoOverride:
return "repo-override"
case SourceRepoConfig:
return "repo-config"
case SourceActionConfig:
return "action-config"
case SourceEnvironment:
return "environment"
case SourceCLIFlags:
return "cli-flags"
default:
return "unknown"
}
}

View File

@@ -0,0 +1,802 @@
package internal
import (
"os"
"path/filepath"
"testing"
"github.com/ivuorinen/gh-action-readme/testutil"
)
func TestNewConfigurationLoader(t *testing.T) {
loader := NewConfigurationLoader()
if loader == nil {
t.Fatal("expected non-nil loader")
}
if loader.viper == nil {
t.Fatal("expected viper instance to be initialized")
}
// Check default sources are enabled
expectedSources := []ConfigurationSource{
SourceDefaults, SourceGlobal, SourceRepoOverride,
SourceRepoConfig, SourceActionConfig, SourceEnvironment,
}
for _, source := range expectedSources {
if !loader.sources[source] {
t.Errorf("expected source %s to be enabled by default", source.String())
}
}
// CLI flags should be disabled by default
if loader.sources[SourceCLIFlags] {
t.Error("expected CLI flags source to be disabled by default")
}
}
func TestNewConfigurationLoaderWithOptions(t *testing.T) {
tests := []struct {
name string
opts ConfigurationOptions
expected []ConfigurationSource
}{
{
name: "default options",
opts: ConfigurationOptions{},
expected: []ConfigurationSource{
SourceDefaults, SourceGlobal, SourceRepoOverride,
SourceRepoConfig, SourceActionConfig, SourceEnvironment,
},
},
{
name: "custom enabled sources",
opts: ConfigurationOptions{
EnabledSources: []ConfigurationSource{SourceDefaults, SourceGlobal},
},
expected: []ConfigurationSource{SourceDefaults, SourceGlobal},
},
{
name: "all sources enabled",
opts: ConfigurationOptions{
EnabledSources: []ConfigurationSource{
SourceDefaults, SourceGlobal, SourceRepoOverride,
SourceRepoConfig, SourceActionConfig, SourceEnvironment, SourceCLIFlags,
},
},
expected: []ConfigurationSource{
SourceDefaults, SourceGlobal, SourceRepoOverride,
SourceRepoConfig, SourceActionConfig, SourceEnvironment, SourceCLIFlags,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
loader := NewConfigurationLoaderWithOptions(tt.opts)
for _, expectedSource := range tt.expected {
if !loader.sources[expectedSource] {
t.Errorf("expected source %s to be enabled", expectedSource.String())
}
}
// Check that non-expected sources are disabled
allSources := []ConfigurationSource{
SourceDefaults, SourceGlobal, SourceRepoOverride,
SourceRepoConfig, SourceActionConfig, SourceEnvironment, SourceCLIFlags,
}
for _, source := range allSources {
expected := false
for _, expectedSource := range tt.expected {
if source == expectedSource {
expected = true
break
}
}
if loader.sources[source] != expected {
t.Errorf("source %s enabled=%v, expected=%v", source.String(), loader.sources[source], expected)
}
}
})
}
}
func TestConfigurationLoader_LoadConfiguration(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T, tempDir string) (configFile, repoRoot, actionDir string)
expectError bool
checkFunc func(t *testing.T, config *AppConfig)
}{
{
name: "defaults only",
setupFunc: func(_ *testing.T, _ string) (string, string, string) {
return "", "", ""
},
checkFunc: func(_ *testing.T, config *AppConfig) {
testutil.AssertEqual(t, "default", config.Theme)
testutil.AssertEqual(t, "md", config.OutputFormat)
testutil.AssertEqual(t, ".", config.OutputDir)
},
},
{
name: "multi-level configuration hierarchy",
setupFunc: func(_ *testing.T, tempDir string) (string, string, string) {
// Create global config
globalConfigDir := filepath.Join(tempDir, ".config", "gh-action-readme")
_ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions
globalConfigPath := filepath.Join(globalConfigDir, "config.yaml")
testutil.WriteTestFile(t, globalConfigPath, `
theme: default
output_format: md
github_token: global-token
verbose: false
`)
// Create repo root with repo-specific config
repoRoot := filepath.Join(tempDir, "repo")
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
theme: github
output_format: html
verbose: true
`)
// Create action directory with action-specific config
actionDir := filepath.Join(repoRoot, "action")
_ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(actionDir, "config.yaml"), `
theme: professional
output_dir: output
quiet: false
`)
return globalConfigPath, repoRoot, actionDir
},
checkFunc: func(_ *testing.T, config *AppConfig) {
// Should have action-level overrides
testutil.AssertEqual(t, "professional", config.Theme)
testutil.AssertEqual(t, "output", config.OutputDir)
// Should inherit from repo level
testutil.AssertEqual(t, "html", config.OutputFormat)
testutil.AssertEqual(t, true, config.Verbose)
// Should inherit GitHub token from global config
testutil.AssertEqual(t, "global-token", config.GitHubToken)
},
},
{
name: "environment variable overrides",
setupFunc: func(_ *testing.T, tempDir string) (string, string, string) {
// Set environment variables
_ = os.Setenv("GH_README_GITHUB_TOKEN", "env-token")
t.Cleanup(func() {
_ = os.Unsetenv("GH_README_GITHUB_TOKEN")
})
// Create config file with different token
configPath := filepath.Join(tempDir, "config.yml")
testutil.WriteTestFile(t, configPath, `
theme: minimal
github_token: config-token
`)
return configPath, tempDir, ""
},
checkFunc: func(_ *testing.T, config *AppConfig) {
// Environment variable should override config file
testutil.AssertEqual(t, "env-token", config.GitHubToken)
testutil.AssertEqual(t, "minimal", config.Theme)
},
},
{
name: "hidden config file priority",
setupFunc: func(_ *testing.T, tempDir string) (string, string, string) {
repoRoot := filepath.Join(tempDir, "repo")
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
// Create multiple hidden config files - first one should win
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
theme: minimal
output_format: json
`)
configDir := filepath.Join(repoRoot, ".config")
_ = os.MkdirAll(configDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(configDir, "ghreadme.yaml"), `
theme: professional
quiet: true
`)
githubDir := filepath.Join(repoRoot, ".github")
_ = os.MkdirAll(githubDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(githubDir, "ghreadme.yaml"), `
theme: github
verbose: true
`)
return "", repoRoot, ""
},
checkFunc: func(_ *testing.T, config *AppConfig) {
// Should use the first found config (.ghreadme.yaml has priority)
testutil.AssertEqual(t, "minimal", config.Theme)
testutil.AssertEqual(t, "json", config.OutputFormat)
},
},
{
name: "selective source loading",
setupFunc: func(_ *testing.T, _ string) (string, string, string) {
// This test uses a loader with specific sources enabled
return "", "", ""
},
checkFunc: func(_ *testing.T, _ *AppConfig) {
// This will be tested with a custom loader
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Set HOME to temp directory for fallback
originalHome := os.Getenv("HOME")
_ = os.Setenv("HOME", tmpDir)
defer func() {
if originalHome != "" {
_ = os.Setenv("HOME", originalHome)
} else {
_ = os.Unsetenv("HOME")
}
}()
configFile, repoRoot, actionDir := tt.setupFunc(t, tmpDir)
// Special handling for selective source loading test
var loader *ConfigurationLoader
if tt.name == "selective source loading" {
// Create loader with only defaults and global sources
loader = NewConfigurationLoaderWithOptions(ConfigurationOptions{
EnabledSources: []ConfigurationSource{SourceDefaults, SourceGlobal},
})
} else {
loader = NewConfigurationLoader()
}
config, err := loader.LoadConfiguration(configFile, repoRoot, actionDir)
if tt.expectError {
testutil.AssertError(t, err)
return
}
testutil.AssertNoError(t, err)
if tt.checkFunc != nil {
tt.checkFunc(t, config)
}
})
}
}
func TestConfigurationLoader_LoadGlobalConfig(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T, tempDir string) string
expectError bool
checkFunc func(t *testing.T, config *AppConfig)
}{
{
name: "valid global config",
setupFunc: func(t *testing.T, tempDir string) string {
configPath := filepath.Join(tempDir, "config.yaml")
testutil.WriteTestFile(t, configPath, `
theme: professional
output_format: html
github_token: test-token
verbose: true
`)
return configPath
},
checkFunc: func(_ *testing.T, config *AppConfig) {
testutil.AssertEqual(t, "professional", config.Theme)
testutil.AssertEqual(t, "html", config.OutputFormat)
testutil.AssertEqual(t, "test-token", config.GitHubToken)
testutil.AssertEqual(t, true, config.Verbose)
},
},
{
name: "nonexistent config file",
setupFunc: func(_ *testing.T, tempDir string) string {
return filepath.Join(tempDir, "nonexistent.yaml")
},
expectError: true,
},
{
name: "invalid YAML",
setupFunc: func(t *testing.T, tempDir string) string {
configPath := filepath.Join(tempDir, "invalid.yaml")
testutil.WriteTestFile(t, configPath, "invalid: yaml: content: [")
return configPath
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Set HOME to temp directory
originalHome := os.Getenv("HOME")
_ = os.Setenv("HOME", tmpDir)
defer func() {
if originalHome != "" {
_ = os.Setenv("HOME", originalHome)
} else {
_ = os.Unsetenv("HOME")
}
}()
configFile := tt.setupFunc(t, tmpDir)
loader := NewConfigurationLoader()
config, err := loader.LoadGlobalConfig(configFile)
if tt.expectError {
testutil.AssertError(t, err)
return
}
testutil.AssertNoError(t, err)
if tt.checkFunc != nil {
tt.checkFunc(t, config)
}
})
}
}
func TestConfigurationLoader_ValidateConfiguration(t *testing.T) {
tests := []struct {
name string
config *AppConfig
expectError bool
errorMsg string
}{
{
name: "nil config",
config: nil,
expectError: true,
errorMsg: "configuration cannot be nil",
},
{
name: "valid config",
config: &AppConfig{
Theme: "default",
OutputFormat: "md",
OutputDir: ".",
Verbose: false,
Quiet: false,
},
expectError: false,
},
{
name: "invalid output format",
config: &AppConfig{
Theme: "default",
OutputFormat: "invalid",
OutputDir: ".",
},
expectError: true,
errorMsg: "invalid output format",
},
{
name: "empty output directory",
config: &AppConfig{
Theme: "default",
OutputFormat: "md",
OutputDir: "",
},
expectError: true,
errorMsg: "output directory cannot be empty",
},
{
name: "verbose and quiet both true",
config: &AppConfig{
Theme: "default",
OutputFormat: "md",
OutputDir: ".",
Verbose: true,
Quiet: true,
},
expectError: true,
errorMsg: "verbose and quiet flags are mutually exclusive",
},
{
name: "invalid theme",
config: &AppConfig{
Theme: "nonexistent",
OutputFormat: "md",
OutputDir: ".",
},
expectError: true,
errorMsg: "invalid theme",
},
{
name: "valid built-in themes",
config: &AppConfig{
Theme: "github",
OutputFormat: "html",
OutputDir: "docs",
},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
loader := NewConfigurationLoader()
err := loader.ValidateConfiguration(tt.config)
if tt.expectError {
testutil.AssertError(t, err)
if tt.errorMsg != "" {
testutil.AssertStringContains(t, err.Error(), tt.errorMsg)
}
} else {
testutil.AssertNoError(t, err)
}
})
}
}
func TestConfigurationLoader_SourceManagement(t *testing.T) {
loader := NewConfigurationLoader()
// Test initial state
sources := loader.GetConfigurationSources()
if len(sources) != 6 { // All except CLI flags
t.Errorf("expected 6 enabled sources, got %d", len(sources))
}
// Test disabling a source
loader.DisableSource(SourceGlobal)
if loader.sources[SourceGlobal] {
t.Error("expected SourceGlobal to be disabled")
}
// Test enabling a source
loader.EnableSource(SourceCLIFlags)
if !loader.sources[SourceCLIFlags] {
t.Error("expected SourceCLIFlags to be enabled")
}
// Test updated sources list
sources = loader.GetConfigurationSources()
expectedCount := 6 // 5 original + CLI flags - Global
if len(sources) != expectedCount {
t.Errorf("expected %d enabled sources, got %d", expectedCount, len(sources))
}
}
func TestConfigurationSource_String(t *testing.T) {
tests := []struct {
source ConfigurationSource
expected string
}{
{SourceDefaults, "defaults"},
{SourceGlobal, "global"},
{SourceRepoOverride, "repo-override"},
{SourceRepoConfig, "repo-config"},
{SourceActionConfig, "action-config"},
{SourceEnvironment, "environment"},
{SourceCLIFlags, "cli-flags"},
{ConfigurationSource(999), "unknown"},
}
for _, tt := range tests {
result := tt.source.String()
if result != tt.expected {
t.Errorf("source %d String() = %s, expected %s", int(tt.source), result, tt.expected)
}
}
}
func TestConfigurationLoader_EnvironmentOverrides(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T) func()
expectedToken string
}{
{
name: "GH_README_GITHUB_TOKEN priority",
setupFunc: func(_ *testing.T) func() {
_ = os.Setenv("GH_README_GITHUB_TOKEN", "priority-token")
_ = os.Setenv("GITHUB_TOKEN", "fallback-token")
return func() {
_ = os.Unsetenv("GH_README_GITHUB_TOKEN")
_ = os.Unsetenv("GITHUB_TOKEN")
}
},
expectedToken: "priority-token",
},
{
name: "GITHUB_TOKEN fallback",
setupFunc: func(_ *testing.T) func() {
_ = os.Unsetenv("GH_README_GITHUB_TOKEN")
_ = os.Setenv("GITHUB_TOKEN", "fallback-token")
return func() {
_ = os.Unsetenv("GITHUB_TOKEN")
}
},
expectedToken: "fallback-token",
},
{
name: "no environment variables",
setupFunc: func(_ *testing.T) func() {
_ = os.Unsetenv("GH_README_GITHUB_TOKEN")
_ = os.Unsetenv("GITHUB_TOKEN")
return func() {}
},
expectedToken: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cleanup := tt.setupFunc(t)
defer cleanup()
tmpDir, tmpCleanup := testutil.TempDir(t)
defer tmpCleanup()
loader := NewConfigurationLoader()
config, err := loader.LoadConfiguration("", tmpDir, "")
testutil.AssertNoError(t, err)
testutil.AssertEqual(t, tt.expectedToken, config.GitHubToken)
})
}
}
func TestConfigurationLoader_RepoOverrides(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Create a mock git repository structure for testing
repoRoot := filepath.Join(tmpDir, "test-repo")
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
// Create global config with repo overrides
globalConfigDir := filepath.Join(tmpDir, ".config", "gh-action-readme")
_ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions
globalConfigPath := filepath.Join(globalConfigDir, "config.yaml")
globalConfigContent := "theme: default\n"
globalConfigContent += "output_format: md\n"
globalConfigContent += "repo_overrides:\n"
globalConfigContent += " test-repo:\n"
globalConfigContent += " theme: github\n"
globalConfigContent += " output_format: html\n"
globalConfigContent += " verbose: true\n"
testutil.WriteTestFile(t, globalConfigPath, globalConfigContent)
// Set environment for XDG compliance
originalHome := os.Getenv("HOME")
_ = os.Setenv("HOME", tmpDir)
defer func() {
if originalHome != "" {
_ = os.Setenv("HOME", originalHome)
} else {
_ = os.Unsetenv("HOME")
}
}()
loader := NewConfigurationLoader()
config, err := loader.LoadConfiguration(globalConfigPath, repoRoot, "")
testutil.AssertNoError(t, err)
// Note: Since we don't have actual git repository detection in this test,
// repo overrides won't be applied. This test validates the structure works.
testutil.AssertEqual(t, "default", config.Theme)
testutil.AssertEqual(t, "md", config.OutputFormat)
}
// TestConfigurationLoader_ApplyRepoOverrides tests repo-specific overrides.
func TestConfigurationLoader_ApplyRepoOverrides(t *testing.T) {
tests := []struct {
name string
config *AppConfig
expectedTheme string
expectedFormat string
}{
{
name: "no repo overrides configured",
config: &AppConfig{
Theme: "default",
OutputFormat: "md",
RepoOverrides: nil,
},
expectedTheme: "default",
expectedFormat: "md",
},
{
name: "empty repo overrides map",
config: &AppConfig{
Theme: "default",
OutputFormat: "md",
RepoOverrides: map[string]AppConfig{},
},
expectedTheme: "default",
expectedFormat: "md",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
loader := NewConfigurationLoader()
loader.applyRepoOverrides(tt.config, tmpDir)
testutil.AssertEqual(t, tt.expectedTheme, tt.config.Theme)
testutil.AssertEqual(t, tt.expectedFormat, tt.config.OutputFormat)
})
}
}
// TestConfigurationLoader_LoadActionConfig tests action-specific configuration loading.
func TestConfigurationLoader_LoadActionConfig(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T, tmpDir string) string
expectError bool
expectedVals map[string]string
}{
{
name: "no action directory provided",
setupFunc: func(_ *testing.T, _ string) string {
return ""
},
expectError: false,
expectedVals: map[string]string{},
},
{
name: "action directory with config file",
setupFunc: func(t *testing.T, tmpDir string) string {
actionDir := filepath.Join(tmpDir, "action")
_ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions
configPath := filepath.Join(actionDir, "config.yaml")
testutil.WriteTestFile(t, configPath, `
theme: minimal
output_format: json
verbose: true
`)
return actionDir
},
expectError: false,
expectedVals: map[string]string{
"theme": "minimal",
"output_format": "json",
},
},
{
name: "action directory with malformed config file",
setupFunc: func(t *testing.T, tmpDir string) string {
actionDir := filepath.Join(tmpDir, "action")
_ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions
configPath := filepath.Join(actionDir, "config.yaml")
testutil.WriteTestFile(t, configPath, "invalid yaml content:\n - broken [")
return actionDir
},
expectError: false, // Function may handle YAML errors gracefully
expectedVals: map[string]string{},
},
{
name: "action directory without config file",
setupFunc: func(_ *testing.T, tmpDir string) string {
actionDir := filepath.Join(tmpDir, "action")
_ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions
return actionDir
},
expectError: false,
expectedVals: map[string]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
actionDir := tt.setupFunc(t, tmpDir)
loader := NewConfigurationLoader()
config, err := loader.loadActionConfig(actionDir)
if tt.expectError {
testutil.AssertError(t, err)
} else {
testutil.AssertNoError(t, err)
// Check expected values if no error
if config != nil {
for key, expected := range tt.expectedVals {
switch key {
case "theme":
testutil.AssertEqual(t, expected, config.Theme)
case "output_format":
testutil.AssertEqual(t, expected, config.OutputFormat)
}
}
}
}
})
}
}
// TestConfigurationLoader_ValidateTheme tests theme validation edge cases.
func TestConfigurationLoader_ValidateTheme(t *testing.T) {
tests := []struct {
name string
theme string
expectError bool
}{
{
name: "valid built-in theme",
theme: "github",
expectError: false,
},
{
name: "valid default theme",
theme: "default",
expectError: false,
},
{
name: "empty theme returns error",
theme: "",
expectError: true,
},
{
name: "invalid theme",
theme: "nonexistent-theme",
expectError: true,
},
{
name: "case sensitive theme",
theme: "GitHub",
expectError: true,
},
{
name: "custom theme path",
theme: "/custom/theme/path.tmpl",
expectError: false,
},
{
name: "relative theme path",
theme: "custom/theme.tmpl",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
loader := NewConfigurationLoader()
err := loader.validateTheme(tt.theme)
if tt.expectError {
testutil.AssertError(t, err)
} else {
testutil.AssertNoError(t, err)
}
})
}
}

109
internal/constants.go Normal file
View File

@@ -0,0 +1,109 @@
// Package internal provides common constants used throughout the application.
package internal
// File extension constants.
const (
// ActionFileExtYML is the primary action file extension.
ActionFileExtYML = ".yml"
// ActionFileExtYAML is the alternative action file extension.
ActionFileExtYAML = ".yaml"
// ActionFileNameYML is the primary action file name.
ActionFileNameYML = "action.yml"
// ActionFileNameYAML is the alternative action file name.
ActionFileNameYAML = "action.yaml"
)
// File permission constants.
const (
// FilePermDefault is the default file permission for created files.
FilePermDefault = 0600
// FilePermTest is the file permission used in tests.
FilePermTest = 0600
)
// Configuration file constants.
const (
// ConfigFileName is the primary configuration file name.
ConfigFileName = "config"
// ConfigFileExtYAML is the configuration file extension.
ConfigFileExtYAML = ".yaml"
// ConfigFileNameFull is the full configuration file name.
ConfigFileNameFull = ConfigFileName + ConfigFileExtYAML
)
// Context key constants for maps and data structures.
const (
// ContextKeyError is used as a key for error information in context maps.
ContextKeyError = "error"
// ContextKeyTheme is used as a key for theme information.
ContextKeyTheme = "theme"
// ContextKeyConfig is used as a key for configuration information.
ContextKeyConfig = "config"
)
// Common string identifiers.
const (
// ThemeGitHub is the GitHub theme identifier.
ThemeGitHub = "github"
// ThemeGitLab is the GitLab theme identifier.
ThemeGitLab = "gitlab"
// ThemeMinimal is the minimal theme identifier.
ThemeMinimal = "minimal"
// ThemeProfessional is the professional theme identifier.
ThemeProfessional = "professional"
// ThemeDefault is the default theme identifier.
ThemeDefault = "default"
)
// Environment variable names.
const (
// EnvGitHubToken is the tool-specific GitHub token environment variable.
EnvGitHubToken = "GH_README_GITHUB_TOKEN" // #nosec G101 -- environment variable name, not a credential
// EnvGitHubTokenStandard is the standard GitHub token environment variable.
EnvGitHubTokenStandard = "GITHUB_TOKEN" // #nosec G101 -- environment variable name, not a credential
)
// Configuration keys and paths.
const (
// ConfigKeyGitHubToken is the configuration key for GitHub token.
ConfigKeyGitHubToken = "github_token"
// ConfigKeyTheme is the configuration key for theme.
ConfigKeyTheme = "theme"
// ConfigKeyOutputFormat is the configuration key for output format.
ConfigKeyOutputFormat = "output_format"
// ConfigKeyOutputDir is the configuration key for output directory.
ConfigKeyOutputDir = "output_dir"
// ConfigKeyVerbose is the configuration key for verbose mode.
ConfigKeyVerbose = "verbose"
// ConfigKeyQuiet is the configuration key for quiet mode.
ConfigKeyQuiet = "quiet"
// ConfigKeyAnalyzeDependencies is the configuration key for dependency analysis.
ConfigKeyAnalyzeDependencies = "analyze_dependencies"
// ConfigKeyShowSecurityInfo is the configuration key for security info display.
ConfigKeyShowSecurityInfo = "show_security_info"
)
// Template path constants.
const (
// TemplatePathDefault is the default template path.
TemplatePathDefault = "templates/readme.tmpl"
// TemplatePathGitHub is the GitHub theme template path.
TemplatePathGitHub = "templates/themes/github/readme.tmpl"
// TemplatePathGitLab is the GitLab theme template path.
TemplatePathGitLab = "templates/themes/gitlab/readme.tmpl"
// TemplatePathMinimal is the minimal theme template path.
TemplatePathMinimal = "templates/themes/minimal/readme.tmpl"
// TemplatePathProfessional is the professional theme template path.
TemplatePathProfessional = "templates/themes/professional/readme.tmpl"
)
// Config file search patterns.
const (
// ConfigFilePatternHidden is the primary hidden config file pattern.
ConfigFilePatternHidden = ".ghreadme.yaml"
// ConfigFilePatternConfig is the secondary config directory pattern.
ConfigFilePatternConfig = ".config/ghreadme.yaml"
// ConfigFilePatternGitHub is the GitHub ecosystem config pattern.
ConfigFilePatternGitHub = ".github/ghreadme.yaml"
)

View File

@@ -32,7 +32,43 @@ const (
updateTypeNone = "none"
updateTypeMajor = "major"
updateTypePatch = "patch"
updateTypeMinor = "minor"
defaultBranch = "main"
// Timeout constants.
apiCallTimeout = 10 * time.Second
cacheDefaultTTL = 1 * time.Hour
// File permission constants.
backupFilePerms = 0600
updatedFilePerms = 0600
// GitHub URL patterns.
githubBaseURL = "https://github.com"
marketplaceBaseURL = "https://github.com/marketplace/actions/"
// Version parsing constants.
fullSHALength = 40
minSHALength = 7
versionPartsCount = 3
// File path patterns.
dockerPrefix = "docker://"
localPathPrefix = "./"
localPathUpPrefix = "../"
// File extensions.
backupExtension = ".backup"
// Cache key prefixes.
cacheKeyLatest = "latest:"
cacheKeyRepo = "repo:"
// YAML structure constants.
usesFieldPrefix = "uses: "
// Special line estimation for script URLs.
scriptLineEstimate = 10
)
// Dependency represents a GitHub Action dependency with detailed information.
@@ -223,7 +259,7 @@ func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependen
VersionType: versionType,
IsPinned: versionType == CommitSHA || (versionType == SemanticVersion && a.isVersionPinned(version)),
Author: owner,
SourceURL: fmt.Sprintf("https://github.com/%s/%s", owner, repo),
SourceURL: fmt.Sprintf("%s/%s/%s", githubBaseURL, owner, repo),
IsLocalAction: isLocal,
IsShellScript: false,
WithParams: a.convertWithParams(step.With),
@@ -231,7 +267,7 @@ func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependen
// Add marketplace URL for public actions
if !isLocal {
dep.MarketplaceURL = fmt.Sprintf("https://github.com/marketplace/actions/%s", repo)
dep.MarketplaceURL = marketplaceBaseURL + repo
}
// Fetch additional metadata from GitHub API if available
@@ -254,8 +290,14 @@ func (a *Analyzer) analyzeShellScript(step CompositeStep, stepNumber int) *Depen
scriptURL := ""
if a.RepoInfo.Organization != "" && a.RepoInfo.Repository != "" {
// This would ideally link to the specific line in the action.yml file
scriptURL = fmt.Sprintf("https://github.com/%s/%s/blob/%s/action.yml#L%d",
a.RepoInfo.Organization, a.RepoInfo.Repository, a.RepoInfo.DefaultBranch, stepNumber*10) // Rough estimate
scriptURL = fmt.Sprintf(
"%s/%s/%s/blob/%s/action.yml#L%d",
githubBaseURL,
a.RepoInfo.Organization,
a.RepoInfo.Repository,
a.RepoInfo.DefaultBranch,
stepNumber*scriptLineEstimate,
) // Rough estimate
}
return &Dependency{
@@ -283,11 +325,11 @@ func (a *Analyzer) parseUsesStatement(uses string) (owner, repo, version string,
// - ./local-action
// - docker://alpine:3.14
if strings.HasPrefix(uses, "./") || strings.HasPrefix(uses, "../") {
if strings.HasPrefix(uses, localPathPrefix) || strings.HasPrefix(uses, localPathUpPrefix) {
return "", "", uses, LocalPath
}
if strings.HasPrefix(uses, "docker://") {
if strings.HasPrefix(uses, dockerPrefix) {
return "", "", uses, LocalPath
}
@@ -319,7 +361,7 @@ func (a *Analyzer) parseUsesStatement(uses string) (owner, repo, version string,
func (a *Analyzer) isCommitSHA(version string) bool {
// Check if it's a 40-character hex string (full SHA) or 7+ character hex (short SHA)
re := regexp.MustCompile(`^[a-f0-9]{7,40}$`)
return len(version) >= 7 && re.MatchString(version)
return len(version) >= minSHALength && re.MatchString(version)
}
// isSemanticVersion checks if a version string follows semantic versioning.
@@ -333,7 +375,7 @@ func (a *Analyzer) isSemanticVersion(version string) bool {
func (a *Analyzer) isVersionPinned(version string) bool {
// Consider it pinned if it specifies patch version (v1.2.3) or is a commit SHA
// Also check for full commit SHAs (40 chars)
if len(version) == 40 {
if len(version) == fullSHALength {
return true
}
re := regexp.MustCompile(`^v?\d+\.\d+\.\d+`)
@@ -393,11 +435,11 @@ func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, er
return "", "", fmt.Errorf("GitHub client not available")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout)
defer cancel()
// Check cache first
cacheKey := fmt.Sprintf("latest:%s/%s", owner, repo)
cacheKey := cacheKeyLatest + fmt.Sprintf("%s/%s", owner, repo)
if version, sha, found := a.getCachedVersion(cacheKey); found {
return version, sha, nil
}
@@ -478,7 +520,7 @@ func (a *Analyzer) cacheVersion(cacheKey, version, sha string) {
}
versionInfo := map[string]string{"version": version, "sha": sha}
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, 1*time.Hour)
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, cacheDefaultTTL)
}
// compareVersions compares two version strings and returns the update type.
@@ -505,7 +547,7 @@ func (a *Analyzer) compareVersions(current, latest string) string {
func (a *Analyzer) parseVersionParts(version string) []string {
parts := strings.Split(version, ".")
// For floating versions like "v4", treat as "v4.0.0" for comparison
for len(parts) < 3 {
for len(parts) < versionPartsCount {
parts = append(parts, "0")
}
return parts
@@ -517,7 +559,7 @@ func (a *Analyzer) determineUpdateType(currentParts, latestParts []string) strin
return updateTypeMajor
}
if currentParts[1] != latestParts[1] {
return "minor"
return updateTypeMinor
}
if currentParts[2] != latestParts[2] {
return updateTypePatch
@@ -573,14 +615,14 @@ func (a *Analyzer) ApplyPinnedUpdates(updates []PinnedUpdate) error {
// updateActionFile applies updates to a single action file.
func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) error {
// Read the file
content, err := os.ReadFile(filePath)
content, err := os.ReadFile(filePath) // #nosec G304 -- file path from function parameter
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Create backup
backupPath := filePath + ".backup"
if err := os.WriteFile(backupPath, content, 0644); err != nil {
backupPath := filePath + backupExtension
if err := os.WriteFile(backupPath, content, backupFilePerms); err != nil { // #nosec G306 -- backup file permissions
return fmt.Errorf("failed to create backup: %w", err)
}
@@ -592,8 +634,7 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
if strings.Contains(line, update.OldUses) {
// Replace the uses statement while preserving indentation
indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " ")))
usesPrefix := "uses: "
lines[i] = indent + usesPrefix + update.NewUses
lines[i] = indent + usesFieldPrefix + update.NewUses
update.LineNumber = i + 1 // Store line number for reference
break
}
@@ -602,7 +643,8 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
// Write updated content
updatedContent := strings.Join(lines, "\n")
if err := os.WriteFile(filePath, []byte(updatedContent), 0644); err != nil {
if err := os.WriteFile(filePath, []byte(updatedContent), updatedFilePerms); err != nil {
// #nosec G306 -- updated file permissions
return fmt.Errorf("failed to write updated file: %w", err)
}
@@ -629,11 +671,11 @@ func (a *Analyzer) validateActionFile(filePath string) error {
// enrichWithGitHubData fetches additional information from GitHub API.
func (a *Analyzer) enrichWithGitHubData(dep *Dependency, owner, repo string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout)
defer cancel()
// Check cache first
cacheKey := fmt.Sprintf("repo:%s/%s", owner, repo)
cacheKey := cacheKeyRepo + fmt.Sprintf("%s/%s", owner, repo)
if a.Cache != nil {
if cached, exists := a.Cache.Get(cacheKey); exists {
if repository, ok := cached.(*github.Repository); ok {
@@ -651,7 +693,7 @@ func (a *Analyzer) enrichWithGitHubData(dep *Dependency, owner, repo string) err
// Cache the result with 1 hour TTL
if a.Cache != nil {
_ = a.Cache.SetWithTTL(cacheKey, repository, 1*time.Hour) // Ignore cache errors
_ = a.Cache.SetWithTTL(cacheKey, repository, cacheDefaultTTL) // Ignore cache errors
}
// Enrich dependency with API data

View File

@@ -11,6 +11,7 @@ import (
"github.com/google/go-github/v57/github"
"github.com/ivuorinen/gh-action-readme/internal/cache"
"github.com/ivuorinen/gh-action-readme/internal/git"
"github.com/ivuorinen/gh-action-readme/testutil"
)
@@ -25,34 +26,34 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
}{
{
name: "simple action - no dependencies",
actionYML: testutil.SimpleActionYML,
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
expectError: false,
expectDeps: false,
expectedLen: 0,
},
{
name: "composite action with dependencies",
actionYML: testutil.CompositeActionYML,
actionYML: testutil.MustReadFixture("actions/composite/with-dependencies.yml"),
expectError: false,
expectDeps: true,
expectedLen: 3,
expectedDeps: []string{"actions/checkout@v4", "actions/setup-node@v3"},
expectedLen: 5, // 3 action dependencies + 2 shell script dependencies
expectedDeps: []string{"actions/checkout@v4", "actions/setup-node@v4", "actions/setup-python@v4"},
},
{
name: "docker action - no step dependencies",
actionYML: testutil.DockerActionYML,
actionYML: testutil.MustReadFixture("actions/docker/basic.yml"),
expectError: false,
expectDeps: false,
expectedLen: 0,
},
{
name: "invalid action file",
actionYML: testutil.InvalidActionYML,
actionYML: testutil.MustReadFixture("actions/invalid/invalid-using.yml"),
expectError: true,
},
{
name: "minimal action - no dependencies",
actionYML: testutil.MinimalActionYML,
actionYML: testutil.MustReadFixture("minimal-action.yml"),
expectError: false,
expectDeps: false,
expectedLen: 0,
@@ -401,18 +402,7 @@ func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) {
defer cleanup()
// Create a test action file with composite steps
actionContent := `name: 'Test Composite Action'
description: 'Test action for update testing'
runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3.8.0
with:
node-version: '18'
`
actionContent := testutil.MustReadFixture("test-composite-action.yml")
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, actionContent)
@@ -528,7 +518,7 @@ func TestAnalyzer_WithoutGitHubClient(t *testing.T) {
defer cleanup()
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, testutil.CompositeActionYML)
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/composite/basic.yml"))
deps, err := analyzer.AnalyzeActionFile(actionPath)
@@ -553,3 +543,76 @@ type mockTransport struct {
func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.client.Do(req)
}
// TestNewAnalyzer tests the analyzer constructor.
func TestNewAnalyzer(t *testing.T) {
// Create test dependencies
mockResponses := testutil.MockGitHubResponses()
githubClient := testutil.MockGitHubClient(mockResponses)
cacheInstance, err := cache.NewCache(cache.DefaultConfig())
testutil.AssertNoError(t, err)
defer func() { _ = cacheInstance.Close() }()
repoInfo := git.RepoInfo{
Organization: "test-owner",
Repository: "test-repo",
}
tests := []struct {
name string
client *github.Client
repoInfo git.RepoInfo
cache DependencyCache
expectNotNil bool
}{
{
name: "creates analyzer with all dependencies",
client: githubClient,
repoInfo: repoInfo,
cache: NewCacheAdapter(cacheInstance),
expectNotNil: true,
},
{
name: "creates analyzer with nil client",
client: nil,
repoInfo: repoInfo,
cache: NewCacheAdapter(cacheInstance),
expectNotNil: true,
},
{
name: "creates analyzer with nil cache",
client: githubClient,
repoInfo: repoInfo,
cache: nil,
expectNotNil: true,
},
{
name: "creates analyzer with empty repo info",
client: githubClient,
repoInfo: git.RepoInfo{},
cache: NewCacheAdapter(cacheInstance),
expectNotNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
analyzer := NewAnalyzer(tt.client, tt.repoInfo, tt.cache)
if tt.expectNotNil && analyzer == nil {
t.Fatal("expected non-nil analyzer")
}
// Verify fields are set correctly
if analyzer.GitHubClient != tt.client {
t.Error("GitHub client not set correctly")
}
if analyzer.Cache != tt.cache {
t.Error("cache not set correctly")
}
if analyzer.RepoInfo != tt.repoInfo {
t.Error("repo info not set correctly")
}
})
}
}

View File

@@ -10,7 +10,7 @@ import (
// parseCompositeActionFromFile reads and parses a composite action file.
func (a *Analyzer) parseCompositeActionFromFile(actionPath string) (*ActionWithComposite, error) {
// Read the file
data, err := os.ReadFile(actionPath)
data, err := os.ReadFile(actionPath) // #nosec G304 -- action path from function parameter
if err != nil {
return nil, fmt.Errorf("failed to read action file %s: %w", actionPath, err)
}

View File

@@ -3,10 +3,28 @@ package internal
import (
"os"
"strings"
"github.com/ivuorinen/gh-action-readme/internal/errors"
)
// Error detection constants for automatic error code determination.
const (
// File system error patterns.
errorPatternFileNotFound = "no such file or directory"
errorPatternPermission = "permission denied"
// Content format error patterns.
errorPatternYAML = "yaml"
// Service-specific error patterns.
errorPatternGitHub = "github"
errorPatternConfig = "config"
// Exit code constants.
exitCodeError = 1
)
// ErrorHandler provides centralized error handling and exit management.
type ErrorHandler struct {
output *ColoredOutput
@@ -22,7 +40,7 @@ func NewErrorHandler(output *ColoredOutput) *ErrorHandler {
// HandleError handles contextual errors and exits with appropriate code.
func (eh *ErrorHandler) HandleError(err *errors.ContextualError) {
eh.output.ErrorWithSuggestions(err)
os.Exit(1)
os.Exit(exitCodeError)
}
// HandleFatalError handles fatal errors with contextual information.
@@ -48,7 +66,7 @@ func (eh *ErrorHandler) HandleSimpleError(message string, err error) {
// Try to determine appropriate error code based on error content
if err != nil {
context["error"] = err.Error()
context[ContextKeyError] = err.Error()
code = eh.determineErrorCode(err)
}
@@ -60,15 +78,15 @@ func (eh *ErrorHandler) determineErrorCode(err error) errors.ErrorCode {
errStr := err.Error()
switch {
case contains(errStr, "no such file or directory"):
case contains(errStr, errorPatternFileNotFound):
return errors.ErrCodeFileNotFound
case contains(errStr, "permission denied"):
case contains(errStr, errorPatternPermission):
return errors.ErrCodePermission
case contains(errStr, "yaml"):
case contains(errStr, errorPatternYAML):
return errors.ErrCodeInvalidYAML
case contains(errStr, "github"):
case contains(errStr, errorPatternGitHub):
return errors.ErrCodeGitHubAPI
case contains(errStr, "config"):
case contains(errStr, errorPatternConfig):
return errors.ErrCodeConfiguration
default:
return errors.ErrCodeUnknown
@@ -77,35 +95,5 @@ func (eh *ErrorHandler) determineErrorCode(err error) errors.ErrorCode {
// contains checks if a string contains a substring (case-insensitive).
func contains(s, substr string) bool {
// Simple implementation - could use strings.Contains with strings.ToLower
// but avoiding extra imports for now
sLen := len(s)
substrLen := len(substr)
if substrLen > sLen {
return false
}
for i := 0; i <= sLen-substrLen; i++ {
match := true
for j := 0; j < substrLen; j++ {
if toLower(s[i+j]) != toLower(substr[j]) {
match = false
break
}
}
if match {
return true
}
}
return false
}
// toLower converts a byte to lowercase.
func toLower(b byte) byte {
if b >= 'A' && b <= 'Z' {
return b + ('a' - 'A')
}
return b
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}

View File

@@ -0,0 +1,151 @@
// Package internal demonstrates how to use focused interfaces for better separation of concerns.
package internal
import (
"fmt"
"github.com/ivuorinen/gh-action-readme/internal/errors"
)
// SimpleLogger demonstrates a component that only needs basic message logging.
// It depends only on MessageLogger, not the entire output system.
type SimpleLogger struct {
logger MessageLogger
}
// NewSimpleLogger creates a logger that only needs message logging capabilities.
func NewSimpleLogger(logger MessageLogger) *SimpleLogger {
return &SimpleLogger{logger: logger}
}
// LogOperation logs a simple operation with different message types.
func (sl *SimpleLogger) LogOperation(operation string, success bool) {
sl.logger.Info("Starting operation: %s", operation)
if success {
sl.logger.Success("Operation completed: %s", operation)
} else {
sl.logger.Warning("Operation had issues: %s", operation)
}
}
// FocusedErrorManager demonstrates a component that only handles error reporting.
// It depends only on ErrorReporter and ErrorFormatter, not the entire output system.
type FocusedErrorManager struct {
manager ErrorManager
}
// NewFocusedErrorManager creates an error manager with focused dependencies.
func NewFocusedErrorManager(manager ErrorManager) *FocusedErrorManager {
return &FocusedErrorManager{
manager: manager,
}
}
// HandleValidationError handles validation errors with context and suggestions.
func (fem *FocusedErrorManager) HandleValidationError(file string, missingFields []string) {
context := map[string]string{
"file": file,
"missing_fields": fmt.Sprintf("%v", missingFields),
}
fem.manager.ErrorWithContext(
errors.ErrCodeValidation,
fmt.Sprintf("Validation failed for %s", file),
context,
)
}
// TaskProgress demonstrates a component that only needs progress reporting.
// It depends only on ProgressReporter, not the entire output system.
type TaskProgress struct {
reporter ProgressReporter
}
// NewTaskProgress creates a progress reporter with focused dependencies.
func NewTaskProgress(reporter ProgressReporter) *TaskProgress {
return &TaskProgress{reporter: reporter}
}
// ReportProgress reports progress for a long-running task.
func (tp *TaskProgress) ReportProgress(task string, step int, total int) {
tp.reporter.Progress("Task %s: step %d of %d", task, step, total)
}
// ConfigAwareComponent demonstrates a component that only needs to check configuration.
// It depends only on OutputConfig, not the entire output system.
type ConfigAwareComponent struct {
config OutputConfig
}
// NewConfigAwareComponent creates a component that checks output configuration.
func NewConfigAwareComponent(config OutputConfig) *ConfigAwareComponent {
return &ConfigAwareComponent{config: config}
}
// ShouldOutput determines whether output should be generated based on quiet mode.
func (cac *ConfigAwareComponent) ShouldOutput() bool {
return !cac.config.IsQuiet()
}
// CompositeOutputWriter demonstrates how to compose interfaces for specific needs.
// It combines MessageLogger and ProgressReporter without error handling.
type CompositeOutputWriter struct {
writer OutputWriter
}
// NewCompositeOutputWriter creates a writer that combines message and progress reporting.
func NewCompositeOutputWriter(writer OutputWriter) *CompositeOutputWriter {
return &CompositeOutputWriter{writer: writer}
}
// ProcessWithOutput demonstrates processing with both messages and progress.
func (cow *CompositeOutputWriter) ProcessWithOutput(items []string) {
if cow.writer.IsQuiet() {
return
}
cow.writer.Info("Processing %d items", len(items))
for i, item := range items {
cow.writer.Progress("Processing item %d: %s", i+1, item)
// Process the item...
}
cow.writer.Success("All items processed successfully")
}
// ValidationComponent demonstrates combining error handling interfaces.
type ValidationComponent struct {
errorManager ErrorManager
logger MessageLogger
}
// NewValidationComponent creates a validator with focused error handling and logging.
func NewValidationComponent(errorManager ErrorManager, logger MessageLogger) *ValidationComponent {
return &ValidationComponent{
errorManager: errorManager,
logger: logger,
}
}
// ValidateAndReport validates an item and reports results using focused interfaces.
func (vc *ValidationComponent) ValidateAndReport(item string, isValid bool, err error) {
if isValid {
vc.logger.Success("Validation passed for: %s", item)
return
}
if err != nil {
if contextualErr, ok := err.(*errors.ContextualError); ok {
vc.errorManager.ErrorWithSuggestions(contextualErr)
} else {
vc.errorManager.Error("Validation failed for %s: %v", item, err)
}
} else {
vc.errorManager.ErrorWithSimpleFix(
fmt.Sprintf("Validation failed for %s", item),
"Please check the item configuration and try again",
)
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/ivuorinen/gh-action-readme/internal/cache"
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
"github.com/ivuorinen/gh-action-readme/internal/errors"
"github.com/ivuorinen/gh-action-readme/internal/git"
)
@@ -24,16 +25,34 @@ const (
)
// Generator orchestrates the documentation generation process.
// It uses focused interfaces to reduce coupling and improve testability.
type Generator struct {
Config *AppConfig
Output *ColoredOutput
Config *AppConfig
Output CompleteOutput
Progress ProgressManager
}
// NewGenerator creates a new generator instance with the provided configuration.
// This constructor maintains backward compatibility by using concrete implementations.
func NewGenerator(config *AppConfig) *Generator {
return NewGeneratorWithDependencies(
config,
NewColoredOutput(config.Quiet),
NewProgressBarManager(config.Quiet),
)
}
// NewGeneratorWithDependencies creates a new generator with dependency injection.
// This constructor allows for better testability and flexibility by accepting interfaces.
func NewGeneratorWithDependencies(
config *AppConfig,
output CompleteOutput,
progress ProgressManager,
) *Generator {
return &Generator{
Config: config,
Output: NewColoredOutput(config.Quiet),
Config: config,
Output: output,
Progress: progress,
}
}
@@ -104,10 +123,11 @@ func (g *Generator) parseAndValidateAction(actionPath string) (*ActionYML, error
if len(validationResult.MissingFields) > 0 {
// Check for critical validation errors that cannot be fixed with defaults
for _, field := range validationResult.MissingFields {
if field == "runs.using" {
// Invalid runtime - cannot be fixed with defaults, must fail
// All core required fields should cause validation failure
if field == "name" || field == "description" || field == "runs" || field == "runs.using" {
// Required fields missing - cannot be fixed with defaults, must fail
return nil, fmt.Errorf(
"action file %s has invalid runtime configuration: %v",
"action file %s has invalid configuration, missing required field(s): %v",
actionPath,
validationResult.MissingFields,
)
@@ -140,11 +160,11 @@ func (g *Generator) generateByFormat(action *ActionYML, outputDir, actionPath st
case "md":
return g.generateMarkdown(action, outputDir, actionPath)
case OutputFormatHTML:
return g.generateHTML(action, outputDir)
return g.generateHTML(action, outputDir, actionPath)
case OutputFormatJSON:
return g.generateJSON(action, outputDir)
case OutputFormatASCIIDoc:
return g.generateASCIIDoc(action, outputDir)
return g.generateASCIIDoc(action, outputDir, actionPath)
default:
return fmt.Errorf("unsupported output format: %s", g.Config.OutputFormat)
}
@@ -175,7 +195,8 @@ func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath st
}
outputPath := filepath.Join(outputDir, "README.md")
if err := os.WriteFile(outputPath, []byte(content), 0644); err != nil {
if err := os.WriteFile(outputPath, []byte(content), FilePermDefault); err != nil {
// #nosec G306 -- output file permissions
return fmt.Errorf("failed to write README.md to %s: %w", outputPath, err)
}
@@ -184,15 +205,27 @@ func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath st
}
// generateHTML creates an HTML file using the template and optional header/footer.
func (g *Generator) generateHTML(action *ActionYML, outputDir string) error {
func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string) error {
// Use theme-based template if theme is specified, otherwise use explicit template path
templatePath := g.Config.Template
if g.Config.Theme != "" {
templatePath = resolveThemeTemplate(g.Config.Theme)
}
opts := TemplateOptions{
TemplatePath: g.Config.Template,
TemplatePath: templatePath,
HeaderPath: g.Config.Header,
FooterPath: g.Config.Footer,
Format: "html",
}
content, err := RenderReadme(action, opts)
// Find repository root for git information
repoRoot, _ := git.FindRepositoryRoot(outputDir)
// Build comprehensive template data
templateData := BuildTemplateData(action, g.Config, repoRoot, actionPath)
content, err := RenderReadme(templateData, opts)
if err != nil {
return fmt.Errorf("failed to render HTML template: %w", err)
}
@@ -226,7 +259,7 @@ func (g *Generator) generateJSON(action *ActionYML, outputDir string) error {
}
// generateASCIIDoc creates an AsciiDoc file using the template.
func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir string) error {
func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir, actionPath string) error {
// Use AsciiDoc template
templatePath := resolveTemplatePath("templates/themes/asciidoc/readme.adoc")
@@ -235,13 +268,20 @@ func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir string) error
Format: "asciidoc",
}
content, err := RenderReadme(action, opts)
// Find repository root for git information
repoRoot, _ := git.FindRepositoryRoot(outputDir)
// Build comprehensive template data
templateData := BuildTemplateData(action, g.Config, repoRoot, actionPath)
content, err := RenderReadme(templateData, opts)
if err != nil {
return fmt.Errorf("failed to render AsciiDoc template: %w", err)
}
outputPath := filepath.Join(outputDir, "README.adoc")
if err := os.WriteFile(outputPath, []byte(content), 0644); err != nil {
if err := os.WriteFile(outputPath, []byte(content), FilePermDefault); err != nil {
// #nosec G306 -- output file permissions
return fmt.Errorf("failed to write AsciiDoc to %s: %w", outputPath, err)
}
@@ -271,15 +311,53 @@ func (g *Generator) DiscoverActionFiles(dir string, recursive bool) ([]string, e
return actionFiles, nil
}
// DiscoverActionFilesWithValidation discovers action files with centralized error handling and validation.
// This function consolidates the duplicated file discovery logic across the codebase.
func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool, context string) ([]string, error) {
// Discover action files
actionFiles, err := g.DiscoverActionFiles(dir, recursive)
if err != nil {
g.Output.ErrorWithContext(
errors.ErrCodeFileNotFound,
fmt.Sprintf("failed to discover action files for %s", context),
map[string]string{
"directory": dir,
"recursive": fmt.Sprintf("%t", recursive),
"context": context,
ContextKeyError: err.Error(),
},
)
return nil, err
}
// Check if any files were found
if len(actionFiles) == 0 {
contextMsg := fmt.Sprintf("no GitHub Action files found for %s", context)
g.Output.ErrorWithContext(
errors.ErrCodeNoActionFiles,
contextMsg,
map[string]string{
"directory": dir,
"recursive": fmt.Sprintf("%t", recursive),
"context": context,
"suggestion": "Please run this command in a directory containing GitHub Action files (action.yml or action.yaml)",
},
)
return nil, fmt.Errorf("no action files found in directory: %s", dir)
}
return actionFiles, nil
}
// ProcessBatch processes multiple action.yml files.
func (g *Generator) ProcessBatch(paths []string) error {
if len(paths) == 0 {
return fmt.Errorf("no action files to process")
}
bar := g.createProgressBar("Processing files", paths)
bar := g.Progress.CreateProgressBarForFiles("Processing files", paths)
errors, successCount := g.processFiles(paths, bar)
g.finishProgressBar(bar)
g.Progress.FinishProgressBarWithNewline(bar)
g.reportResults(successCount, errors)
if len(errors) > 0 {
@@ -304,9 +382,7 @@ func (g *Generator) processFiles(paths []string, bar *progressbar.ProgressBar) (
successCount++
}
if bar != nil {
_ = bar.Add(1)
}
g.Progress.UpdateProgressBar(bar)
}
return errors, successCount
}
@@ -333,9 +409,9 @@ func (g *Generator) ValidateFiles(paths []string) error {
return fmt.Errorf("no action files to validate")
}
bar := g.createProgressBar("Validating files", paths)
bar := g.Progress.CreateProgressBarForFiles("Validating files", paths)
allResults, errors := g.validateFiles(paths, bar)
g.finishProgressBar(bar)
g.Progress.FinishProgressBarWithNewline(bar)
if !g.Config.Quiet {
g.reportValidationResults(allResults, errors)
@@ -357,21 +433,6 @@ func (g *Generator) ValidateFiles(paths []string) error {
return nil
}
// createProgressBar creates a progress bar with the specified description.
func (g *Generator) createProgressBar(description string, paths []string) *progressbar.ProgressBar {
progressMgr := NewProgressBarManager(g.Config.Quiet)
return progressMgr.CreateProgressBarForFiles(description, paths)
}
// finishProgressBar completes the progress bar display.
func (g *Generator) finishProgressBar(bar *progressbar.ProgressBar) {
progressMgr := NewProgressBarManager(g.Config.Quiet)
progressMgr.FinishProgressBar(bar)
if bar != nil {
fmt.Println()
}
}
// validateFiles processes each file for validation.
func (g *Generator) validateFiles(paths []string, bar *progressbar.ProgressBar) ([]ValidationResult, []string) {
allResults := make([]ValidationResult, 0, len(paths))
@@ -393,9 +454,7 @@ func (g *Generator) validateFiles(paths []string, bar *progressbar.ProgressBar)
result.MissingFields = append([]string{fmt.Sprintf("file: %s", path)}, result.MissingFields...)
allResults = append(allResults, result)
if bar != nil {
_ = bar.Add(1)
}
g.Progress.UpdateProgressBar(bar)
}
return allResults, errors
}

View File

@@ -0,0 +1,375 @@
package internal
import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/ivuorinen/gh-action-readme/testutil"
)
// TestGenerator_ComprehensiveGeneration demonstrates the new table-driven testing framework
// by testing generation across all fixtures, themes, and formats systematically.
func TestGenerator_ComprehensiveGeneration(t *testing.T) {
// Create test cases using the new helper functions
cases := testutil.CreateGeneratorTestCases()
// Filter to a subset for demonstration (full test would be very large)
filteredCases := make([]testutil.GeneratorTestCase, 0)
for _, testCase := range cases {
// Only test a few combinations for demonstration
if (testCase.Theme == "default" && testCase.OutputFormat == "md") ||
(testCase.Theme == "github" && testCase.OutputFormat == "html") ||
(testCase.Theme == "minimal" && testCase.OutputFormat == "json") {
// Add custom executor for generator tests
testCase.Executor = createGeneratorTestExecutor()
filteredCases = append(filteredCases, testCase)
}
}
// Run the test suite
testutil.RunGeneratorTests(t, filteredCases)
}
// TestGenerator_AllValidFixtures tests generation with all valid fixtures.
func TestGenerator_AllValidFixtures(t *testing.T) {
validFixtures := testutil.GetValidFixtures()
for _, fixture := range validFixtures {
fixture := fixture // capture loop variable
t.Run(fixture, func(t *testing.T) {
t.Parallel()
// Create temporary action from fixture
actionPath := testutil.CreateTemporaryAction(t, fixture)
// Test with default configuration
config := &AppConfig{
Theme: "default",
OutputFormat: "md",
OutputDir: ".",
Quiet: true,
}
generator := NewGenerator(config)
// Generate documentation
err := generator.GenerateFromFile(actionPath)
if err != nil {
t.Errorf("failed to generate documentation for fixture %s: %v", fixture, err)
}
})
}
}
// TestGenerator_AllInvalidFixtures tests that invalid fixtures produce expected errors.
func TestGenerator_AllInvalidFixtures(t *testing.T) {
invalidFixtures := testutil.GetInvalidFixtures()
for _, fixture := range invalidFixtures {
fixture := fixture // capture loop variable
t.Run(fixture, func(t *testing.T) {
t.Parallel()
// Some invalid fixtures might not be loadable
actionFixture, err := testutil.LoadActionFixture(fixture)
if err != nil {
// This is expected for some invalid fixtures
return
}
// Create temporary action from fixture
tempDir, cleanup := testutil.TempDir(t)
defer cleanup()
testutil.WriteTestFile(t, tempDir+"/action.yml", actionFixture.Content)
// Test with default configuration
config := &AppConfig{
Theme: "default",
OutputFormat: "md",
OutputDir: ".",
Quiet: true,
}
generator := NewGenerator(config)
// Generate documentation - should fail
err = generator.GenerateFromFile(tempDir + "/action.yml")
if err == nil {
t.Errorf("expected generation to fail for invalid fixture %s, but it succeeded", fixture)
}
})
}
}
// TestGenerator_AllThemes demonstrates theme testing using helper functions.
func TestGenerator_AllThemes(t *testing.T) {
// Use the helper function to test all themes
testutil.TestAllThemes(t, func(t *testing.T, theme string) {
// Create a simple action for testing
actionPath := testutil.CreateTemporaryAction(t, "actions/javascript/simple.yml")
config := &AppConfig{
Theme: theme,
OutputFormat: "md",
OutputDir: ".",
Quiet: true,
}
generator := NewGenerator(config)
err := generator.GenerateFromFile(actionPath)
testutil.AssertNoError(t, err)
})
}
// TestGenerator_AllFormats demonstrates format testing using helper functions.
func TestGenerator_AllFormats(t *testing.T) {
// Use the helper function to test all formats
testutil.TestAllFormats(t, func(t *testing.T, format string) {
// Create a simple action for testing
actionPath := testutil.CreateTemporaryAction(t, "actions/javascript/simple.yml")
config := &AppConfig{
Theme: "default",
OutputFormat: format,
OutputDir: ".",
Quiet: true,
}
generator := NewGenerator(config)
err := generator.GenerateFromFile(actionPath)
testutil.AssertNoError(t, err)
})
}
// TestGenerator_ByActionType demonstrates testing by action type.
func TestGenerator_ByActionType(t *testing.T) {
actionTypes := []testutil.ActionType{
testutil.ActionTypeJavaScript,
testutil.ActionTypeComposite,
testutil.ActionTypeDocker,
}
for _, actionType := range actionTypes {
actionType := actionType // capture loop variable
t.Run(string(actionType), func(t *testing.T) {
t.Parallel()
fixtures := testutil.GetFixturesByActionType(actionType)
if len(fixtures) == 0 {
t.Skipf("no fixtures available for action type %s", actionType)
}
// Test the first fixture of this type
fixture := fixtures[0]
actionPath := testutil.CreateTemporaryAction(t, fixture)
config := &AppConfig{
Theme: "default",
OutputFormat: "md",
OutputDir: ".",
Quiet: true,
}
generator := NewGenerator(config)
err := generator.GenerateFromFile(actionPath)
testutil.AssertNoError(t, err)
})
}
}
// TestGenerator_WithMockEnvironment demonstrates testing with a complete mock environment.
func TestGenerator_WithMockEnvironment(t *testing.T) {
// Create a complete test environment
envConfig := &testutil.EnvironmentConfig{
ActionFixtures: []string{"actions/composite/with-dependencies.yml"},
WithMocks: true,
}
env := testutil.CreateTestEnvironment(t, envConfig)
// Clean up environment
defer func() {
for _, cleanup := range env.Cleanup {
if err := cleanup(); err != nil {
t.Errorf("cleanup failed: %v", err)
}
}
}()
if len(env.ActionPaths) == 0 {
t.Fatal("expected at least one action path")
}
config := &AppConfig{
Theme: "github",
OutputFormat: "md",
OutputDir: ".",
Quiet: true,
}
generator := NewGenerator(config)
err := generator.GenerateFromFile(env.ActionPaths[0])
testutil.AssertNoError(t, err)
}
// TestGenerator_FixtureValidation demonstrates fixture validation.
func TestGenerator_FixtureValidation(t *testing.T) {
// Test that all valid fixtures pass validation
validFixtures := testutil.GetValidFixtures()
for _, fixtureName := range validFixtures {
t.Run(fixtureName, func(t *testing.T) {
testutil.AssertFixtureValid(t, fixtureName)
})
}
// Test that all invalid fixtures fail validation
invalidFixtures := testutil.GetInvalidFixtures()
for _, fixtureName := range invalidFixtures {
t.Run(fixtureName, func(t *testing.T) {
testutil.AssertFixtureInvalid(t, fixtureName)
})
}
}
// createGeneratorTestExecutor returns a test executor function for generator tests.
func createGeneratorTestExecutor() testutil.TestExecutor {
return func(t *testing.T, testCase testutil.TestCase, ctx *testutil.TestContext) *testutil.TestResult {
t.Helper()
result := &testutil.TestResult{
Context: ctx,
}
var actionPath string
// If we have a fixture, load it and create action file
if testCase.Fixture != "" {
fixture, err := ctx.FixtureManager.LoadActionFixture(testCase.Fixture)
if err != nil {
result.Error = fmt.Errorf("failed to load fixture %s: %w", testCase.Fixture, err)
return result
}
// Create temporary action file
actionPath = filepath.Join(ctx.TempDir, "action.yml")
testutil.WriteTestFile(t, actionPath, fixture.Content)
}
// If we don't have an action file to test, just return success
if actionPath == "" {
result.Success = true
return result
}
// Create generator configuration from test config
config := createGeneratorConfigFromTestConfig(ctx.Config, ctx.TempDir)
// Save current working directory and change to project root for template resolution
originalWd, err := os.Getwd()
if err != nil {
result.Error = fmt.Errorf("failed to get working directory: %w", err)
return result
}
// Use runtime.Caller to find project root relative to this file
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
result.Error = fmt.Errorf("failed to get current file path")
return result
}
// Get the project root (go up from internal/generator_comprehensive_test.go to project root)
projectRoot := filepath.Dir(filepath.Dir(currentFile))
if err := os.Chdir(projectRoot); err != nil {
result.Error = fmt.Errorf("failed to change to project root %s: %w", projectRoot, err)
return result
}
// Debug: Log the working directory and template path
currentWd, _ := os.Getwd()
t.Logf("Test working directory: %s, template path: %s", currentWd, config.Template)
// Restore working directory after test
defer func() {
if err := os.Chdir(originalWd); err != nil {
// Log error but don't fail the test
t.Logf("Failed to restore working directory: %v", err)
}
}()
// Create and run generator
generator := NewGenerator(config)
err = generator.GenerateFromFile(actionPath)
if err != nil {
result.Error = err
result.Success = false
} else {
result.Success = true
// Detect generated files
result.Files = testutil.DetectGeneratedFiles(ctx.TempDir, config.OutputFormat)
}
return result
}
}
// createGeneratorConfigFromTestConfig converts TestConfig to AppConfig.
func createGeneratorConfigFromTestConfig(testConfig *testutil.TestConfig, outputDir string) *AppConfig {
config := &AppConfig{
Theme: "default",
OutputFormat: "md",
OutputDir: outputDir,
Template: "templates/readme.tmpl",
Schema: "schemas/schema.json",
Verbose: false,
Quiet: true, // Default to quiet for tests
GitHubToken: "",
}
// Override with test-specific settings
if testConfig != nil {
if testConfig.Theme != "" {
config.Theme = testConfig.Theme
}
if testConfig.OutputFormat != "" {
config.OutputFormat = testConfig.OutputFormat
}
if testConfig.OutputDir != "" {
config.OutputDir = testConfig.OutputDir
}
config.Verbose = testConfig.Verbose
config.Quiet = testConfig.Quiet
}
// Set appropriate template path based on theme and output format
config.Template = resolveTemplatePathForTest(config.Theme, config.OutputFormat)
return config
}
// resolveTemplatePathForTest resolves the correct template path for testing.
func resolveTemplatePathForTest(theme, _ string) string {
switch theme {
case "github":
return "templates/themes/github/readme.tmpl"
case "gitlab":
return "templates/themes/gitlab/readme.tmpl"
case "minimal":
return "templates/themes/minimal/readme.tmpl"
case "professional":
return "templates/themes/professional/readme.tmpl"
default:
return "templates/readme.tmpl"
}
}

View File

@@ -44,7 +44,9 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
{
name: "single action.yml in root",
setupFunc: func(t *testing.T, tmpDir string) {
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.SimpleActionYML)
fixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
testutil.AssertNoError(t, err)
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), fixture.Content)
},
recursive: false,
expectedLen: 1,
@@ -52,7 +54,9 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
{
name: "action.yaml variant",
setupFunc: func(t *testing.T, tmpDir string) {
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yaml"), testutil.SimpleActionYML)
fixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
testutil.AssertNoError(t, err)
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yaml"), fixture.Content)
},
recursive: false,
expectedLen: 1,
@@ -60,8 +64,12 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
{
name: "both yml and yaml files",
setupFunc: func(t *testing.T, tmpDir string) {
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.SimpleActionYML)
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yaml"), testutil.MinimalActionYML)
simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
testutil.AssertNoError(t, err)
minimalFixture, err := testutil.LoadActionFixture("minimal-action.yml")
testutil.AssertNoError(t, err)
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), simpleFixture.Content)
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yaml"), minimalFixture.Content)
},
recursive: false,
expectedLen: 2,
@@ -69,10 +77,14 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
{
name: "recursive discovery",
setupFunc: func(t *testing.T, tmpDir string) {
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.SimpleActionYML)
simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
testutil.AssertNoError(t, err)
compositeFixture, err := testutil.LoadActionFixture("actions/composite/basic.yml")
testutil.AssertNoError(t, err)
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), simpleFixture.Content)
subDir := filepath.Join(tmpDir, "subdir")
_ = os.MkdirAll(subDir, 0755)
testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), testutil.CompositeActionYML)
_ = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), compositeFixture.Content)
},
recursive: true,
expectedLen: 2,
@@ -80,10 +92,14 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
{
name: "non-recursive skips subdirectories",
setupFunc: func(t *testing.T, tmpDir string) {
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.SimpleActionYML)
simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
testutil.AssertNoError(t, err)
compositeFixture, err := testutil.LoadActionFixture("actions/composite/basic.yml")
testutil.AssertNoError(t, err)
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), simpleFixture.Content)
subDir := filepath.Join(tmpDir, "subdir")
_ = os.MkdirAll(subDir, 0755)
testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), testutil.CompositeActionYML)
_ = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), compositeFixture.Content)
},
recursive: false,
expectedLen: 1,
@@ -153,42 +169,48 @@ func TestGenerator_GenerateFromFile(t *testing.T) {
}{
{
name: "simple action to markdown",
actionYML: testutil.SimpleActionYML,
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
outputFormat: "md",
expectError: false,
contains: []string{"# Simple Action", "A simple test action"},
contains: []string{"# Simple JavaScript Action", "A simple JavaScript action for testing"},
},
{
name: "composite action to markdown",
actionYML: testutil.CompositeActionYML,
actionYML: testutil.MustReadFixture("actions/composite/basic.yml"),
outputFormat: "md",
expectError: false,
contains: []string{"# Composite Action", "A composite action with dependencies"},
contains: []string{"# Basic Composite Action", "A simple composite action with basic steps"},
},
{
name: "action to HTML",
actionYML: testutil.SimpleActionYML,
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
outputFormat: "html",
expectError: false,
contains: []string{"Simple Action", "A simple test action"}, // HTML uses same template content
contains: []string{
"Simple JavaScript Action",
"A simple JavaScript action for testing",
}, // HTML uses same template content
},
{
name: "action to JSON",
actionYML: testutil.SimpleActionYML,
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
outputFormat: "json",
expectError: false,
contains: []string{`"name": "Simple Action"`, `"description": "A simple test action"`},
contains: []string{
`"name": "Simple JavaScript Action"`,
`"description": "A simple JavaScript action for testing"`,
},
},
{
name: "invalid action file",
actionYML: testutil.InvalidActionYML,
actionYML: testutil.MustReadFixture("actions/invalid/invalid-using.yml"),
outputFormat: "md",
expectError: true, // Invalid runtime configuration should cause failure
contains: []string{},
},
{
name: "unknown output format",
actionYML: testutil.SimpleActionYML,
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
outputFormat: "unknown",
expectError: true,
},
@@ -299,10 +321,10 @@ func TestGenerator_ProcessBatch(t *testing.T) {
// Create separate directories for each action
dir1 := filepath.Join(tmpDir, "action1")
dir2 := filepath.Join(tmpDir, "action2")
if err := os.MkdirAll(dir1, 0755); err != nil {
if err := os.MkdirAll(dir1, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("failed to create dir1: %v", err)
}
if err := os.MkdirAll(dir2, 0755); err != nil {
if err := os.MkdirAll(dir2, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("failed to create dir2: %v", err)
}
@@ -310,8 +332,8 @@ func TestGenerator_ProcessBatch(t *testing.T) {
filepath.Join(dir1, "action.yml"),
filepath.Join(dir2, "action.yml"),
}
testutil.WriteTestFile(t, files[0], testutil.SimpleActionYML)
testutil.WriteTestFile(t, files[1], testutil.CompositeActionYML)
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/composite/basic.yml"))
return files
},
expectError: false,
@@ -323,10 +345,10 @@ func TestGenerator_ProcessBatch(t *testing.T) {
// Create separate directories for mixed test too
dir1 := filepath.Join(tmpDir, "valid-action")
dir2 := filepath.Join(tmpDir, "invalid-action")
if err := os.MkdirAll(dir1, 0755); err != nil {
if err := os.MkdirAll(dir1, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("failed to create dir1: %v", err)
}
if err := os.MkdirAll(dir2, 0755); err != nil {
if err := os.MkdirAll(dir2, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("failed to create dir2: %v", err)
}
@@ -334,8 +356,8 @@ func TestGenerator_ProcessBatch(t *testing.T) {
filepath.Join(dir1, "action.yml"),
filepath.Join(dir2, "action.yml"),
}
testutil.WriteTestFile(t, files[0], testutil.SimpleActionYML)
testutil.WriteTestFile(t, files[1], testutil.InvalidActionYML)
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/invalid/invalid-using.yml"))
return files
},
expectError: true, // Invalid runtime configuration should cause batch to fail
@@ -413,8 +435,8 @@ func TestGenerator_ValidateFiles(t *testing.T) {
filepath.Join(tmpDir, "action1.yml"),
filepath.Join(tmpDir, "action2.yml"),
}
testutil.WriteTestFile(t, files[0], testutil.SimpleActionYML)
testutil.WriteTestFile(t, files[1], testutil.MinimalActionYML)
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("minimal-action.yml"))
return files
},
expectError: false,
@@ -426,8 +448,8 @@ func TestGenerator_ValidateFiles(t *testing.T) {
filepath.Join(tmpDir, "valid.yml"),
filepath.Join(tmpDir, "invalid.yml"),
}
testutil.WriteTestFile(t, files[0], testutil.SimpleActionYML)
testutil.WriteTestFile(t, files[1], testutil.InvalidActionYML)
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/invalid/missing-description.yml"))
return files
},
expectError: true, // Validation should fail for invalid runtime configuration
@@ -513,7 +535,7 @@ func TestGenerator_WithDifferentThemes(t *testing.T) {
testutil.SetupTestTemplates(t, tmpDir)
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, testutil.SimpleActionYML)
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
for _, theme := range themes {
t.Run("theme_"+theme, func(t *testing.T) {
@@ -575,7 +597,7 @@ func TestGenerator_ErrorHandling(t *testing.T) {
}
generator := NewGenerator(config)
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, testutil.SimpleActionYML)
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
return generator, actionPath
},
wantError: "template",
@@ -588,7 +610,7 @@ func TestGenerator_ErrorHandling(t *testing.T) {
// Create a directory with no write permissions
restrictedDir := filepath.Join(tmpDir, "restricted")
_ = os.MkdirAll(restrictedDir, 0444) // Read-only
_ = os.MkdirAll(restrictedDir, 0444) // #nosec G301 -- intentionally read-only for test
config := &AppConfig{
OutputFormat: "md",
@@ -598,7 +620,7 @@ func TestGenerator_ErrorHandling(t *testing.T) {
}
generator := NewGenerator(config)
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, testutil.SimpleActionYML)
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
return generator, actionPath
},
wantError: "permission denied",

View File

@@ -80,9 +80,7 @@ func DetectRepository(repoRoot string) (*RepoInfo, error) {
}
// Try to get default branch
if defaultBranch, err := getDefaultBranch(repoRoot); err == nil {
info.DefaultBranch = defaultBranch
}
info.DefaultBranch = getDefaultBranch(repoRoot)
return info, nil
}
@@ -114,7 +112,7 @@ func getRemoteURLFromGit(repoRoot string) (string, error) {
// getRemoteURLFromConfig parses .git/config to extract remote URL.
func getRemoteURLFromConfig(repoRoot string) (string, error) {
configPath := filepath.Join(repoRoot, ".git", "config")
file, err := os.Open(configPath)
file, err := os.Open(configPath) // #nosec G304 -- git config path constructed from repo root
if err != nil {
return "", fmt.Errorf("failed to open git config: %w", err)
}
@@ -150,7 +148,7 @@ func getRemoteURLFromConfig(repoRoot string) (string, error) {
}
// getDefaultBranch gets the default branch name.
func getDefaultBranch(repoRoot string) (string, error) {
func getDefaultBranch(repoRoot string) string {
cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD")
cmd.Dir = repoRoot
@@ -159,24 +157,30 @@ func getDefaultBranch(repoRoot string) (string, error) {
// Fallback to common default branches
for _, branch := range []string{DefaultBranch, "master"} {
if branchExists(repoRoot, branch) {
return branch, nil
return branch
}
}
return DefaultBranch, nil // Default fallback
return DefaultBranch // Default fallback
}
// Extract branch name from refs/remotes/origin/HEAD -> refs/remotes/origin/main
parts := strings.Split(strings.TrimSpace(string(output)), "/")
if len(parts) > 0 {
return parts[len(parts)-1], nil
return parts[len(parts)-1]
}
return DefaultBranch, nil
return DefaultBranch
}
// branchExists checks if a branch exists in the repository.
func branchExists(repoRoot, branch string) bool {
cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
cmd := exec.Command(
"git",
"show-ref",
"--verify",
"--quiet",
"refs/heads/"+branch,
) // #nosec G204 -- branch name validated by git
cmd.Dir = repoRoot
return cmd.Run() == nil
}

View File

@@ -20,14 +20,14 @@ func TestFindRepositoryRoot(t *testing.T) {
setupFunc: func(t *testing.T, tmpDir string) string {
// Create .git directory
gitDir := filepath.Join(tmpDir, ".git")
err := os.MkdirAll(gitDir, 0755)
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
if err != nil {
t.Fatalf("failed to create .git directory: %v", err)
}
// Create subdirectory to test from
subDir := filepath.Join(tmpDir, "subdir", "nested")
err = os.MkdirAll(subDir, 0755)
err = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
if err != nil {
t.Fatalf("failed to create subdirectory: %v", err)
}
@@ -54,7 +54,7 @@ func TestFindRepositoryRoot(t *testing.T) {
setupFunc: func(t *testing.T, tmpDir string) string {
// Create subdirectory without .git
subDir := filepath.Join(tmpDir, "subdir")
err := os.MkdirAll(subDir, 0755)
err := os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
if err != nil {
t.Fatalf("failed to create subdirectory: %v", err)
}
@@ -117,7 +117,7 @@ func TestDetectGitRepository(t *testing.T) {
setupFunc: func(t *testing.T, tmpDir string) string {
// Create .git directory
gitDir := filepath.Join(tmpDir, ".git")
err := os.MkdirAll(gitDir, 0755)
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
if err != nil {
t.Fatalf("failed to create .git directory: %v", err)
}
@@ -150,7 +150,7 @@ func TestDetectGitRepository(t *testing.T) {
name: "SSH remote URL",
setupFunc: func(t *testing.T, tmpDir string) string {
gitDir := filepath.Join(tmpDir, ".git")
err := os.MkdirAll(gitDir, 0755)
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
if err != nil {
t.Fatalf("failed to create .git directory: %v", err)
}
@@ -185,7 +185,7 @@ func TestDetectGitRepository(t *testing.T) {
name: "git repository without origin remote",
setupFunc: func(t *testing.T, tmpDir string) string {
gitDir := filepath.Join(tmpDir, ".git")
err := os.MkdirAll(gitDir, 0755)
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
if err != nil {
t.Fatalf("failed to create .git directory: %v", err)
}

View File

@@ -110,12 +110,12 @@ func TestFindGitRepoRoot(t *testing.T) {
setupFunc: func(t *testing.T, tmpDir string) string {
// Create .git directory
gitDir := filepath.Join(tmpDir, ".git")
err := os.MkdirAll(gitDir, 0755)
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
testutil.AssertNoError(t, err)
// Create subdirectory to test from
subDir := filepath.Join(tmpDir, "subdir")
err = os.MkdirAll(subDir, 0755)
err = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
testutil.AssertNoError(t, err)
return subDir
@@ -135,12 +135,12 @@ func TestFindGitRepoRoot(t *testing.T) {
setupFunc: func(t *testing.T, tmpDir string) string {
// Create .git directory at root
gitDir := filepath.Join(tmpDir, ".git")
err := os.MkdirAll(gitDir, 0755)
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
testutil.AssertNoError(t, err)
// Create deeply nested subdirectory
nestedDir := filepath.Join(tmpDir, "a", "b", "c")
err = os.MkdirAll(nestedDir, 0755)
err = os.MkdirAll(nestedDir, 0750) // #nosec G301 -- test directory permissions
testutil.AssertNoError(t, err)
return nestedDir
@@ -222,7 +222,7 @@ func TestGetGitRepoRootAndInfo(t *testing.T) {
func setupCompleteGitRepo(t *testing.T, tmpDir string) string {
// Create .git directory
gitDir := filepath.Join(tmpDir, ".git")
err := os.MkdirAll(gitDir, 0755)
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
testutil.AssertNoError(t, err)
// Create a basic git config to make it look like a real repo
@@ -238,7 +238,7 @@ func setupCompleteGitRepo(t *testing.T, tmpDir string) string {
merge = refs/heads/main
`
configPath := filepath.Join(gitDir, "config")
err = os.WriteFile(configPath, []byte(configContent), 0644)
err = os.WriteFile(configPath, []byte(configContent), 0600) // #nosec G306 -- test file permissions
testutil.AssertNoError(t, err)
return tmpDir
@@ -247,7 +247,7 @@ func setupCompleteGitRepo(t *testing.T, tmpDir string) string {
func setupMinimalGitRepo(t *testing.T, tmpDir string) string {
// Create .git directory but with minimal content
gitDir := filepath.Join(tmpDir, ".git")
err := os.MkdirAll(gitDir, 0755)
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
testutil.AssertNoError(t, err)
return tmpDir

View File

@@ -11,7 +11,7 @@ type HTMLWriter struct {
}
func (w *HTMLWriter) Write(output string, path string) error {
f, err := os.Create(path)
f, err := os.Create(path) // #nosec G304 -- path from function parameter
if err != nil {
return err
}

80
internal/interfaces.go Normal file
View File

@@ -0,0 +1,80 @@
// Package internal defines focused interfaces following Interface Segregation Principle.
package internal
import (
"os"
"github.com/schollz/progressbar/v3"
"github.com/ivuorinen/gh-action-readme/internal/errors"
)
// MessageLogger handles informational output messages.
type MessageLogger interface {
Info(format string, args ...any)
Success(format string, args ...any)
Warning(format string, args ...any)
Bold(format string, args ...any)
Printf(format string, args ...any)
Fprintf(w *os.File, format string, args ...any)
}
// ErrorReporter handles error output and reporting.
type ErrorReporter interface {
Error(format string, args ...any)
ErrorWithSuggestions(err *errors.ContextualError)
ErrorWithContext(code errors.ErrorCode, message string, context map[string]string)
ErrorWithSimpleFix(message, suggestion string)
}
// ErrorFormatter handles formatting of contextual errors.
type ErrorFormatter interface {
FormatContextualError(err *errors.ContextualError) string
}
// ProgressReporter handles progress indication and status updates.
type ProgressReporter interface {
Progress(format string, args ...any)
}
// OutputConfig provides configuration queries for output behavior.
type OutputConfig interface {
IsQuiet() bool
}
// ProgressManager handles progress bar creation and management.
type ProgressManager interface {
CreateProgressBar(description string, total int) *progressbar.ProgressBar
CreateProgressBarForFiles(description string, files []string) *progressbar.ProgressBar
FinishProgressBar(bar *progressbar.ProgressBar)
FinishProgressBarWithNewline(bar *progressbar.ProgressBar)
UpdateProgressBar(bar *progressbar.ProgressBar)
ProcessWithProgressBar(
description string,
items []string,
processFunc func(item string, bar *progressbar.ProgressBar),
)
}
// OutputWriter combines message logging and progress reporting for general output needs.
type OutputWriter interface {
MessageLogger
ProgressReporter
OutputConfig
}
// ErrorManager combines error reporting and formatting for comprehensive error handling.
type ErrorManager interface {
ErrorReporter
ErrorFormatter
}
// CompleteOutput combines all output interfaces for backward compatibility.
// This should be used sparingly and only where all capabilities are truly needed.
type CompleteOutput interface {
MessageLogger
ErrorReporter
ErrorFormatter
ProgressReporter
OutputConfig
}

462
internal/interfaces_test.go Normal file
View File

@@ -0,0 +1,462 @@
// Package internal provides tests for the focused interfaces and demonstrates improved testability.
package internal
import (
"os"
"strings"
"testing"
"github.com/schollz/progressbar/v3"
"github.com/ivuorinen/gh-action-readme/internal/errors"
)
// MockMessageLogger implements MessageLogger for testing.
type MockMessageLogger struct {
InfoCalls []string
SuccessCalls []string
WarningCalls []string
BoldCalls []string
PrintfCalls []string
}
func (m *MockMessageLogger) Info(format string, args ...any) {
m.InfoCalls = append(m.InfoCalls, formatMessage(format, args...))
}
func (m *MockMessageLogger) Success(format string, args ...any) {
m.SuccessCalls = append(m.SuccessCalls, formatMessage(format, args...))
}
func (m *MockMessageLogger) Warning(format string, args ...any) {
m.WarningCalls = append(m.WarningCalls, formatMessage(format, args...))
}
func (m *MockMessageLogger) Bold(format string, args ...any) {
m.BoldCalls = append(m.BoldCalls, formatMessage(format, args...))
}
func (m *MockMessageLogger) Printf(format string, args ...any) {
m.PrintfCalls = append(m.PrintfCalls, formatMessage(format, args...))
}
func (m *MockMessageLogger) Fprintf(_ *os.File, format string, args ...any) {
// For testing, just track the formatted message
m.PrintfCalls = append(m.PrintfCalls, formatMessage(format, args...))
}
// MockErrorReporter implements ErrorReporter for testing.
type MockErrorReporter struct {
ErrorCalls []string
ErrorWithSuggestionsCalls []string
ErrorWithContextCalls []string
ErrorWithSimpleFixCalls []string
}
func (m *MockErrorReporter) Error(format string, args ...any) {
m.ErrorCalls = append(m.ErrorCalls, formatMessage(format, args...))
}
func (m *MockErrorReporter) ErrorWithSuggestions(err *errors.ContextualError) {
if err != nil {
m.ErrorWithSuggestionsCalls = append(m.ErrorWithSuggestionsCalls, err.Error())
}
}
func (m *MockErrorReporter) ErrorWithContext(_ errors.ErrorCode, message string, _ map[string]string) {
m.ErrorWithContextCalls = append(m.ErrorWithContextCalls, message)
}
func (m *MockErrorReporter) ErrorWithSimpleFix(message, suggestion string) {
m.ErrorWithSimpleFixCalls = append(m.ErrorWithSimpleFixCalls, message+": "+suggestion)
}
// MockProgressReporter implements ProgressReporter for testing.
type MockProgressReporter struct {
ProgressCalls []string
}
func (m *MockProgressReporter) Progress(format string, args ...any) {
m.ProgressCalls = append(m.ProgressCalls, formatMessage(format, args...))
}
// MockOutputConfig implements OutputConfig for testing.
type MockOutputConfig struct {
QuietMode bool
}
func (m *MockOutputConfig) IsQuiet() bool {
return m.QuietMode
}
// MockProgressManager implements ProgressManager for testing.
type MockProgressManager struct {
CreateProgressBarCalls []string
CreateProgressBarForFilesCalls []string
FinishProgressBarCalls int
FinishProgressBarWithNewlineCalls int
UpdateProgressBarCalls int
ProcessWithProgressBarCalls []string
}
func (m *MockProgressManager) CreateProgressBar(description string, total int) *progressbar.ProgressBar {
m.CreateProgressBarCalls = append(m.CreateProgressBarCalls, formatMessage("%s (total: %d)", description, total))
return nil // Return nil for mock to avoid actual progress bar
}
func (m *MockProgressManager) CreateProgressBarForFiles(description string, files []string) *progressbar.ProgressBar {
m.CreateProgressBarForFilesCalls = append(
m.CreateProgressBarForFilesCalls,
formatMessage("%s (files: %d)", description, len(files)),
)
return nil // Return nil for mock to avoid actual progress bar
}
func (m *MockProgressManager) FinishProgressBar(_ *progressbar.ProgressBar) {
m.FinishProgressBarCalls++
}
func (m *MockProgressManager) FinishProgressBarWithNewline(_ *progressbar.ProgressBar) {
m.FinishProgressBarWithNewlineCalls++
}
func (m *MockProgressManager) UpdateProgressBar(_ *progressbar.ProgressBar) {
m.UpdateProgressBarCalls++
}
func (m *MockProgressManager) ProcessWithProgressBar(
description string,
items []string,
processFunc func(item string, bar *progressbar.ProgressBar),
) {
m.ProcessWithProgressBarCalls = append(
m.ProcessWithProgressBarCalls,
formatMessage("%s (items: %d)", description, len(items)),
)
// Execute the process function for each item
for _, item := range items {
processFunc(item, nil)
}
}
// Helper function to format messages consistently.
func formatMessage(format string, args ...any) string {
if len(args) == 0 {
return format
}
// Simple formatting for test purposes
result := format
for _, arg := range args {
result = strings.Replace(result, "%s", toString(arg), 1)
result = strings.Replace(result, "%d", toString(arg), 1)
result = strings.Replace(result, "%v", toString(arg), 1)
}
return result
}
func toString(v any) string {
switch val := v.(type) {
case string:
return val
case int:
return formatInt(val)
default:
return "unknown"
}
}
func formatInt(i int) string {
// Simple int to string conversion for testing
if i == 0 {
return "0"
}
result := ""
negative := i < 0
if negative {
i = -i
}
for i > 0 {
digit := i % 10
result = string(rune('0'+digit)) + result
i /= 10
}
if negative {
result = "-" + result
}
return result
}
// Test that demonstrates improved testability with focused interfaces.
func TestFocusedInterfaces_SimpleLogger(t *testing.T) {
mockLogger := &MockMessageLogger{}
simpleLogger := NewSimpleLogger(mockLogger)
// Test successful operation
simpleLogger.LogOperation("test-operation", true)
// Verify the expected calls were made
if len(mockLogger.InfoCalls) != 1 {
t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls))
}
if len(mockLogger.SuccessCalls) != 1 {
t.Errorf("expected 1 Success call, got %d", len(mockLogger.SuccessCalls))
}
if len(mockLogger.WarningCalls) != 0 {
t.Errorf("expected 0 Warning calls, got %d", len(mockLogger.WarningCalls))
}
// Check message content
if !strings.Contains(mockLogger.InfoCalls[0], "test-operation") {
t.Errorf("expected Info call to contain 'test-operation', got: %s", mockLogger.InfoCalls[0])
}
if !strings.Contains(mockLogger.SuccessCalls[0], "test-operation") {
t.Errorf("expected Success call to contain 'test-operation', got: %s", mockLogger.SuccessCalls[0])
}
}
func TestFocusedInterfaces_SimpleLogger_WithFailure(t *testing.T) {
mockLogger := &MockMessageLogger{}
simpleLogger := NewSimpleLogger(mockLogger)
// Test failed operation
simpleLogger.LogOperation("failing-operation", false)
// Verify the expected calls were made
if len(mockLogger.InfoCalls) != 1 {
t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls))
}
if len(mockLogger.SuccessCalls) != 0 {
t.Errorf("expected 0 Success calls, got %d", len(mockLogger.SuccessCalls))
}
if len(mockLogger.WarningCalls) != 1 {
t.Errorf("expected 1 Warning call, got %d", len(mockLogger.WarningCalls))
}
}
func TestFocusedInterfaces_ErrorManager(t *testing.T) {
mockReporter := &MockErrorReporter{}
mockFormatter := &MockErrorFormatter{}
mockManager := &mockErrorManager{
reporter: mockReporter,
formatter: mockFormatter,
}
errorManager := NewFocusedErrorManager(mockManager)
// Test validation error handling
errorManager.HandleValidationError("test-file.yml", []string{"name", "description"})
// Verify the expected calls were made
if len(mockReporter.ErrorWithContextCalls) != 1 {
t.Errorf("expected 1 ErrorWithContext call, got %d", len(mockReporter.ErrorWithContextCalls))
}
if !strings.Contains(mockReporter.ErrorWithContextCalls[0], "test-file.yml") {
t.Errorf("expected error message to contain 'test-file.yml', got: %s", mockReporter.ErrorWithContextCalls[0])
}
}
func TestFocusedInterfaces_TaskProgress(t *testing.T) {
mockReporter := &MockProgressReporter{}
taskProgress := NewTaskProgress(mockReporter)
// Test progress reporting
taskProgress.ReportProgress("compile", 3, 10)
// Verify the expected calls were made
if len(mockReporter.ProgressCalls) != 1 {
t.Errorf("expected 1 Progress call, got %d", len(mockReporter.ProgressCalls))
}
if !strings.Contains(mockReporter.ProgressCalls[0], "compile") {
t.Errorf("expected progress message to contain 'compile', got: %s", mockReporter.ProgressCalls[0])
}
}
func TestFocusedInterfaces_ConfigAwareComponent(t *testing.T) {
tests := []struct {
name string
quietMode bool
shouldShow bool
}{
{
name: "normal mode should output",
quietMode: false,
shouldShow: true,
},
{
name: "quiet mode should not output",
quietMode: true,
shouldShow: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockConfig := &MockOutputConfig{QuietMode: tt.quietMode}
component := NewConfigAwareComponent(mockConfig)
result := component.ShouldOutput()
if result != tt.shouldShow {
t.Errorf("expected ShouldOutput() to return %v, got %v", tt.shouldShow, result)
}
})
}
}
func TestFocusedInterfaces_CompositeOutputWriter(t *testing.T) {
// Create a composite mock that implements OutputWriter
mockLogger := &MockMessageLogger{}
mockProgress := &MockProgressReporter{}
mockConfig := &MockOutputConfig{QuietMode: false}
compositeWriter := &CompositeOutputWriter{
writer: &mockOutputWriter{
logger: mockLogger,
reporter: mockProgress,
config: mockConfig,
},
}
items := []string{"item1", "item2", "item3"}
compositeWriter.ProcessWithOutput(items)
// Verify that the composite writer uses both message logging and progress reporting
// Should have called Info and Success for overall status
if len(mockLogger.InfoCalls) != 1 {
t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls))
}
if len(mockLogger.SuccessCalls) != 1 {
t.Errorf("expected 1 Success call, got %d", len(mockLogger.SuccessCalls))
}
// Should have called Progress for each item
if len(mockProgress.ProgressCalls) != 3 {
t.Errorf("expected 3 Progress calls, got %d", len(mockProgress.ProgressCalls))
}
}
func TestFocusedInterfaces_GeneratorWithDependencyInjection(t *testing.T) {
// Create focused mocks
mockOutput := &mockCompleteOutput{
logger: &MockMessageLogger{},
reporter: &MockErrorReporter{},
formatter: &MockErrorFormatter{},
progress: &MockProgressReporter{},
config: &MockOutputConfig{QuietMode: false},
}
mockProgress := &MockProgressManager{}
// Create generator with dependency injection
config := &AppConfig{
Theme: "default",
OutputFormat: "md",
OutputDir: ".",
Verbose: false,
Quiet: false,
}
generator := NewGeneratorWithDependencies(config, mockOutput, mockProgress)
// Verify the generator was created with the injected dependencies
if generator == nil {
t.Fatal("expected generator to be created")
}
if generator.Config != config {
t.Error("expected generator to have the provided config")
}
if generator.Output != mockOutput {
t.Error("expected generator to have the injected output")
}
if generator.Progress != mockProgress {
t.Error("expected generator to have the injected progress manager")
}
}
// Composite mock types to implement the composed interfaces
type mockCompleteOutput struct {
logger MessageLogger
reporter ErrorReporter
formatter ErrorFormatter
progress ProgressReporter
config OutputConfig
}
func (m *mockCompleteOutput) Info(format string, args ...any) { m.logger.Info(format, args...) }
func (m *mockCompleteOutput) Success(format string, args ...any) { m.logger.Success(format, args...) }
func (m *mockCompleteOutput) Warning(format string, args ...any) { m.logger.Warning(format, args...) }
func (m *mockCompleteOutput) Bold(format string, args ...any) { m.logger.Bold(format, args...) }
func (m *mockCompleteOutput) Printf(format string, args ...any) { m.logger.Printf(format, args...) }
func (m *mockCompleteOutput) Fprintf(w *os.File, format string, args ...any) {
m.logger.Fprintf(w, format, args...)
}
func (m *mockCompleteOutput) Error(format string, args ...any) { m.reporter.Error(format, args...) }
func (m *mockCompleteOutput) ErrorWithSuggestions(err *errors.ContextualError) {
m.reporter.ErrorWithSuggestions(err)
}
func (m *mockCompleteOutput) ErrorWithContext(code errors.ErrorCode, message string, context map[string]string) {
m.reporter.ErrorWithContext(code, message, context)
}
func (m *mockCompleteOutput) ErrorWithSimpleFix(message, suggestion string) {
m.reporter.ErrorWithSimpleFix(message, suggestion)
}
func (m *mockCompleteOutput) FormatContextualError(err *errors.ContextualError) string {
return m.formatter.FormatContextualError(err)
}
func (m *mockCompleteOutput) Progress(format string, args ...any) {
m.progress.Progress(format, args...)
}
func (m *mockCompleteOutput) IsQuiet() bool { return m.config.IsQuiet() }
type mockOutputWriter struct {
logger MessageLogger
reporter ProgressReporter
config OutputConfig
}
func (m *mockOutputWriter) Info(format string, args ...any) { m.logger.Info(format, args...) }
func (m *mockOutputWriter) Success(format string, args ...any) { m.logger.Success(format, args...) }
func (m *mockOutputWriter) Warning(format string, args ...any) { m.logger.Warning(format, args...) }
func (m *mockOutputWriter) Bold(format string, args ...any) { m.logger.Bold(format, args...) }
func (m *mockOutputWriter) Printf(format string, args ...any) { m.logger.Printf(format, args...) }
func (m *mockOutputWriter) Fprintf(w *os.File, format string, args ...any) {
m.logger.Fprintf(w, format, args...)
}
func (m *mockOutputWriter) Progress(format string, args ...any) { m.reporter.Progress(format, args...) }
func (m *mockOutputWriter) IsQuiet() bool { return m.config.IsQuiet() }
// MockErrorFormatter implements ErrorFormatter for testing.
type MockErrorFormatter struct {
FormatContextualErrorCalls []string
}
func (m *MockErrorFormatter) FormatContextualError(err *errors.ContextualError) string {
if err != nil {
formatted := err.Error()
m.FormatContextualErrorCalls = append(m.FormatContextualErrorCalls, formatted)
return formatted
}
return ""
}
// mockErrorManager implements ErrorManager for testing.
type mockErrorManager struct {
reporter ErrorReporter
formatter ErrorFormatter
}
func (m *mockErrorManager) Error(format string, args ...any) { m.reporter.Error(format, args...) }
func (m *mockErrorManager) ErrorWithSuggestions(err *errors.ContextualError) {
m.reporter.ErrorWithSuggestions(err)
}
func (m *mockErrorManager) ErrorWithContext(code errors.ErrorCode, message string, context map[string]string) {
m.reporter.ErrorWithContext(code, message, context)
}
func (m *mockErrorManager) ErrorWithSimpleFix(message, suggestion string) {
m.reporter.ErrorWithSimpleFix(message, suggestion)
}
func (m *mockErrorManager) FormatContextualError(err *errors.ContextualError) string {
return m.formatter.FormatContextualError(err)
}

View File

@@ -2,16 +2,19 @@ package internal
import (
"testing"
"github.com/ivuorinen/gh-action-readme/testutil"
)
func TestParseActionYML_Valid(t *testing.T) {
path := "../testdata/example-action/action.yml"
action, err := ParseActionYML(path)
// Create temporary action file using fixture
actionPath := testutil.CreateTemporaryAction(t, "actions/javascript/simple.yml")
action, err := ParseActionYML(actionPath)
if err != nil {
t.Fatalf("failed to parse action.yml: %v", err)
}
if action.Name != "Example Action" {
t.Errorf("expected name 'Example Action', got '%s'", action.Name)
if action.Name != "Simple JavaScript Action" {
t.Errorf("expected name 'Simple JavaScript Action', got '%s'", action.Name)
}
if action.Description == "" {
t.Error("expected non-empty description")

View File

@@ -1,10 +1,18 @@
package internal
import (
"path/filepath"
"testing"
"github.com/ivuorinen/gh-action-readme/testutil"
)
func TestRenderReadme(t *testing.T) {
// Set up test templates
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
testutil.SetupTestTemplates(t, tmpDir)
action := &ActionYML{
Name: "MyAction",
Description: "desc",
@@ -12,7 +20,7 @@ func TestRenderReadme(t *testing.T) {
"foo": {Description: "Foo input", Required: true},
},
}
tmpl := "../templates/readme.tmpl"
tmpl := filepath.Join(tmpDir, "templates", "readme.tmpl")
opts := TemplateOptions{TemplatePath: tmpl, Format: "md"}
out, err := RenderReadme(action, opts)
if err != nil {

View File

@@ -118,7 +118,7 @@ func (jw *JSONWriter) Write(action *ActionYML, outputPath string) error {
}
// Write to file
return os.WriteFile(outputPath, data, 0644)
return os.WriteFile(outputPath, data, FilePermDefault) // #nosec G306 -- JSON output file permissions
}
// convertToJSONOutput converts ActionYML to structured JSON output.

View File

@@ -11,11 +11,22 @@ import (
)
// ColoredOutput provides methods for colored terminal output.
// It implements all the focused interfaces for backward compatibility.
type ColoredOutput struct {
NoColor bool
Quiet bool
}
// Compile-time interface checks.
var (
_ MessageLogger = (*ColoredOutput)(nil)
_ ErrorReporter = (*ColoredOutput)(nil)
_ ErrorFormatter = (*ColoredOutput)(nil)
_ ProgressReporter = (*ColoredOutput)(nil)
_ OutputConfig = (*ColoredOutput)(nil)
_ CompleteOutput = (*ColoredOutput)(nil)
)
// NewColoredOutput creates a new colored output instance.
func NewColoredOutput(quiet bool) *ColoredOutput {
return &ColoredOutput{

View File

@@ -40,7 +40,7 @@ type Branding struct {
// ParseActionYML reads and parses action.yml from given path.
func ParseActionYML(path string) (*ActionYML, error) {
f, err := os.Open(path)
f, err := os.Open(path) // #nosec G304 -- path from function parameter
if err != nil {
return nil, err
}

View File

@@ -2,14 +2,20 @@
package internal
import (
"fmt"
"github.com/schollz/progressbar/v3"
)
// ProgressBarManager handles progress bar creation and management.
// It implements the ProgressManager interface.
type ProgressBarManager struct {
quiet bool
}
// Compile-time interface check.
var _ ProgressManager = (*ProgressBarManager)(nil)
// NewProgressBarManager creates a new progress bar manager.
func NewProgressBarManager(quiet bool) *ProgressBarManager {
return &ProgressBarManager{
@@ -48,3 +54,36 @@ func (pm *ProgressBarManager) FinishProgressBar(bar *progressbar.ProgressBar) {
_ = bar.Finish()
}
}
// FinishProgressBarWithNewline completes the progress bar display and adds a newline.
func (pm *ProgressBarManager) FinishProgressBarWithNewline(bar *progressbar.ProgressBar) {
pm.FinishProgressBar(bar)
if bar != nil {
fmt.Println()
}
}
// ProcessWithProgressBar executes a function for each item with progress tracking.
// The processFunc receives the item and the progress bar for updating.
func (pm *ProgressBarManager) ProcessWithProgressBar(
description string,
items []string,
processFunc func(item string, bar *progressbar.ProgressBar),
) {
bar := pm.CreateProgressBarForFiles(description, items)
defer pm.FinishProgressBarWithNewline(bar)
for _, item := range items {
processFunc(item, bar)
if bar != nil {
_ = bar.Add(1)
}
}
}
// UpdateProgressBar safely updates the progress bar if it exists.
func (pm *ProgressBarManager) UpdateProgressBar(bar *progressbar.ProgressBar) {
if bar != nil {
_ = bar.Add(1)
}
}

142
internal/progress_test.go Normal file
View File

@@ -0,0 +1,142 @@
package internal
import (
"testing"
"github.com/schollz/progressbar/v3"
)
func TestProgressBarManager_CreateProgressBar(t *testing.T) {
tests := []struct {
name string
quiet bool
description string
total int
expectNil bool
}{
{
name: "normal progress bar",
quiet: false,
description: "Test progress",
total: 10,
expectNil: false,
},
{
name: "quiet mode returns nil",
quiet: true,
description: "Test progress",
total: 10,
expectNil: true,
},
{
name: "single item returns nil",
quiet: false,
description: "Test progress",
total: 1,
expectNil: true,
},
{
name: "zero items returns nil",
quiet: false,
description: "Test progress",
total: 0,
expectNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pm := NewProgressBarManager(tt.quiet)
bar := pm.CreateProgressBar(tt.description, tt.total)
if tt.expectNil {
if bar != nil {
t.Errorf("expected nil progress bar, got %v", bar)
}
} else {
if bar == nil {
t.Error("expected progress bar, got nil")
}
}
})
}
}
func TestProgressBarManager_CreateProgressBarForFiles(t *testing.T) {
pm := NewProgressBarManager(false)
files := []string{"file1.yml", "file2.yml", "file3.yml"}
bar := pm.CreateProgressBarForFiles("Processing files", files)
if bar == nil {
t.Error("expected progress bar for multiple files, got nil")
}
}
func TestProgressBarManager_FinishProgressBar(_ *testing.T) {
pm := NewProgressBarManager(false)
// Test with nil bar (should not panic)
pm.FinishProgressBar(nil)
// Test with actual bar
bar := pm.CreateProgressBar("Test", 5)
if bar != nil {
pm.FinishProgressBar(bar)
}
}
func TestProgressBarManager_UpdateProgressBar(_ *testing.T) {
pm := NewProgressBarManager(false)
// Test with nil bar (should not panic)
pm.UpdateProgressBar(nil)
// Test with actual bar
bar := pm.CreateProgressBar("Test", 5)
if bar != nil {
pm.UpdateProgressBar(bar)
}
}
func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) {
pm := NewProgressBarManager(false)
items := []string{"item1", "item2", "item3"}
processedItems := make([]string, 0)
processFunc := func(item string, _ *progressbar.ProgressBar) {
processedItems = append(processedItems, item)
}
pm.ProcessWithProgressBar("Processing items", items, processFunc)
if len(processedItems) != len(items) {
t.Errorf("expected %d processed items, got %d", len(items), len(processedItems))
}
for i, item := range items {
if processedItems[i] != item {
t.Errorf("expected item %s at position %d, got %s", item, i, processedItems[i])
}
}
}
func TestProgressBarManager_ProcessWithProgressBar_QuietMode(t *testing.T) {
pm := NewProgressBarManager(true) // quiet mode
items := []string{"item1", "item2"}
processedItems := make([]string, 0)
processFunc := func(item string, bar *progressbar.ProgressBar) {
processedItems = append(processedItems, item)
// In quiet mode, bar should be nil
if bar != nil {
t.Error("expected nil progress bar in quiet mode")
}
}
pm.ProcessWithProgressBar("Processing items", items, processFunc)
if len(processedItems) != len(items) {
t.Errorf("expected %d processed items, got %d", len(items), len(processedItems))
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/ivuorinen/gh-action-readme/internal/cache"
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
"github.com/ivuorinen/gh-action-readme/internal/git"
"github.com/ivuorinen/gh-action-readme/internal/validation"
)
const (
@@ -45,10 +46,6 @@ type TemplateData struct {
Dependencies []dependencies.Dependency `json:"dependencies,omitempty"`
}
// GitInfo contains Git repository information for templates.
// Note: GitInfo struct removed - using git.RepoInfo instead to avoid duplication
// Note: Dependency struct is now defined in internal/dependencies package
// templateFuncs returns a map of custom template functions.
func templateFuncs() template.FuncMap {
return template.FuncMap{
@@ -115,19 +112,19 @@ func isValidOrgRepo(org, repo string) bool {
// formatVersion ensures version has proper @ prefix.
func formatVersion(version string) string {
version = strings.TrimSpace(version)
if version != "" && !strings.HasPrefix(version, "@") {
return "@" + version
}
if version == "" {
return "@v1"
}
if !strings.HasPrefix(version, "@") {
return "@" + version
}
return version
}
// buildUsesString constructs the uses string with optional action name.
func buildUsesString(td *TemplateData, org, repo, version string) string {
if td.Name != "" {
actionName := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(td.Name), " ", "-"))
actionName := validation.SanitizeActionName(td.Name)
if actionName != "" && actionName != repo {
return fmt.Sprintf("%s/%s/%s%s", org, repo, actionName, version)
}
@@ -225,7 +222,7 @@ func RenderReadme(action any, opts TemplateOptions) (string, error) {
return "", err
}
var tmpl *template.Template
if opts.Format == "html" {
if opts.Format == OutputFormatHTML {
tmpl, err = template.New("readme").Funcs(templateFuncs()).Parse(string(tmplContent))
if err != nil {
return "", err

View File

@@ -25,16 +25,19 @@ func IsSemanticVersion(version string) bool {
// IsVersionPinned checks if a semantic version is pinned to a specific version.
func IsVersionPinned(version string) bool {
// Consider it pinned if it specifies patch version (v1.2.3) or is a commit SHA
if IsSemanticVersion(version) {
return true
}
return IsCommitSHA(version) && len(version) == 40 // Only full SHAs are considered pinned
// Consider it pinned if it specifies patch version (v1.2.3) or is a full commit SHA
return IsSemanticVersion(version) || (IsCommitSHA(version) && len(version) == 40)
}
// ValidateGitBranch checks if a branch exists in the given repository.
func ValidateGitBranch(repoRoot, branch string) bool {
cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
cmd := exec.Command(
"git",
"show-ref",
"--verify",
"--quiet",
"refs/heads/"+branch,
) // #nosec G204 -- branch name validated by git
cmd.Dir = repoRoot
return cmd.Run() == nil
}

View File

@@ -19,7 +19,7 @@ func TestValidateActionYMLPath(t *testing.T) {
name: "valid action.yml file",
setupFunc: func(t *testing.T, tmpDir string) string {
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, testutil.SimpleActionYML)
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
return actionPath
},
expectError: false,
@@ -28,7 +28,7 @@ func TestValidateActionYMLPath(t *testing.T) {
name: "valid action.yaml file",
setupFunc: func(t *testing.T, tmpDir string) string {
actionPath := filepath.Join(tmpDir, "action.yaml")
testutil.WriteTestFile(t, actionPath, testutil.MinimalActionYML)
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("minimal-action.yml"))
return actionPath
},
expectError: false,
@@ -44,7 +44,7 @@ func TestValidateActionYMLPath(t *testing.T) {
name: "file with wrong extension",
setupFunc: func(t *testing.T, tmpDir string) string {
actionPath := filepath.Join(tmpDir, "action.txt")
testutil.WriteTestFile(t, actionPath, testutil.SimpleActionYML)
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
return actionPath
},
expectError: true,
@@ -240,7 +240,7 @@ func TestValidateGitBranch(t *testing.T) {
setupFunc: func(_ *testing.T, tmpDir string) (string, string) {
// Create a simple git repository
gitDir := filepath.Join(tmpDir, ".git")
_ = os.MkdirAll(gitDir, 0755)
_ = os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
// Create a basic git config
configContent := `[core]
@@ -297,7 +297,7 @@ func TestIsGitRepository(t *testing.T) {
name: "directory with .git folder",
setupFunc: func(_ *testing.T, tmpDir string) string {
gitDir := filepath.Join(tmpDir, ".git")
_ = os.MkdirAll(gitDir, 0755)
_ = os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
return tmpDir
},
expected: true,

View File

@@ -77,9 +77,7 @@ func (d *ProjectDetector) DetectProjectSettings() (*DetectedSettings, error) {
}
// Detect project characteristics
if err := d.detectProjectCharacteristics(settings); err != nil {
d.output.Warning("Could not detect project characteristics: %v", err)
}
d.detectProjectCharacteristics(settings)
// Suggest configuration based on detection
d.suggestConfiguration(settings)
@@ -134,7 +132,7 @@ func (d *ProjectDetector) detectActionFiles(settings *DetectedSettings) error {
}
// detectProjectCharacteristics detects project type, language, and framework.
func (d *ProjectDetector) detectProjectCharacteristics(settings *DetectedSettings) error {
func (d *ProjectDetector) detectProjectCharacteristics(settings *DetectedSettings) {
// Check for common files and patterns
characteristics := d.analyzeProjectFiles()
@@ -148,8 +146,6 @@ func (d *ProjectDetector) detectProjectCharacteristics(settings *DetectedSetting
settings.HasDockerfile = true
d.output.Success("Detected Dockerfile")
}
return nil
}
// detectVersion attempts to detect project version from various sources.
@@ -175,7 +171,7 @@ func (d *ProjectDetector) detectVersion() string {
// detectVersionFromPackageJSON detects version from package.json.
func (d *ProjectDetector) detectVersionFromPackageJSON() string {
packageJSONPath := filepath.Join(d.currentDir, "package.json")
data, err := os.ReadFile(packageJSONPath)
data, err := os.ReadFile(packageJSONPath) // #nosec G304 -- path is constructed from current directory
if err != nil {
return ""
}
@@ -208,6 +204,7 @@ func (d *ProjectDetector) detectVersionFromFiles() string {
for _, filename := range versionFiles {
versionPath := filepath.Join(d.currentDir, filename)
// #nosec G304 -- path constructed from current dir
if data, err := os.ReadFile(versionPath); err == nil {
version := strings.TrimSpace(string(data))
if version != "" {
@@ -293,7 +290,7 @@ func (d *ProjectDetector) analyzeActionFile(actionFile string, settings *Detecte
// parseActionFile reads and parses an action YAML file.
func (d *ProjectDetector) parseActionFile(actionFile string) (map[string]any, error) {
data, err := os.ReadFile(actionFile)
data, err := os.ReadFile(actionFile) // #nosec G304 -- action file path from function parameter
if err != nil {
return nil, fmt.Errorf("failed to read action file: %w", err)
}

View File

@@ -23,7 +23,7 @@ func TestProjectDetector_analyzeProjectFiles(t *testing.T) {
for filename, content := range testFiles {
filePath := filepath.Join(tempDir, filename)
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
if err := os.WriteFile(filePath, []byte(content), 0600); err != nil { // #nosec G306 -- test file permissions
t.Fatalf("Failed to create test file %s: %v", filename, err)
}
}
@@ -73,7 +73,7 @@ func TestProjectDetector_detectVersionFromPackageJSON(t *testing.T) {
}`
packagePath := filepath.Join(tempDir, "package.json")
if err := os.WriteFile(packagePath, []byte(packageJSON), 0644); err != nil {
if err := os.WriteFile(packagePath, []byte(packageJSON), 0600); err != nil { // #nosec G306 -- test file permissions
t.Fatalf("Failed to create package.json: %v", err)
}
@@ -95,7 +95,7 @@ func TestProjectDetector_detectVersionFromFiles(t *testing.T) {
// Create VERSION file
versionContent := "3.2.1\n"
versionPath := filepath.Join(tempDir, "VERSION")
if err := os.WriteFile(versionPath, []byte(versionContent), 0644); err != nil {
if err := os.WriteFile(versionPath, []byte(versionContent), 0600); err != nil { // #nosec G306 -- test file permissions
t.Fatalf("Failed to create VERSION file: %v", err)
}
@@ -116,18 +116,26 @@ func TestProjectDetector_findActionFiles(t *testing.T) {
// Create action files
actionYML := filepath.Join(tempDir, "action.yml")
if err := os.WriteFile(actionYML, []byte("name: Test Action"), 0644); err != nil {
if err := os.WriteFile(
actionYML,
[]byte("name: Test Action"),
0600, // #nosec G306 -- test file permissions
); err != nil {
t.Fatalf("Failed to create action.yml: %v", err)
}
// Create subdirectory with another action file
subDir := filepath.Join(tempDir, "subaction")
if err := os.MkdirAll(subDir, 0755); err != nil {
if err := os.MkdirAll(subDir, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("Failed to create subdirectory: %v", err)
}
subActionYAML := filepath.Join(subDir, "action.yaml")
if err := os.WriteFile(subActionYAML, []byte("name: Sub Action"), 0644); err != nil {
if err := os.WriteFile(
subActionYAML,
[]byte("name: Sub Action"),
0600, // #nosec G306 -- test file permissions
); err != nil {
t.Fatalf("Failed to create sub action.yaml: %v", err)
}

View File

@@ -39,7 +39,7 @@ func NewConfigExporter(output *internal.ColoredOutput) *ConfigExporter {
// ExportConfig exports the configuration to the specified format and path.
func (e *ConfigExporter) ExportConfig(config *internal.AppConfig, format ExportFormat, outputPath string) error {
// Create output directory if it doesn't exist
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
if err := os.MkdirAll(filepath.Dir(outputPath), 0750); err != nil { // #nosec G301 -- output directory permissions
return fmt.Errorf("failed to create output directory: %w", err)
}
@@ -60,7 +60,7 @@ func (e *ConfigExporter) exportYAML(config *internal.AppConfig, outputPath strin
// Create a clean config without sensitive data for export
exportConfig := e.sanitizeConfig(config)
file, err := os.Create(outputPath)
file, err := os.Create(outputPath) // #nosec G304 -- output path from function parameter
if err != nil {
return fmt.Errorf("failed to create YAML file: %w", err)
}
@@ -88,7 +88,7 @@ func (e *ConfigExporter) exportJSON(config *internal.AppConfig, outputPath strin
// Create a clean config without sensitive data for export
exportConfig := e.sanitizeConfig(config)
file, err := os.Create(outputPath)
file, err := os.Create(outputPath) // #nosec G304 -- output path from function parameter
if err != nil {
return fmt.Errorf("failed to create JSON file: %w", err)
}
@@ -113,7 +113,7 @@ func (e *ConfigExporter) exportTOML(config *internal.AppConfig, outputPath strin
// In a full implementation, you would use "github.com/BurntSushi/toml"
exportConfig := e.sanitizeConfig(config)
file, err := os.Create(outputPath)
file, err := os.Create(outputPath) // #nosec G304 -- output path from function parameter
if err != nil {
return fmt.Errorf("failed to create TOML file: %w", err)
}
@@ -126,9 +126,7 @@ func (e *ConfigExporter) exportTOML(config *internal.AppConfig, outputPath strin
_, _ = file.WriteString("# Generated by the interactive configuration wizard\n\n")
// Basic TOML export (simplified version)
if err := e.writeTOMLConfig(file, exportConfig); err != nil {
return fmt.Errorf("failed to write TOML: %w", err)
}
e.writeTOMLConfig(file, exportConfig)
e.output.Success("Configuration exported to: %s", outputPath)
return nil
@@ -173,7 +171,7 @@ func (e *ConfigExporter) sanitizeConfig(config *internal.AppConfig) *internal.Ap
}
// writeTOMLConfig writes a basic TOML configuration.
func (e *ConfigExporter) writeTOMLConfig(file *os.File, config *internal.AppConfig) error {
func (e *ConfigExporter) writeTOMLConfig(file *os.File, config *internal.AppConfig) {
e.writeRepositorySection(file, config)
e.writeTemplateSection(file, config)
e.writeFeaturesSection(file, config)
@@ -181,8 +179,6 @@ func (e *ConfigExporter) writeTOMLConfig(file *os.File, config *internal.AppConf
e.writeWorkflowSection(file, config)
e.writePermissionsSection(file, config)
e.writeVariablesSection(file, config)
return nil
}
// writeRepositorySection writes the repository information section.

View File

@@ -103,7 +103,7 @@ func verifyFileExists(t *testing.T, outputPath string) {
// verifyYAMLContent verifies YAML content is valid and contains expected data.
func verifyYAMLContent(t *testing.T, outputPath string, expected *internal.AppConfig) {
data, err := os.ReadFile(outputPath)
data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
@@ -123,7 +123,7 @@ func verifyYAMLContent(t *testing.T, outputPath string, expected *internal.AppCo
// verifyJSONContent verifies JSON content is valid and contains expected data.
func verifyJSONContent(t *testing.T, outputPath string, expected *internal.AppConfig) {
data, err := os.ReadFile(outputPath)
data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
@@ -143,7 +143,7 @@ func verifyJSONContent(t *testing.T, outputPath string, expected *internal.AppCo
// verifyTOMLContent verifies TOML content contains expected fields.
func verifyTOMLContent(t *testing.T, outputPath string) {
data, err := os.ReadFile(outputPath)
data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}

View File

@@ -43,24 +43,16 @@ func (w *ConfigWizard) Run() (*internal.AppConfig, error) {
}
// Step 2: Configure basic settings
if err := w.configureBasicSettings(); err != nil {
return nil, fmt.Errorf("failed to configure basic settings: %w", err)
}
w.configureBasicSettings()
// Step 3: Configure template and output settings
if err := w.configureTemplateSettings(); err != nil {
return nil, fmt.Errorf("failed to configure template settings: %w", err)
}
w.configureTemplateSettings()
// Step 4: Configure features
if err := w.configureFeatures(); err != nil {
return nil, fmt.Errorf("failed to configure features: %w", err)
}
w.configureFeatures()
// Step 5: Configure GitHub integration
if err := w.configureGitHubIntegration(); err != nil {
return nil, fmt.Errorf("failed to configure GitHub integration: %w", err)
}
w.configureGitHubIntegration()
// Step 6: Summary and confirmation
if err := w.showSummaryAndConfirm(); err != nil {
@@ -96,8 +88,8 @@ func (w *ConfigWizard) detectProjectSettings() error {
}
// Check for existing action files
actionFiles, err := w.findActionFiles(currentDir)
if err == nil && len(actionFiles) > 0 {
actionFiles := w.findActionFiles(currentDir)
if len(actionFiles) > 0 {
w.output.Success(" 🎯 Found %d action file(s)", len(actionFiles))
}
@@ -105,7 +97,7 @@ func (w *ConfigWizard) detectProjectSettings() error {
}
// configureBasicSettings handles basic configuration prompts.
func (w *ConfigWizard) configureBasicSettings() error {
func (w *ConfigWizard) configureBasicSettings() {
w.output.Bold("\n⚙ Step 2: Basic Settings")
// Organization
@@ -119,19 +111,15 @@ func (w *ConfigWizard) configureBasicSettings() error {
if version != "" {
w.config.Version = version
}
return nil
}
// configureTemplateSettings handles template and output configuration.
func (w *ConfigWizard) configureTemplateSettings() error {
func (w *ConfigWizard) configureTemplateSettings() {
w.output.Bold("\n🎨 Step 3: Template & Output Settings")
w.configureThemeSelection()
w.configureOutputFormat()
w.configureOutputDirectory()
return nil
}
// configureThemeSelection handles theme selection.
@@ -208,7 +196,7 @@ func (w *ConfigWizard) displayFormatOptions(formats []string) {
}
// configureFeatures handles feature configuration.
func (w *ConfigWizard) configureFeatures() error {
func (w *ConfigWizard) configureFeatures() {
w.output.Bold("\n🚀 Step 4: Features")
// Dependency analysis
@@ -220,19 +208,17 @@ func (w *ConfigWizard) configureFeatures() error {
w.output.Info("Security information shows pinned vs floating versions and security recommendations.")
showSecurity := w.promptYesNo("Show security information?", w.config.ShowSecurityInfo)
w.config.ShowSecurityInfo = showSecurity
return nil
}
// configureGitHubIntegration handles GitHub API configuration.
func (w *ConfigWizard) configureGitHubIntegration() error {
func (w *ConfigWizard) configureGitHubIntegration() {
w.output.Bold("\n🐙 Step 5: GitHub Integration")
// Check for existing token
existingToken := internal.GetGitHubToken(w.config)
if existingToken != "" {
w.output.Success("GitHub token already configured ✓")
return nil
return
}
w.output.Info("GitHub integration requires a personal access token for:")
@@ -245,7 +231,7 @@ func (w *ConfigWizard) configureGitHubIntegration() error {
if !setupToken {
w.output.Info("You can set up the token later using environment variables:")
w.output.Printf(" export GITHUB_TOKEN=your_personal_access_token")
return nil
return
}
w.output.Info("\nTo create a personal access token:")
@@ -265,8 +251,6 @@ func (w *ConfigWizard) configureGitHubIntegration() error {
w.config.GitHubToken = token
}
}
return nil
}
// showSummaryAndConfirm displays configuration summary and asks for confirmation.
@@ -286,9 +270,9 @@ func (w *ConfigWizard) showSummaryAndConfirm() error {
tokenStatus := "Not configured"
if w.config.GitHubToken != "" {
tokenStatus = "Configured ✓"
tokenStatus = "Configured ✓" // #nosec G101 -- status message, not actual token
} else if internal.GetGitHubToken(w.config) != "" {
tokenStatus = "Configured via environment ✓"
tokenStatus = "Configured via environment ✓" // #nosec G101 -- status message, not actual token
}
w.output.Printf(" GitHub Token: %s", tokenStatus)
@@ -361,7 +345,7 @@ func (w *ConfigWizard) promptYesNo(prompt string, defaultValue bool) bool {
}
// findActionFiles discovers action files in the given directory.
func (w *ConfigWizard) findActionFiles(dir string) ([]string, error) {
func (w *ConfigWizard) findActionFiles(dir string) []string {
var actionFiles []string
// Check for action.yml and action.yaml
@@ -372,5 +356,5 @@ func (w *ConfigWizard) findActionFiles(dir string) ([]string, error) {
}
}
return actionFiles, nil
return actionFiles
}