Files
gh-action-readme/internal/configuration_loader.go
Ismo Vuorinen 7f80105ff5 feat: go 1.25.5, dependency updates, renamed internal/errors (#129)
* feat: rename internal/errors to internal/apperrors

* fix(tests): clear env values before using in tests

* feat: rename internal/errors to internal/apperrors

* chore(deps): update go and all dependencies

* chore: remove renovate from pre-commit, formatting

* chore: sonarcloud fixes

* feat: consolidate constants to appconstants/constants.go

* chore: sonarcloud fixes

* feat: simplification, deduplication, test utils

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: clean up

* fix: config discovery, const deduplication

* chore: fixes
2026-01-01 23:17:29 +02:00

305 lines
9.1 KiB
Go

package internal
import (
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/spf13/viper"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// ConfigurationLoader handles loading and merging configuration from multiple sources.
type ConfigurationLoader struct {
// sources tracks which sources are enabled
sources map[appconstants.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 []appconstants.ConfigurationSource
}
// NewConfigurationLoader creates a new configuration loader with default options.
func NewConfigurationLoader() *ConfigurationLoader {
return &ConfigurationLoader{
sources: map[appconstants.ConfigurationSource]bool{
appconstants.SourceDefaults: true,
appconstants.SourceGlobal: true,
appconstants.SourceRepoOverride: true,
appconstants.SourceRepoConfig: true,
appconstants.SourceActionConfig: true,
appconstants.SourceEnvironment: true,
appconstants.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[appconstants.ConfigurationSource]bool),
viper: viper.New(),
}
// Set default sources if none specified
if len(opts.EnabledSources) == 0 {
opts.EnabledSources = []appconstants.ConfigurationSource{
appconstants.SourceDefaults, appconstants.SourceGlobal, appconstants.SourceRepoOverride,
appconstants.SourceRepoConfig, appconstants.SourceActionConfig, appconstants.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
}
// 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 errors.New("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 errors.New("output directory cannot be empty")
}
// Validate mutually exclusive flags
if config.Verbose && config.Quiet {
return errors.New("verbose and quiet flags are mutually exclusive")
}
return nil
}
// 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() []appconstants.ConfigurationSource {
var sources []appconstants.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 appconstants.ConfigurationSource) {
cl.sources[source] = true
}
// DisableSource disables a specific configuration source.
func (cl *ConfigurationLoader) DisableSource(source appconstants.ConfigurationSource) {
cl.sources[source] = false
}
// loadDefaultsStep loads default configuration values.
func (cl *ConfigurationLoader) loadDefaultsStep(config *AppConfig) {
if cl.sources[appconstants.SourceDefaults] {
defaults := DefaultAppConfig()
*config = *defaults
}
}
// loadGlobalStep loads global configuration.
func (cl *ConfigurationLoader) loadGlobalStep(config *AppConfig, configFile string) error {
if !cl.sources[appconstants.SourceGlobal] {
return nil
}
globalConfig, err := cl.loadGlobalConfig(configFile)
if err != nil {
return fmt.Errorf(appconstants.ErrFailedToLoadGlobalConfig, 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[appconstants.SourceRepoOverride] || repoRoot == "" {
return
}
cl.applyRepoOverrides(config, repoRoot)
}
// loadRepoConfigStep loads repository root configuration.
func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot string) error {
if !cl.sources[appconstants.SourceRepoConfig] || repoRoot == "" {
return nil
}
repoConfig, err := cl.loadRepoConfig(repoRoot)
if err != nil {
return fmt.Errorf(appconstants.ErrFailedToLoadRepoConfig, 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[appconstants.SourceActionConfig] || actionDir == "" {
return nil
}
actionConfig, err := cl.loadActionConfig(actionDir)
if err != nil {
return fmt.Errorf(appconstants.ErrFailedToLoadActionConfig, 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[appconstants.SourceEnvironment] {
cl.applyEnvironmentOverrides(config)
}
}
// loadGlobalConfig initializes and loads the global configuration using Viper.
func (cl *ConfigurationLoader) loadGlobalConfig(configFile string) (*AppConfig, error) {
v, err := initializeViperInstance()
if err != nil {
return nil, err
}
return loadAndUnmarshalConfig(configFile, v)
}
// loadRepoConfig loads repository-level configuration from hidden config files.
func (cl *ConfigurationLoader) loadRepoConfig(repoRoot string) (*AppConfig, error) {
return loadRepoConfigInternal(repoRoot)
}
// loadActionConfig loads action-level configuration from config.yaml.
func (cl *ConfigurationLoader) loadActionConfig(actionDir string) (*AppConfig, error) {
return loadActionConfigInternal(actionDir)
}
// 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 := loadGitHubTokenFromEnv(); 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)
}
// validateTheme validates that a theme exists and is supported.
func (cl *ConfigurationLoader) validateTheme(theme string) error {
if theme == "" {
return errors.New("theme cannot be empty")
}
// Check if it's a built-in theme
if containsString(appconstants.GetSupportedThemes(), 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(appconstants.GetSupportedThemes(), ", "))
}