diff --git a/cmd/root_test.go b/cmd/root_test.go index bc74f55bb..74d6abdf1 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -144,7 +144,7 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { // Create config handler var configHandler config.ConfigHandler if options.ConfigHandler == nil { - configHandler = config.NewYamlConfigHandler(injector) + configHandler = config.NewConfigHandler(injector) } else { configHandler = options.ConfigHandler } diff --git a/pkg/config/config_handler.go b/pkg/config/config_handler.go index 6723dac60..256449eb7 100644 --- a/pkg/config/config_handler.go +++ b/pkg/config/config_handler.go @@ -2,7 +2,12 @@ package config import ( "fmt" + "math" + "os" "path/filepath" + "reflect" + "strconv" + "strings" "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/di" @@ -53,31 +58,39 @@ const ( contextFileName = "context" ) -// BaseConfigHandler is a base implementation of the ConfigHandler interface -type BaseConfigHandler struct { - ConfigHandler - injector di.Injector - shell shell.Shell - config v1alpha1.Config - context string - secretsProviders []secrets.SecretsProvider - loaded bool - shims *Shims - schemaValidator *SchemaValidator - contextValues map[string]any +// configHandler is the concrete implementation of the ConfigHandler interface that provides +// YAML-based configuration management with support for contexts, schemas, and values files. +type configHandler struct { + injector di.Injector + shell shell.Shell + config v1alpha1.Config + context string + secretsProviders []secrets.SecretsProvider + loaded bool + shims *Shims + schemaValidator *SchemaValidator + contextValues map[string]any + path string + defaultContextConfig v1alpha1.Context + loadedContexts map[string]bool } // ============================================================================= // Constructor // ============================================================================= -// NewBaseConfigHandler creates a new BaseConfigHandler instance -func NewBaseConfigHandler(injector di.Injector) *BaseConfigHandler { - return &BaseConfigHandler{ - injector: injector, - shims: NewShims(), - contextValues: make(map[string]any), +// NewConfigHandler creates a new ConfigHandler instance with default context configuration. +func NewConfigHandler(injector di.Injector) ConfigHandler { + handler := &configHandler{ + injector: injector, + shims: NewShims(), + contextValues: make(map[string]any), + loadedContexts: make(map[string]bool), } + + handler.config.Version = "v1alpha1" + + return handler } // ============================================================================= @@ -85,7 +98,7 @@ func NewBaseConfigHandler(injector di.Injector) *BaseConfigHandler { // ============================================================================= // Initialize sets up the config handler by resolving and storing the shell dependency. -func (c *BaseConfigHandler) Initialize() error { +func (c *configHandler) Initialize() error { shell, ok := c.injector.Resolve("shell").(shell.Shell) if !ok { return fmt.Errorf("error resolving shell") @@ -98,8 +111,463 @@ func (c *BaseConfigHandler) Initialize() error { return nil } +// LoadConfigString loads configuration from a YAML string into the internal config structure. +// It unmarshals the YAML, records which contexts were present in the input, validates and sets +// the config version, and marks the configuration as loaded. Returns an error if unmarshalling +// fails or if the config version is unsupported. +func (c *configHandler) LoadConfigString(content string) error { + if content == "" { + return nil + } + + var tempConfig v1alpha1.Config + if err := c.shims.YamlUnmarshal([]byte(content), &tempConfig); err != nil { + return fmt.Errorf("error unmarshalling yaml: %w", err) + } + + if tempConfig.Contexts != nil { + for contextName := range tempConfig.Contexts { + c.loadedContexts[contextName] = true + } + } + + if err := c.shims.YamlUnmarshal([]byte(content), &c.config); err != nil { + return fmt.Errorf("error unmarshalling yaml: %w", err) + } + + if c.config.Version == "" { + c.config.Version = "v1alpha1" + } else if c.config.Version != "v1alpha1" { + return fmt.Errorf("unsupported config version: %s", c.config.Version) + } + + return nil +} + +// LoadConfig loads the configuration from the specified path. If the file does not exist, it does nothing. +func (c *configHandler) LoadConfig(path string) error { + c.path = path + if _, err := c.shims.Stat(path); os.IsNotExist(err) { + return nil + } + + data, err := c.shims.ReadFile(path) + if err != nil { + return fmt.Errorf("error reading config file: %w", err) + } + + return c.LoadConfigString(string(data)) +} + +// LoadContextConfig loads the context-specific windsor.yaml file for the current context. +// It is intended for pipelines that only require static context configuration, not dynamic values. +// The values.yaml file is loaded lazily upon first access via Get() or SetContextValue(). +// Returns nil if already loaded, or if loading succeeds; otherwise returns an error for shell or file-related issues. +func (c *configHandler) LoadContextConfig() error { + if c.loaded { + return nil + } + if c.shell == nil { + return fmt.Errorf("shell not initialized") + } + + projectRoot, err := c.shell.GetProjectRoot() + if err != nil { + return fmt.Errorf("error retrieving project root: %w", err) + } + + contextName := c.GetContext() + contextConfigDir := filepath.Join(projectRoot, "contexts", contextName) + + yamlPath := filepath.Join(contextConfigDir, "windsor.yaml") + ymlPath := filepath.Join(contextConfigDir, "windsor.yml") + + var contextConfigPath string + if _, err := c.shims.Stat(yamlPath); err == nil { + contextConfigPath = yamlPath + } else if _, err := c.shims.Stat(ymlPath); err == nil { + contextConfigPath = ymlPath + } + + if contextConfigPath != "" { + data, err := c.shims.ReadFile(contextConfigPath) + if err != nil { + return fmt.Errorf("error reading context config file: %w", err) + } + + var contextConfig v1alpha1.Context + if err := c.shims.YamlUnmarshal(data, &contextConfig); err != nil { + return fmt.Errorf("error unmarshalling context yaml: %w", err) + } + + if c.config.Contexts == nil { + c.config.Contexts = make(map[string]*v1alpha1.Context) + } + + if c.config.Contexts[contextName] == nil { + c.config.Contexts[contextName] = &v1alpha1.Context{} + } + + c.config.Contexts[contextName].Merge(&contextConfig) + + c.loadedContexts[contextName] = true + } + + if len(c.config.Contexts) > 0 { + c.loaded = true + } + + return nil +} + +// IsContextConfigLoaded returns true if the base configuration is loaded, the current context name is set, +// and a context-specific configuration has been loaded for the current context. Returns false otherwise. +func (c *configHandler) IsContextConfigLoaded() bool { + if !c.loaded { + return false + } + + contextName := c.GetContext() + if contextName == "" { + return false + } + + return c.loadedContexts[contextName] +} + +// SaveConfig writes the current configuration state to disk. It writes the root windsor.yaml file with only the version field, +// and creates or updates the current context's windsor.yaml file and values.yaml containing dynamic schema-based values. +// The root windsor.yaml is created if missing; the context windsor.yaml is created if missing and not tracked in the root config; +// values.yaml is created if context values are present. If the overwrite parameter is true, existing files are updated with the +// current in-memory state. All operations are performed for the current context. +func (c *configHandler) SaveConfig(overwrite ...bool) error { + if c.shell == nil { + return fmt.Errorf("shell not initialized") + } + + shouldOverwrite := false + if len(overwrite) > 0 { + shouldOverwrite = overwrite[0] + } + + projectRoot, err := c.shell.GetProjectRoot() + if err != nil { + return fmt.Errorf("error retrieving project root: %w", err) + } + + rootConfigPath := filepath.Join(projectRoot, "windsor.yaml") + contextName := c.GetContext() + contextConfigPath := filepath.Join(projectRoot, "contexts", contextName, "windsor.yaml") + + rootExists := false + if _, err := c.shims.Stat(rootConfigPath); err == nil { + rootExists = true + } + + contextExists := false + if _, err := c.shims.Stat(contextConfigPath); err == nil { + contextExists = true + } + + contextExistsInRoot := c.loadedContexts[contextName] + + shouldCreateRootConfig := !rootExists + shouldCreateContextConfig := !contextExists && !contextExistsInRoot + shouldUpdateRootConfig := shouldOverwrite && rootExists + shouldUpdateContextConfig := shouldOverwrite && contextExists + + if shouldCreateRootConfig || shouldUpdateRootConfig { + rootConfig := struct { + Version string `yaml:"version"` + }{ + Version: c.config.Version, + } + + data, err := c.shims.YamlMarshal(rootConfig) + if err != nil { + return fmt.Errorf("error marshalling root config: %w", err) + } + + if err := c.shims.WriteFile(rootConfigPath, data, 0644); err != nil { + return fmt.Errorf("error writing root config: %w", err) + } + } + + if shouldCreateContextConfig || shouldUpdateContextConfig { + var contextConfig v1alpha1.Context + + if c.config.Contexts != nil && c.config.Contexts[contextName] != nil { + contextConfig = *c.config.Contexts[contextName] + } else { + contextConfig = c.defaultContextConfig + } + + contextDir := filepath.Join(projectRoot, "contexts", contextName) + if err := c.shims.MkdirAll(contextDir, 0755); err != nil { + return fmt.Errorf("error creating context directory: %w", err) + } + + data, err := c.shims.YamlMarshal(contextConfig) + if err != nil { + return fmt.Errorf("error marshalling context config: %w", err) + } + + if err := c.shims.WriteFile(contextConfigPath, data, 0644); err != nil { + return fmt.Errorf("error writing context config: %w", err) + } + } + + if c.loaded && c.contextValues != nil && len(c.contextValues) > 0 { + if err := c.saveContextValues(); err != nil { + return fmt.Errorf("error saving values.yaml: %w", err) + } + } + + return nil +} + +// SetDefault sets the given context configuration as the default and merges it with any +// existing context configuration. If no context exists, the default becomes the context. +// If a context exists, it merges the default with the existing context, with existing +// values taking precedence over defaults. +func (c *configHandler) SetDefault(context v1alpha1.Context) error { + c.defaultContextConfig = context + currentContext := c.GetContext() + contextKey := fmt.Sprintf("contexts.%s", currentContext) + + if c.Get(contextKey) == nil { + return c.Set(contextKey, &context) + } + + if c.config.Contexts == nil { + c.config.Contexts = make(map[string]*v1alpha1.Context) + } + if c.config.Contexts[currentContext] == nil { + c.config.Contexts[currentContext] = &v1alpha1.Context{} + } + defaultCopy := context.DeepCopy() + existingCopy := c.config.Contexts[currentContext].DeepCopy() + defaultCopy.Merge(existingCopy) + c.config.Contexts[currentContext] = defaultCopy + + return nil +} + +// Get returns the value at the specified configuration path using the configured lookup precedence. +// Lookup order is: context configuration from windsor.yaml, then values.yaml, then schema defaults. +// Returns nil if the path is empty or if no value is found in any source. +func (c *configHandler) Get(path string) any { + if path == "" { + return nil + } + pathKeys := parsePath(path) + + value := getValueByPath(c.config, pathKeys) + if value != nil { + return value + } + + if len(pathKeys) >= 2 && pathKeys[0] == "contexts" { + if len(pathKeys) >= 3 && c.loaded { + if err := c.ensureValuesYamlLoaded(); err != nil { + } + if c.contextValues != nil { + key := pathKeys[2] + if value, exists := c.contextValues[key]; exists { + return value + } + } + } + + value = getValueByPath(c.defaultContextConfig, pathKeys[2:]) + if value != nil { + return value + } + } + + if len(pathKeys) == 1 && c.schemaValidator != nil && c.schemaValidator.Schema != nil { + defaults, err := c.schemaValidator.GetSchemaDefaults() + if err == nil { + if value, exists := defaults[pathKeys[0]]; exists { + return value + } + } + } + + return nil +} + +// GetString retrieves a string value for the specified key from the configuration, with an optional default value. +// If the key is not found, it returns the provided default value or an empty string if no default is provided. +func (c *configHandler) GetString(key string, defaultValue ...string) string { + contextKey := fmt.Sprintf("contexts.%s.%s", c.context, key) + value := c.Get(contextKey) + if value == nil { + if len(defaultValue) > 0 { + return defaultValue[0] + } + return "" + } + strValue := fmt.Sprintf("%v", value) + return strValue +} + +// GetInt retrieves an integer value for the specified key from the configuration, with an optional default value. +func (c *configHandler) GetInt(key string, defaultValue ...int) int { + contextKey := fmt.Sprintf("contexts.%s.%s", c.context, key) + value := c.Get(contextKey) + if value == nil { + if len(defaultValue) > 0 { + return defaultValue[0] + } + return 0 + } + intValue, ok := value.(int) + if !ok { + return 0 + } + return intValue +} + +// GetBool retrieves a boolean value for the specified key from the configuration, with an optional default value. +func (c *configHandler) GetBool(key string, defaultValue ...bool) bool { + contextKey := fmt.Sprintf("contexts.%s.%s", c.context, key) + value := c.Get(contextKey) + if value == nil { + if len(defaultValue) > 0 { + return defaultValue[0] + } + return false + } + if boolValue, ok := value.(bool); ok { + return boolValue + } + return false +} + +// GetStringSlice retrieves a slice of strings for the specified key from the configuration, with an optional default value. +// If the key is not found, it returns the provided default value or an empty slice if no default is provided. +func (c *configHandler) GetStringSlice(key string, defaultValue ...[]string) []string { + contextKey := fmt.Sprintf("contexts.%s.%s", c.context, key) + value := c.Get(contextKey) + if value == nil { + if len(defaultValue) > 0 { + return defaultValue[0] + } + return []string{} + } + strSlice, ok := value.([]string) + if !ok { + return []string{} + } + return strSlice +} + +// GetStringMap retrieves a map of string key-value pairs for the specified key from the configuration. +// If the key is not found, it returns the provided default value or an empty map if no default is provided. +func (c *configHandler) GetStringMap(key string, defaultValue ...map[string]string) map[string]string { + contextKey := fmt.Sprintf("contexts.%s.%s", c.context, key) + value := c.Get(contextKey) + if value == nil { + if len(defaultValue) > 0 { + return defaultValue[0] + } + return map[string]string{} + } + + strMap, ok := value.(map[string]string) + if !ok { + return map[string]string{} + } + + return strMap +} + +// Set updates the value at the specified path in the configuration using reflection. +// It parses the path, performs type conversion if necessary, and sets the value in the config structure. +// An error is returned if the path is invalid, conversion fails, or the update cannot be performed. +func (c *configHandler) Set(path string, value any) error { + if path == "" { + return nil + } + + pathKeys := parsePath(path) + if len(pathKeys) == 0 { + return fmt.Errorf("invalid path: %s", path) + } + + if strValue, ok := value.(string); ok { + currentValue := c.Get(path) + if currentValue != nil { + targetType := reflect.TypeOf(currentValue) + convertedValue, err := convertValue(strValue, targetType) + if err != nil { + return fmt.Errorf("error converting value for %s: %w", path, err) + } + value = convertedValue + } + } + + configValue := reflect.ValueOf(&c.config) + return setValueByPath(configValue, pathKeys, value, path) +} + +// SetContextValue sets a value at the given path for the current context, updating config or values.yaml in memory. +// The key must not be empty or have invalid dot notation. Values are schema-validated if available. +// Changes require SaveConfig to persist. Returns error if path or value is invalid, or conversion fails. +func (c *configHandler) SetContextValue(path string, value any) error { + if path == "" { + return fmt.Errorf("path cannot be empty") + } + if strings.Contains(path, "..") || strings.HasPrefix(path, ".") || strings.HasSuffix(path, ".") { + return fmt.Errorf("invalid path format: %s", path) + } + if c.isKeyInStaticSchema(path) { + if c.config.Contexts == nil { + c.config.Contexts = make(map[string]*v1alpha1.Context) + } + contextName := c.GetContext() + if c.config.Contexts[contextName] == nil { + c.config.Contexts[contextName] = &v1alpha1.Context{} + } + fullPath := fmt.Sprintf("contexts.%s.%s", contextName, path) + return c.Set(fullPath, value) + } + if err := c.ensureValuesYamlLoaded(); err != nil { + return fmt.Errorf("error loading values.yaml: %w", err) + } + convertedValue := c.convertStringValue(value) + c.contextValues[path] = convertedValue + if c.schemaValidator != nil && c.schemaValidator.Schema != nil { + if result, err := c.schemaValidator.Validate(c.contextValues); err != nil { + return fmt.Errorf("error validating context value: %w", err) + } else if !result.Valid { + return fmt.Errorf("context value validation failed: %v", result.Errors) + } + } + return nil +} + +// GetConfig returns the context config object for the current context, or the default if none is set. +func (c *configHandler) GetConfig() *v1alpha1.Context { + defaultConfigCopy := c.defaultContextConfig.DeepCopy() + context := c.context + + if context == "" { + return defaultConfigCopy + } + + if ctx, ok := c.config.Contexts[context]; ok { + mergedConfig := defaultConfigCopy + mergedConfig.Merge(ctx) + return mergedConfig + } + + return defaultConfigCopy +} + // GetContext retrieves the current context from the environment, file, or defaults to "local" -func (c *BaseConfigHandler) GetContext() string { +func (c *configHandler) GetContext() string { contextName := "local" envContext := c.shims.Getenv("WINDSOR_CONTEXT") @@ -124,7 +592,7 @@ func (c *BaseConfigHandler) GetContext() string { } // SetContext sets the current context in the file and updates the cache -func (c *BaseConfigHandler) SetContext(context string) error { +func (c *configHandler) SetContext(context string) error { projectRoot, err := c.shell.GetProjectRoot() if err != nil { return fmt.Errorf("error getting project root: %w", err) @@ -150,7 +618,7 @@ func (c *BaseConfigHandler) SetContext(context string) error { } // GetConfigRoot retrieves the configuration root path based on the current context -func (c *BaseConfigHandler) GetConfigRoot() (string, error) { +func (c *configHandler) GetConfigRoot() (string, error) { context := c.GetContext() projectRoot, err := c.shell.GetProjectRoot() @@ -163,7 +631,7 @@ func (c *BaseConfigHandler) GetConfigRoot() (string, error) { } // Clean cleans up context specific artifacts -func (c *BaseConfigHandler) Clean() error { +func (c *configHandler) Clean() error { configRoot, err := c.GetConfigRoot() if err != nil { return fmt.Errorf("error getting config root: %w", err) @@ -184,23 +652,18 @@ func (c *BaseConfigHandler) Clean() error { } // IsLoaded checks if the configuration has been loaded -func (c *BaseConfigHandler) IsLoaded() bool { +func (c *configHandler) IsLoaded() bool { return c.loaded } -// LoadContextConfig provides a base implementation that should be overridden by concrete implementations -func (c *BaseConfigHandler) LoadContextConfig() error { - return fmt.Errorf("LoadContextConfig not implemented in base config handler") -} - // SetSecretsProvider sets the secrets provider for the config handler -func (c *BaseConfigHandler) SetSecretsProvider(provider secrets.SecretsProvider) { +func (c *configHandler) SetSecretsProvider(provider secrets.SecretsProvider) { c.secretsProviders = append(c.secretsProviders, provider) } // LoadSchema loads the schema.yaml file from the specified directory // Returns error if schema file doesn't exist or is invalid -func (c *BaseConfigHandler) LoadSchema(schemaPath string) error { +func (c *configHandler) LoadSchema(schemaPath string) error { if c.schemaValidator == nil { return fmt.Errorf("schema validator not initialized") } @@ -209,7 +672,7 @@ func (c *BaseConfigHandler) LoadSchema(schemaPath string) error { // LoadSchemaFromBytes loads schema directly from byte content // Returns error if schema content is invalid -func (c *BaseConfigHandler) LoadSchemaFromBytes(schemaContent []byte) error { +func (c *configHandler) LoadSchemaFromBytes(schemaContent []byte) error { if c.schemaValidator == nil { return fmt.Errorf("schema validator not initialized") } @@ -218,15 +681,648 @@ func (c *BaseConfigHandler) LoadSchemaFromBytes(schemaContent []byte) error { // GetSchemaDefaults extracts default values from the loaded schema // Returns defaults as a map suitable for merging with user values -func (c *BaseConfigHandler) GetSchemaDefaults() (map[string]any, error) { +func (c *configHandler) GetSchemaDefaults() (map[string]any, error) { if c.schemaValidator == nil { return nil, fmt.Errorf("schema validator not initialized") } return c.schemaValidator.GetSchemaDefaults() } -// GetContextValues returns the loaded context values from values.yaml -// This should be overridden by concrete implementations like YamlConfigHandler -func (c *BaseConfigHandler) GetContextValues() (map[string]any, error) { - return nil, fmt.Errorf("GetContextValues not implemented in base config handler") +// GetContextValues returns merged context values from windsor.yaml (via GetConfig) and values.yaml +// The context config is converted to a map and deep merged with values.yaml, with values.yaml taking precedence +func (c *configHandler) GetContextValues() (map[string]any, error) { + if err := c.ensureValuesYamlLoaded(); err != nil { + return nil, err + } + + contextConfig := c.GetConfig() + contextData, err := c.shims.YamlMarshal(contextConfig) + if err != nil { + return nil, fmt.Errorf("error marshalling context config: %w", err) + } + + var contextMap map[string]any + if err := c.shims.YamlUnmarshal(contextData, &contextMap); err != nil { + return nil, fmt.Errorf("error unmarshalling context config to map: %w", err) + } + + return c.deepMerge(contextMap, c.contextValues), nil +} + +// GenerateContextID generates a random context ID if one doesn't exist +func (c *configHandler) GenerateContextID() error { + if c.GetString("id") != "" { + return nil + } + + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 7) + if _, err := c.shims.CryptoRandRead(b); err != nil { + return fmt.Errorf("failed to generate random context ID: %w", err) + } + + for i := range b { + b[i] = charset[int(b[i])%len(charset)] + } + + id := "w" + string(b) + return c.SetContextValue("id", id) +} + +// Ensure configHandler implements ConfigHandler +var _ ConfigHandler = (*configHandler)(nil) + +// ============================================================================= +// Private Methods +// ============================================================================= + +// ensureValuesYamlLoaded loads and validates the values.yaml file for the current context, and loads the schema if required. +// It initializes c.contextValues by reading values.yaml from the context directory, unless already loaded or not required. +// If values.yaml or the schema is missing, it initializes an empty map and performs schema validation if possible. +// Returns an error if any schema loading, reading, or unmarshaling fails. +func (c *configHandler) ensureValuesYamlLoaded() error { + if c.contextValues != nil { + return nil + } + if c.shell == nil || !c.loaded { + c.contextValues = make(map[string]any) + return nil + } + projectRoot, err := c.shell.GetProjectRoot() + if err != nil { + return fmt.Errorf("error retrieving project root: %w", err) + } + contextName := c.GetContext() + contextConfigDir := filepath.Join(projectRoot, "contexts", contextName) + valuesPath := filepath.Join(contextConfigDir, "values.yaml") + if c.schemaValidator != nil && c.schemaValidator.Schema == nil { + schemaPath := filepath.Join(projectRoot, "contexts", "_template", "schema.yaml") + if _, err := c.shims.Stat(schemaPath); err == nil { + if err := c.schemaValidator.LoadSchema(schemaPath); err != nil { + return fmt.Errorf("error loading schema: %w", err) + } + } + } + if _, err := c.shims.Stat(valuesPath); err == nil { + data, err := c.shims.ReadFile(valuesPath) + if err != nil { + return fmt.Errorf("error reading values.yaml: %w", err) + } + var values map[string]any + if err := c.shims.YamlUnmarshal(data, &values); err != nil { + return fmt.Errorf("error unmarshalling values.yaml: %w", err) + } + if c.schemaValidator != nil && c.schemaValidator.Schema != nil { + if result, err := c.schemaValidator.Validate(values); err != nil { + return fmt.Errorf("error validating values.yaml: %w", err) + } else if !result.Valid { + return fmt.Errorf("values.yaml validation failed: %v", result.Errors) + } + } + c.contextValues = values + } else { + c.contextValues = make(map[string]any) + } + return nil +} + +// saveContextValues writes the current contextValues map to a values.yaml file in the context directory for the current context. +// This function ensures that the target context directory exists. If a schema validator is configured, it validates the contextValues +// against the schema before saving. The function marshals the values to YAML, writes them to values.yaml, and returns an error if +// validation, marshalling, or file writing fails. +func (c *configHandler) saveContextValues() error { + if c.schemaValidator != nil && c.schemaValidator.Schema != nil { + if result, err := c.schemaValidator.Validate(c.contextValues); err != nil { + return fmt.Errorf("error validating values.yaml: %w", err) + } else if !result.Valid { + return fmt.Errorf("values.yaml validation failed: %v", result.Errors) + } + } + + configRoot, err := c.GetConfigRoot() + if err != nil { + return fmt.Errorf("error getting config root: %w", err) + } + + if err := c.shims.MkdirAll(configRoot, 0755); err != nil { + return fmt.Errorf("error creating context directory: %w", err) + } + + valuesPath := filepath.Join(configRoot, "values.yaml") + data, err := c.shims.YamlMarshal(c.contextValues) + if err != nil { + return fmt.Errorf("error marshalling values.yaml: %w", err) + } + + if err := c.shims.WriteFile(valuesPath, data, 0644); err != nil { + return fmt.Errorf("error writing values.yaml: %w", err) + } + + return nil +} + +// isKeyInStaticSchema determines whether the provided key exists as a top-level field +// in the static windsor.yaml schema, represented by v1alpha1.Context. It checks both +// direct keys and those nested with dot notation (e.g., "environment.TEST_VAR" -> "environment"). +// Returns true if the top-level key matches a YAML field tag in v1alpha1.Context, false otherwise. +func (c *configHandler) isKeyInStaticSchema(key string) bool { + topLevelKey := key + if dotIndex := strings.Index(key, "."); dotIndex != -1 { + topLevelKey = key[:dotIndex] + } + contextType := reflect.TypeOf(v1alpha1.Context{}) + for i := 0; i < contextType.NumField(); i++ { + field := contextType.Field(i) + yamlTag := strings.Split(field.Tag.Get("yaml"), ",")[0] + if yamlTag == topLevelKey { + return true + } + } + return false +} + +// convertStringValue infers and converts a string value to the appropriate type based on schema type information. +// It is used to correctly coerce command-line --set flags (which arrive as strings) to their target types. +// The function uses the configured schema validator, if present, to determine the expected type for the value. +// If type information cannot be found in the schema, it applies pattern-based type conversion heuristics. +// The returned value is properly typed if conversion is possible; otherwise, the original value is returned. +func (c *configHandler) convertStringValue(value any) any { + str, ok := value.(string) + if !ok { + return value + } + if c.schemaValidator != nil && c.schemaValidator.Schema != nil { + if expectedType := c.getExpectedTypeFromSchema(str); expectedType != "" { + if convertedValue := c.convertStringToType(str, expectedType); convertedValue != nil { + return convertedValue + } + } + } + return c.convertStringByPattern(str) +} + +// getExpectedTypeFromSchema attempts to find the expected type for a key in the schema +func (c *configHandler) getExpectedTypeFromSchema(key string) string { + if c.schemaValidator == nil || c.schemaValidator.Schema == nil { + return "" + } + + properties, ok := c.schemaValidator.Schema["properties"] + if !ok { + return "" + } + + propertiesMap, ok := properties.(map[string]any) + if !ok { + return "" + } + + propSchema, exists := propertiesMap[key] + if !exists { + return "" + } + + propSchemaMap, ok := propSchema.(map[string]any) + if !ok { + return "" + } + + expectedType, ok := propSchemaMap["type"] + if !ok { + return "" + } + + expectedTypeStr, ok := expectedType.(string) + if !ok { + return "" + } + + return expectedTypeStr +} + +// convertStringToType converts a string value to the corresponding Go type based on the provided JSON schema type. +// It supports boolean, integer, number, and string schema types. Returns the converted value, or nil if conversion fails. +// The conversion follows JSON schema type expectations: booleans are case-insensitive, integers use strconv.Atoi, +// numbers use strconv.ParseFloat (64-bit), and unrecognized types or conversion failures return nil. +func (c *configHandler) convertStringToType(str, expectedType string) any { + switch expectedType { + case "boolean": + switch strings.ToLower(str) { + case "true": + return true + case "false": + return false + } + case "integer": + if intVal, err := strconv.Atoi(str); err == nil { + return intVal + } + case "number": + if floatVal, err := strconv.ParseFloat(str, 64); err == nil { + return floatVal + } + case "string": + return str + } + return nil +} + +// convertStringByPattern attempts to infer and convert a string value to its most likely Go type. +// It recognizes "true"/"false" as booleans, parses numeric strings as integer or float as appropriate, +// and returns the original string if no conversion pattern matches. +func (c *configHandler) convertStringByPattern(str string) any { + switch strings.ToLower(str) { + case "true": + return true + case "false": + return false + } + + if intVal, err := strconv.Atoi(str); err == nil { + return intVal + } + + if floatVal, err := strconv.ParseFloat(str, 64); err == nil { + return floatVal + } + + return str +} + +// deepMerge recursively merges two maps with overlay values taking precedence. +// Nested maps are merged rather than replaced. Non-map values in overlay replace base values. +func (c *configHandler) deepMerge(base, overlay map[string]any) map[string]any { + result := make(map[string]any) + for k, v := range base { + result[k] = v + } + for k, overlayValue := range overlay { + if baseValue, exists := result[k]; exists { + if baseMap, baseIsMap := baseValue.(map[string]any); baseIsMap { + if overlayMap, overlayIsMap := overlayValue.(map[string]any); overlayIsMap { + result[k] = c.deepMerge(baseMap, overlayMap) + continue + } + } + } + result[k] = overlayValue + } + return result +} + +// getValueByPath retrieves a value by navigating through a struct or map using YAML tags. +func getValueByPath(current any, pathKeys []string) any { + if len(pathKeys) == 0 { + return nil + } + + currValue := reflect.ValueOf(current) + if !currValue.IsValid() { + return nil + } + + for _, key := range pathKeys { + for currValue.Kind() == reflect.Ptr && !currValue.IsNil() { + currValue = currValue.Elem() + } + if currValue.Kind() == reflect.Ptr && currValue.IsNil() { + return nil + } + + switch currValue.Kind() { + case reflect.Struct: + fieldValue := getFieldByYamlTag(currValue, key) + currValue = fieldValue + + case reflect.Map: + mapKey := reflect.ValueOf(key) + if !mapKey.Type().AssignableTo(currValue.Type().Key()) { + return nil + } + mapValue := currValue.MapIndex(mapKey) + if !mapValue.IsValid() { + return nil + } + currValue = mapValue + + default: + return nil + } + } + + if currValue.Kind() == reflect.Ptr { + if currValue.IsNil() { + return nil + } + currValue = currValue.Elem() + } + + if currValue.IsValid() && currValue.CanInterface() { + return currValue.Interface() + } + + return nil +} + +// getFieldByYamlTag retrieves a field from a struct by its YAML tag. +func getFieldByYamlTag(v reflect.Value, tag string) reflect.Value { + t := v.Type() + for i := range make([]struct{}, v.NumField()) { + field := t.Field(i) + yamlTag := strings.Split(field.Tag.Get("yaml"), ",")[0] + if yamlTag == tag { + return v.Field(i) + } + } + return reflect.Value{} +} + +// setValueByPath sets a value in a struct or map by navigating through it using YAML tags. +func setValueByPath(currValue reflect.Value, pathKeys []string, value any, fullPath string) error { + if len(pathKeys) == 0 { + return fmt.Errorf("pathKeys cannot be empty") + } + + key := pathKeys[0] + isLast := len(pathKeys) == 1 + + if currValue.Kind() == reflect.Ptr { + if currValue.IsNil() { + currValue.Set(reflect.New(currValue.Type().Elem())) + } + currValue = currValue.Elem() + } + + switch currValue.Kind() { + case reflect.Struct: + fieldValue := getFieldByYamlTag(currValue, key) + if !fieldValue.IsValid() { + return fmt.Errorf("field not found: %s", key) + } + + if fieldValue.Kind() == reflect.Ptr && fieldValue.IsNil() { + fieldValue.Set(reflect.New(fieldValue.Type().Elem())) + } + + if fieldValue.Kind() == reflect.Map && fieldValue.IsNil() { + fieldValue.Set(reflect.MakeMap(fieldValue.Type())) + } + + if isLast { + newFieldValue, err := assignValue(fieldValue, value) + if err != nil { + return err + } + fieldValue.Set(newFieldValue) + } else { + err := setValueByPath(fieldValue, pathKeys[1:], value, fullPath) + if err != nil { + return err + } + } + + case reflect.Map: + if currValue.IsNil() { + currValue.Set(reflect.MakeMap(currValue.Type())) + } + + mapKey := reflect.ValueOf(key) + if !mapKey.Type().AssignableTo(currValue.Type().Key()) { + return fmt.Errorf("key type mismatch: expected %s, got %s", currValue.Type().Key(), mapKey.Type()) + } + + var nextValue reflect.Value + + if isLast { + val := reflect.ValueOf(value) + if !val.Type().AssignableTo(currValue.Type().Elem()) { + if val.Type().ConvertibleTo(currValue.Type().Elem()) { + val = val.Convert(currValue.Type().Elem()) + } else { + return fmt.Errorf("value type mismatch for key %s: expected %s, got %s", key, currValue.Type().Elem(), val.Type()) + } + } + currValue.SetMapIndex(mapKey, val) + } else { + nextValue = currValue.MapIndex(mapKey) + if !nextValue.IsValid() { + nextValue = reflect.New(currValue.Type().Elem()).Elem() + } else { + nextValue = makeAddressable(nextValue) + } + + err := setValueByPath(nextValue, pathKeys[1:], value, fullPath) + if err != nil { + return err + } + + currValue.SetMapIndex(mapKey, nextValue) + } + + default: + return fmt.Errorf("Invalid path: %s", fullPath) + } + + return nil +} + +// assignValue assigns a value to a struct field, performing type conversion if necessary. +// It supports string-to-type conversion, pointer assignment, and type compatibility checks. +// Returns a reflect.Value suitable for assignment or an error if conversion is not possible. +func assignValue(fieldValue reflect.Value, value any) (reflect.Value, error) { + if !fieldValue.CanSet() { + return reflect.Value{}, fmt.Errorf("cannot set field") + } + + fieldType := fieldValue.Type() + valueType := reflect.TypeOf(value) + + if strValue, ok := value.(string); ok { + convertedValue, err := convertValue(strValue, fieldType) + if err == nil { + return reflect.ValueOf(convertedValue), nil + } + } + + if fieldType.Kind() == reflect.Ptr { + elemType := fieldType.Elem() + newValue := reflect.New(elemType) + val := reflect.ValueOf(value) + + if valueType.AssignableTo(fieldType) { + return val, nil + } + + if val.Type().ConvertibleTo(elemType) { + val = val.Convert(elemType) + newValue.Elem().Set(val) + return newValue, nil + } + + return reflect.Value{}, fmt.Errorf("cannot assign value of type %s to field of type %s", valueType, fieldType) + } + + val := reflect.ValueOf(value) + if valueType.AssignableTo(fieldType) { + return val, nil + } + + if valueType.ConvertibleTo(fieldType) { + return val.Convert(fieldType), nil + } + + return reflect.Value{}, fmt.Errorf("cannot assign value of type %s to field of type %s", valueType, fieldType) +} + +// makeAddressable ensures a value is addressable by creating a new pointer if necessary. +func makeAddressable(v reflect.Value) reflect.Value { + if !v.IsValid() { + return v + } + if v.CanAddr() { + return v + } + addr := reflect.New(v.Type()) + addr.Elem().Set(v) + return addr.Elem() +} + +// parsePath parses a path string into a slice of keys, supporting both dot and bracket notation. +func parsePath(path string) []string { + var keys []string + var currentKey strings.Builder + inBracket := false + + for _, char := range path { + switch char { + case '.': + if !inBracket { + if currentKey.Len() > 0 { + keys = append(keys, currentKey.String()) + currentKey.Reset() + } + } else { + currentKey.WriteRune(char) + } + case '[': + inBracket = true + if currentKey.Len() > 0 { + keys = append(keys, currentKey.String()) + currentKey.Reset() + } + case ']': + inBracket = false + default: + currentKey.WriteRune(char) + } + } + + if currentKey.Len() > 0 { + keys = append(keys, currentKey.String()) + } + + return keys +} + +// convertValue attempts to convert a string value to the appropriate type based on the target field's type +func convertValue(value string, targetType reflect.Type) (any, error) { + isPointer := targetType.Kind() == reflect.Ptr + if isPointer { + targetType = targetType.Elem() + } + + var convertedValue any + var err error + + switch targetType.Kind() { + case reflect.Bool: + convertedValue, err = strconv.ParseBool(value) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + var v int64 + v, err = strconv.ParseInt(value, 10, 64) + if err == nil { + switch targetType.Kind() { + case reflect.Int: + if v < math.MinInt || v > math.MaxInt { + return nil, fmt.Errorf("integer overflow: %d is outside the range of int", v) + } + convertedValue = int(v) + case reflect.Int8: + if v < math.MinInt8 || v > math.MaxInt8 { + return nil, fmt.Errorf("integer overflow: %d is outside the range of int8", v) + } + convertedValue = int8(v) + case reflect.Int16: + if v < math.MinInt16 || v > math.MaxInt16 { + return nil, fmt.Errorf("integer overflow: %d is outside the range of int16", v) + } + convertedValue = int16(v) + case reflect.Int32: + if v < math.MinInt32 || v > math.MaxInt32 { + return nil, fmt.Errorf("integer overflow: %d is outside the range of int32", v) + } + convertedValue = int32(v) + case reflect.Int64: + convertedValue = v + } + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + var v uint64 + v, err = strconv.ParseUint(value, 10, 64) + if err == nil { + switch targetType.Kind() { + case reflect.Uint: + if v > math.MaxUint { + return nil, fmt.Errorf("integer overflow: %d is outside the range of uint", v) + } + convertedValue = uint(v) + case reflect.Uint8: + if v > math.MaxUint8 { + return nil, fmt.Errorf("integer overflow: %d is outside the range of uint8", v) + } + convertedValue = uint8(v) + case reflect.Uint16: + if v > math.MaxUint16 { + return nil, fmt.Errorf("integer overflow: %d is outside the range of uint16", v) + } + convertedValue = uint16(v) + case reflect.Uint32: + if v > math.MaxUint32 { + return nil, fmt.Errorf("integer overflow: %d is outside the range of uint32", v) + } + convertedValue = uint32(v) + case reflect.Uint64: + convertedValue = v + } + } + case reflect.Float32, reflect.Float64: + var v float64 + v, err = strconv.ParseFloat(value, 64) + if err == nil { + if targetType.Kind() == reflect.Float32 { + if v < -math.MaxFloat32 || v > math.MaxFloat32 { + return nil, fmt.Errorf("float overflow: %f is outside the range of float32", v) + } + convertedValue = float32(v) + } else { + convertedValue = v + } + } + case reflect.String: + convertedValue = value + default: + return nil, fmt.Errorf("unsupported type conversion from string to %v", targetType) + } + + if err != nil { + return nil, err + } + + if isPointer { + ptr := reflect.New(targetType) + ptr.Elem().Set(reflect.ValueOf(convertedValue)) + return ptr.Interface(), nil + } + + return convertedValue, nil } diff --git a/pkg/config/yaml_config_handler_test.go b/pkg/config/config_handler_private_test.go similarity index 58% rename from pkg/config/yaml_config_handler_test.go rename to pkg/config/config_handler_private_test.go index 3a8b4a5a3..d5047b83e 100644 --- a/pkg/config/yaml_config_handler_test.go +++ b/pkg/config/config_handler_private_test.go @@ -9,1583 +9,1403 @@ import ( "testing" "github.com/windsorcli/cli/api/v1alpha1" - "github.com/windsorcli/cli/api/v1alpha1/aws" "github.com/windsorcli/cli/api/v1alpha1/cluster" "github.com/windsorcli/cli/api/v1alpha1/vm" "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/secrets" "github.com/windsorcli/cli/pkg/shell" ) -// ============================================================================= -// Helper Functions -// ============================================================================= +// TestGetValueByPath tests the getValueByPath function +func Test_getValueByPath(t *testing.T) { + t.Run("EmptyPathKeys", func(t *testing.T) { + // Given an empty pathKeys slice for value lookup + var current any + pathKeys := []string{} -// stringPtr returns a pointer to the provided string -func stringPtr(s string) *string { - return &s -} + // When calling getValueByPath with empty pathKeys + value := getValueByPath(current, pathKeys) -// ============================================================================= -// Constructor -// ============================================================================= + // Then nil should be returned as the path is invalid + if value != nil { + t.Errorf("Expected value to be nil, got %v", value) + } + }) -func TestNewYamlConfigHandler(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims + t.Run("InvalidCurrentValue", func(t *testing.T) { + // Given a nil current value and a valid path key + var current any = nil + pathKeys := []string{"key"} - return handler, mocks - } - t.Run("Success", func(t *testing.T) { - handler, _ := setup(t) + // When calling getValueByPath with nil current value + value := getValueByPath(current, pathKeys) - // Then the handler should be successfully created and not be nil - if handler == nil { - t.Fatal("Expected non-nil YamlConfigHandler") + // Then nil should be returned as the current value is invalid + if value != nil { + t.Errorf("Expected value to be nil, got %v", value) } }) -} -// ============================================================================= -// Public Methods -// ============================================================================= + t.Run("MapKeyTypeMismatch", func(t *testing.T) { + // Given a map with int keys but attempting to access with a string key + current := map[int]string{1: "one", 2: "two"} + pathKeys := []string{"1"} -func TestYamlConfigHandler_LoadConfig(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - return handler, mocks - } + // When calling getValueByPath with mismatched key type + value := getValueByPath(current, pathKeys) - t.Run("Success", func(t *testing.T) { - // Given a set of safe mocks and a YamlConfigHandler - handler, _ := setup(t) + // Then nil should be returned due to key type mismatch + if value != nil { + t.Errorf("Expected value to be nil, got %v", value) + } + }) - // And a valid config path - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yaml") + t.Run("MapSuccess", func(t *testing.T) { + // Given a map with a string key and corresponding value + current := map[string]string{"key": "testValue"} + pathKeys := []string{"key"} - // When LoadConfig is called with the valid path - err := handler.LoadConfig(configPath) + // When calling getValueByPath with a valid key + value := getValueByPath(current, pathKeys) - // Then no error should be returned - if err != nil { - t.Fatalf("LoadConfig() unexpected error: %v", err) + // Then the corresponding value should be returned successfully + if value == nil { + t.Errorf("Expected value to be 'testValue', got nil") } - - // And the path should be set correctly - if handler.path != configPath { - t.Errorf("Expected path = %v, got = %v", configPath, handler.path) + expectedValue := "testValue" + if value != expectedValue { + t.Errorf("Expected value '%s', got '%v'", expectedValue, value) } }) - t.Run("CreateEmptyConfigFileIfNotExist", func(t *testing.T) { - // Given a set of safe mocks and a YamlConfigHandler - handler, _ := setup(t) - - // And a mocked osStat that returns ErrNotExist - handler.shims.Stat = func(_ string) (os.FileInfo, error) { - return nil, os.ErrNotExist + t.Run("CannotSetField", func(t *testing.T) { + // Given a struct with an unexported field that cannot be set + type TestStruct struct { + unexportedField string `yaml:"unexportedfield"` } + testStruct := &TestStruct{} + currValue := reflect.ValueOf(testStruct) + pathKeys := []string{"unexportedfield"} + value := "testValue" + fullPath := "unexportedfield" - // When LoadConfig is called with a non-existent path - err := handler.LoadConfig("test_config.yaml") + // When attempting to set a value on the unexported field + err := setValueByPath(currValue, pathKeys, value, fullPath) - // Then no error should be returned - if err != nil { - t.Fatalf("LoadConfig() unexpected error: %v", err) + // Then an error should be returned indicating the field cannot be set + expectedErr := "cannot set field" + if err == nil || err.Error() != expectedErr { + t.Errorf("Expected error '%s', got '%v'", expectedErr, err) } }) - t.Run("ReadFileError", func(t *testing.T) { - // Given a set of safe mocks and a YamlConfigHandler - handler, _ := setup(t) - - // And a mocked osReadFile that returns an error - handler.shims.ReadFile = func(filename string) ([]byte, error) { - return nil, fmt.Errorf("mocked error reading file") - } + t.Run("RecursiveFailure", func(t *testing.T) { + // Given a nested map structure without the target field + level3Map := map[string]any{} + level2Map := map[string]any{"level3": level3Map} + level1Map := map[string]any{"level2": level2Map} + testMap := map[string]any{"level1": level1Map} + currValue := reflect.ValueOf(testMap) + pathKeys := []string{"level1", "level2", "nonexistentfield"} + value := "newValue" + fullPath := "level1.level2.nonexistentfield" - // When LoadConfig is called - err := handler.LoadConfig("mocked_config.yaml") + // When attempting to set a value at a non-existent nested path + err := setValueByPath(currValue, pathKeys, value, fullPath) - // Then an error should be returned - if err == nil { - t.Fatalf("LoadConfig() expected error, got nil") + // Then an error should be returned indicating the invalid path + expectedErr := "Invalid path: level1.level2.nonexistentfield" + if err == nil || err.Error() != expectedErr { + t.Errorf("Expected error '%s', got '%v'", expectedErr, err) } + }) - // And the error message should be as expected - expectedError := "error reading config file: mocked error reading file" - if err.Error() != expectedError { - t.Errorf("LoadConfig() error = %v, expected '%s'", err, expectedError) + t.Run("AssignValueTypeMismatch", func(t *testing.T) { + // Given a struct with an int field that cannot accept a string slice + type TestStruct struct { + IntField int `yaml:"intfield"` } - }) + testStruct := &TestStruct{} + currValue := reflect.ValueOf(testStruct) + pathKeys := []string{"intfield"} + value := []string{"incompatibleType"} // A slice, which is incompatible with int + fullPath := "intfield" - t.Run("UnmarshalError", func(t *testing.T) { - // Given a set of safe mocks and a YamlConfigHandler - handler, _ := setup(t) + // When attempting to assign an incompatible value type + err := setValueByPath(currValue, pathKeys, value, fullPath) - // And a mocked yamlUnmarshal that returns an error - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - return fmt.Errorf("mocked error unmarshalling yaml") + // Then an error should be returned indicating the type mismatch + expectedErr := "cannot assign value of type []string to field of type int" + if err == nil || err.Error() != expectedErr { + t.Errorf("Expected error '%s', got '%v'", expectedErr, err) } + }) - // When LoadConfig is called - err := handler.LoadConfig("mocked_path.yaml") - - // Then an error should be returned - if err == nil { - t.Fatalf("LoadConfig() expected error, got nil") + t.Run("AssignPointerValueTypeMismatch", func(t *testing.T) { + // Given a struct with a pointer field that cannot accept a string slice + type TestStruct struct { + IntPtrField *int `yaml:"intptrfield"` } + testStruct := &TestStruct{} + currValue := reflect.ValueOf(testStruct) + pathKeys := []string{"intptrfield"} + value := []string{"incompatibleType"} // A slice, which is incompatible with *int + fullPath := "intptrfield" - // And the error message should be as expected - expectedError := "error unmarshalling yaml: mocked error unmarshalling yaml" - if err.Error() != expectedError { - t.Errorf("LoadConfig() error = %v, expected '%s'", err, expectedError) + // When attempting to assign an incompatible value type to a pointer field + err := setValueByPath(currValue, pathKeys, value, fullPath) + + // Then an error should be returned indicating the pointer type mismatch + expectedErr := "cannot assign value of type []string to field of type *int" + if err == nil || err.Error() != expectedErr { + t.Errorf("Expected error '%s', got '%v'", expectedErr, err) } }) - t.Run("UnsupportedConfigVersion", func(t *testing.T) { - // Given a set of safe mocks and a YamlConfigHandler - handler, _ := setup(t) - - // And a mocked yamlUnmarshal that sets an unsupported version - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - if config, ok := v.(*v1alpha1.Config); ok { - config.Version = "unsupported_version" - } - return nil + t.Run("AssignNonPointerField", func(t *testing.T) { + // Given a struct with a string field that can be directly assigned + type TestStruct struct { + StringField string `yaml:"stringfield"` } + testStruct := &TestStruct{} + currValue := reflect.ValueOf(testStruct) + pathKeys := []string{"stringfield"} + value := "testValue" // Directly assignable to string + fullPath := "stringfield" - // When LoadConfig is called - err := handler.LoadConfig("mocked_path.yaml") + // When assigning a compatible value to the field + err := setValueByPath(currValue, pathKeys, value, fullPath) - // Then an error should be returned - if err == nil { - t.Fatalf("LoadConfig() expected error, got nil") + // Then the field should be set without error + if err != nil { + t.Fatalf("Unexpected error: %v", err) } - - // And the error message should be as expected - expectedError := "unsupported config version: unsupported_version" - if err.Error() != expectedError { - t.Errorf("LoadConfig() error = %v, expected '%s'", err, expectedError) + if testStruct.StringField != "testValue" { + t.Errorf("Expected StringField to be 'testValue', got '%v'", testStruct.StringField) } }) -} -func TestYamlConfigHandler_Get(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize handler: %v", err) + t.Run("AssignConvertibleType", func(t *testing.T) { + // Given a struct with an int field that can accept a convertible float value + type TestStruct struct { + IntField int `yaml:"intfield"` } - return handler, mocks - } - - t.Run("KeyNotUnderContexts", func(t *testing.T) { - // Given a set of safe mocks and a YamlConfigHandler - handler, mocks := setup(t) + testStruct := &TestStruct{} + currValue := reflect.ValueOf(testStruct) + pathKeys := []string{"intfield"} + value := 42.0 // A float64, which is convertible to int + fullPath := "intfield" - // And a mocked shell that returns a project root - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } + // When assigning a value that can be converted to the field's type + err := setValueByPath(currValue, pathKeys, value, fullPath) - // And a mocked shims that handles context file - handler.shims.ReadFile = func(filename string) ([]byte, error) { - if filename == "/mock/project/root/.windsor/context" { - return []byte("local"), nil - } - return nil, fmt.Errorf("file not found") + // Then the field should be set without error + if err != nil { + t.Fatalf("Unexpected error: %v", err) } - - // And a config with proper initialization - handler.config = v1alpha1.Config{ - Version: "v1alpha1", - Contexts: map[string]*v1alpha1.Context{ - "local": { - Environment: map[string]string{}, - }, - }, + if testStruct.IntField != 42 { + t.Errorf("Expected IntField to be 42, got '%v'", testStruct.IntField) } + }) +} - // And the context is set - handler.context = "local" +func Test_parsePath(t *testing.T) { + t.Run("EmptyPath", func(t *testing.T) { + // Given an empty path string to parse + path := "" - // When getting a key not under contexts - val := handler.Get("nonContextKey") + // When calling parsePath with the empty string + pathKeys := parsePath(path) - // Then nil should be returned - if val != nil { - t.Errorf("Expected nil for non-context key, got %v", val) + // Then an empty slice should be returned + if len(pathKeys) != 0 { + t.Errorf("Expected pathKeys to be empty, got %v", pathKeys) } }) - t.Run("InvalidPath", func(t *testing.T) { - // Given a set of safe mocks and a YamlConfigHandler - handler, _ := setup(t) + t.Run("SingleKey", func(t *testing.T) { + // Given a path with a single key + path := "key" - // When calling Get with an empty path - value := handler.Get("") + // When calling parsePath with a single key + pathKeys := parsePath(path) - // Then nil should be returned - if value != nil { - t.Errorf("Expected nil for empty path, got %v", value) + // Then a slice with only that key should be returned + expected := []string{"key"} + if !reflect.DeepEqual(pathKeys, expected) { + t.Errorf("Expected pathKeys to be %v, got %v", expected, pathKeys) } }) - t.Run("PrecedenceAndSchemaDefaults", func(t *testing.T) { - // Given a handler with schema validator and various data sources - handler, _ := setup(t) - handler.context = "test" - handler.loaded = true + t.Run("MultipleKeys", func(t *testing.T) { + // Given a path with multiple keys separated by dots + path := "key1.key2.key3" - // Set up schema validator with defaults - handler.schemaValidator = &SchemaValidator{ - Schema: map[string]any{ - "properties": map[string]any{ - "SCHEMA_KEY": map[string]any{ - "default": "schema_default_value", - }, - }, - }, - } + // When calling parsePath with dot notation + pathKeys := parsePath(path) - // Test schema defaults for single-key path - value := handler.Get("SCHEMA_KEY") - expected := "schema_default_value" - if value != expected { - t.Errorf("Expected schema default value '%s', got '%v'", expected, value) + // Then a slice containing all the keys should be returned + expected := []string{"key1", "key2", "key3"} + if !reflect.DeepEqual(pathKeys, expected) { + t.Errorf("Expected pathKeys to be %v, got %v", expected, pathKeys) } + }) - // Test that multi-key paths don't use schema defaults - value = handler.Get("contexts.test.SCHEMA_KEY") - if value != nil { - t.Errorf("Expected nil for multi-key path, got '%v'", value) - } + t.Run("KeysWithBrackets", func(t *testing.T) { + // Given a path with keys using bracket notation + path := "key1[key2][key3]" - // Test contextValues precedence - handler.contextValues = map[string]any{ - "TEST_VAR": "values_value", - } - value = handler.Get("contexts.test.TEST_VAR") - expected = "values_value" - if value != expected { - t.Errorf("Expected contextValues value '%s', got '%v'", expected, value) - } + // When calling parsePath with bracket notation + pathKeys := parsePath(path) - // Test that contextValues are not checked when not loaded - handler.loaded = false - value = handler.Get("contexts.test.TEST_VAR") - if value != nil { - t.Errorf("Expected nil when not loaded, got '%v'", value) + // Then a slice containing all the keys without brackets should be returned + expected := []string{"key1", "key2", "key3"} + if !reflect.DeepEqual(pathKeys, expected) { + t.Errorf("Expected pathKeys to be %v, got %v", expected, pathKeys) } }) -} -func TestYamlConfigHandler_SaveConfig(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - return handler, mocks - } + t.Run("MixedDotAndBracketNotation", func(t *testing.T) { + // Given a path with mixed dot and bracket notation + path := "key1.key2[key3].key4[key5]" - t.Run("Success", func(t *testing.T) { - // Given a YamlConfigHandler with a mocked shell - handler, mocks := setup(t) + // When calling parsePath with mixed notation + pathKeys := parsePath(path) - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + // Then a slice with all keys regardless of notation should be returned + expected := []string{"key1", "key2", "key3", "key4", "key5"} + if !reflect.DeepEqual(pathKeys, expected) { + t.Errorf("Expected pathKeys to be %v, got %v", expected, pathKeys) } + }) - // And a context is set - handler.context = "test-context" - - // And some configuration data - handler.Set("contexts.test-context.provider", "local") - - // When SaveConfig is called - err := handler.SaveConfig() - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got %v", err) - } + t.Run("DotInsideBrackets", func(t *testing.T) { + // Given a path with a dot inside bracket notation + path := "key1[key2.key3]" - // And the root windsor.yaml should exist with only version - rootConfigPath := filepath.Join(tempDir, "windsor.yaml") - if _, err := handler.shims.Stat(rootConfigPath); os.IsNotExist(err) { - t.Fatalf("Root config file was not created at %s", rootConfigPath) - } + // When calling parsePath with a dot inside brackets + pathKeys := parsePath(path) - // And the context config should exist - contextConfigPath := filepath.Join(tempDir, "contexts", "test-context", "windsor.yaml") - if _, err := handler.shims.Stat(contextConfigPath); os.IsNotExist(err) { - t.Fatalf("Context config file was not created at %s", contextConfigPath) + // Then the dot inside brackets should be treated as part of the key + expected := []string{"key1", "key2.key3"} + if !reflect.DeepEqual(pathKeys, expected) { + t.Errorf("Expected pathKeys to be %v, got %v", expected, pathKeys) } }) +} - t.Run("WithOverwriteFalse", func(t *testing.T) { - // Given a YamlConfigHandler with existing config files - handler, mocks := setup(t) - - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil +func Test_assignValue(t *testing.T) { + t.Run("CannotSetField", func(t *testing.T) { + // Given an unexported field that cannot be set + var unexportedField struct { + unexported int } + fieldValue := reflect.ValueOf(&unexportedField).Elem().Field(0) - handler.context = "test-context" - - // Create existing files - rootConfigPath := filepath.Join(tempDir, "windsor.yaml") - os.WriteFile(rootConfigPath, []byte("existing content"), 0644) - - contextDir := filepath.Join(tempDir, "contexts", "test-context") - os.MkdirAll(contextDir, 0755) - contextConfigPath := filepath.Join(contextDir, "windsor.yaml") - os.WriteFile(contextConfigPath, []byte("existing context content"), 0644) - - // When SaveConfig is called with overwrite false - err := handler.SaveConfig(false) - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got %v", err) - } + // When attempting to assign a value to it + _, err := assignValue(fieldValue, 10) - // And the files should still contain the original content - rootContent, _ := os.ReadFile(rootConfigPath) - if string(rootContent) != "existing content" { - t.Errorf("Root config file was overwritten when it shouldn't have been") + // Then an error should be returned + if err == nil { + t.Errorf("Expected an error for non-settable field, got nil") } - - contextContent, _ := os.ReadFile(contextConfigPath) - if string(contextContent) != "existing context content" { - t.Errorf("Context config file was overwritten when it shouldn't have been") + expectedError := "cannot set field" + if err.Error() != expectedError { + t.Errorf("Expected error %q, got %q", expectedError, err.Error()) } }) - t.Run("ShellNotInitialized", func(t *testing.T) { - // Given a YamlConfigHandler without initialized shell - handler, _ := setup(t) - handler.shell = nil + t.Run("PointerTypeMismatchNonConvertible", func(t *testing.T) { + // Given a pointer field of type *int + var field *int + fieldValue := reflect.ValueOf(&field).Elem() - // When SaveConfig is called - err := handler.SaveConfig() + // When attempting to assign a string value to it + value := "not an int" + _, err := assignValue(fieldValue, value) - // Then an error should be returned + // Then an error should be returned indicating type mismatch if err == nil { - t.Fatal("Expected error, got nil") + t.Errorf("Expected an error for pointer type mismatch, got nil") } - if err.Error() != "shell not initialized" { - t.Errorf("Expected 'shell not initialized' error, got %v", err) + expectedError := "cannot assign value of type string to field of type *int" + if err.Error() != expectedError { + t.Errorf("Expected error %q, got %q", expectedError, err.Error()) } }) - t.Run("GetProjectRootError", func(t *testing.T) { - // Given a YamlConfigHandler with shell that fails to get project root - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "", fmt.Errorf("project root failed") - } + t.Run("ValueTypeMismatchNonConvertible", func(t *testing.T) { + // Given a field of type int + var field int + fieldValue := reflect.ValueOf(&field).Elem() - // When SaveConfig is called - err := handler.SaveConfig() + // When attempting to assign a non-convertible string value to it + value := "not convertible to int" + _, err := assignValue(fieldValue, value) - // Then an error should be returned + // Then an error should be returned indicating type mismatch if err == nil { - t.Fatal("Expected error, got nil") + t.Errorf("Expected an error for non-convertible type mismatch, got nil") } - if !strings.Contains(err.Error(), "error retrieving project root") { - t.Errorf("Expected 'error retrieving project root' in error, got %v", err) + expectedError := "cannot assign value of type string to field of type int" + if err.Error() != expectedError { + t.Errorf("Expected error %q, got %q", expectedError, err.Error()) } }) +} - t.Run("RootConfigExists_SkipsRootCreation", func(t *testing.T) { - // Given a YamlConfigHandler with existing root config - handler, mocks := setup(t) - - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil - } - - handler.context = "test-context" - - // Create existing root config - rootConfigPath := filepath.Join(tempDir, "windsor.yaml") - originalContent := "version: v1alpha1\nexisting: config" - os.WriteFile(rootConfigPath, []byte(originalContent), 0644) +func Test_convertValue(t *testing.T) { + t.Run("ConvertStringToBool", func(t *testing.T) { + // Given a string value that can be converted to bool + value := "true" + targetType := reflect.TypeOf(true) - // When SaveConfig is called - err := handler.SaveConfig() + // When converting the value + result, err := convertValue(value, targetType) // Then no error should be returned if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Fatalf("Unexpected error: %v", err) } - // And the root config should not be overwritten - content, _ := os.ReadFile(rootConfigPath) - if string(content) != originalContent { - t.Errorf("Root config was overwritten when it should be preserved") + // And the result should be a bool + if result != true { + t.Errorf("Expected true, got %v", result) } }) - t.Run("ContextExistsInRoot_SkipsContextCreation", func(t *testing.T) { - // Given a YamlConfigHandler with context existing in root config - handler, mocks := setup(t) + t.Run("ConvertStringToInt", func(t *testing.T) { + // Given a string value that can be converted to int + value := "42" + targetType := reflect.TypeOf(int(0)) - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil - } + // When converting the value + result, err := convertValue(value, targetType) - handler.context = "existing-context" + // Then no error should be returned + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } - // Setup config with existing context in root - handler.config.Contexts = map[string]*v1alpha1.Context{ - "existing-context": { - Provider: stringPtr("local"), - }, + // And the result should be an int + if result != 42 { + t.Errorf("Expected 42, got %v", result) } + }) - // Create existing root config file - rootConfigPath := filepath.Join(tempDir, "windsor.yaml") - os.WriteFile(rootConfigPath, []byte("version: v1alpha1"), 0644) + t.Run("ConvertStringToFloat", func(t *testing.T) { + // Given a string value that can be converted to float + value := "3.14" + targetType := reflect.TypeOf(float64(0)) - // When SaveConfig is called - err := handler.SaveConfig() + // When converting the value + result, err := convertValue(value, targetType) // Then no error should be returned if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Fatalf("Unexpected error: %v", err) } - // And the context config should not be created - contextConfigPath := filepath.Join(tempDir, "contexts", "existing-context", "windsor.yaml") - if _, err := os.Stat(contextConfigPath); !os.IsNotExist(err) { - t.Errorf("Context config was created when it shouldn't have been") + // And the result should be a float + if result != 3.14 { + t.Errorf("Expected 3.14, got %v", result) } }) - t.Run("ContextConfigExists_SkipsContextCreation", func(t *testing.T) { - // Given a YamlConfigHandler with existing context config file - handler, mocks := setup(t) - - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil - } - - handler.context = "test-context" - - // Create existing context config - contextDir := filepath.Join(tempDir, "contexts", "test-context") - os.MkdirAll(contextDir, 0755) - contextConfigPath := filepath.Join(contextDir, "windsor.yaml") - originalContent := "provider: local\nexisting: config" - os.WriteFile(contextConfigPath, []byte(originalContent), 0644) + t.Run("ConvertStringToPointer", func(t *testing.T) { + // Given a string value that can be converted to a pointer type + value := "42" + targetType := reflect.TypeOf((*int)(nil)) - // When SaveConfig is called - err := handler.SaveConfig() + // When converting the value + result, err := convertValue(value, targetType) // Then no error should be returned if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Fatalf("Unexpected error: %v", err) } - // And the context config should not be overwritten - content, _ := os.ReadFile(contextConfigPath) - if string(content) != originalContent { - t.Errorf("Context config was overwritten when it should be preserved") + // And the result should be a pointer to int + if ptr, ok := result.(*int); !ok || *ptr != 42 { + t.Errorf("Expected *int(42), got %v", result) } }) - t.Run("RootConfigMarshalError", func(t *testing.T) { - // Given a YamlConfigHandler with marshal error for root config - handler, mocks := setup(t) - - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil - } + t.Run("UnsupportedType", func(t *testing.T) { + // Given a string value and an unsupported target type + value := "test" + targetType := reflect.TypeOf([]string{}) - handler.context = "test-context" + // When converting the value + _, err := convertValue(value, targetType) - // Override Stat to return not exists (so files will be created) - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + // Then an error should be returned + if err == nil { + t.Fatal("Expected error for unsupported type") } - // Mock YamlMarshal to return error - handler.shims.YamlMarshal = func(v interface{}) ([]byte, error) { - return nil, fmt.Errorf("marshal error") + // And the error message should indicate the unsupported type + expectedErr := "unsupported type conversion from string to []string" + if err.Error() != expectedErr { + t.Errorf("Expected error '%s', got '%s'", expectedErr, err.Error()) } + }) - // When SaveConfig is called - err := handler.SaveConfig() + t.Run("InvalidNumericValue", func(t *testing.T) { + // Given an invalid numeric string value + value := "not a number" + targetType := reflect.TypeOf(int(0)) + + // When converting the value + _, err := convertValue(value, targetType) // Then an error should be returned if err == nil { - t.Fatal("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error marshalling root config") { - t.Errorf("Expected 'error marshalling root config' in error, got %v", err) + t.Fatal("Expected error for invalid numeric value") } }) - t.Run("RootConfigWriteError", func(t *testing.T) { - // Given a YamlConfigHandler with write error for root config - handler, mocks := setup(t) - - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + t.Run("UintTypes", func(t *testing.T) { + // Given a string value and uint target types + value := "42" + targetTypes := []reflect.Type{ + reflect.TypeOf(uint(0)), + reflect.TypeOf(uint8(0)), + reflect.TypeOf(uint16(0)), + reflect.TypeOf(uint32(0)), + reflect.TypeOf(uint64(0)), } - handler.context = "test-context" + // When converting the value to each type + for _, targetType := range targetTypes { + result, err := convertValue(value, targetType) - // Override Stat to return not exists (so files will be created) - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } + // Then no error should be returned + if err != nil { + t.Fatalf("convertValue() unexpected error for %v: %v", targetType, err) + } - // Mock WriteFile to return error - handler.shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { - return fmt.Errorf("write error") - } - - // When SaveConfig is called - err := handler.SaveConfig() - - // Then an error should be returned - if err == nil { - t.Fatal("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error writing root config") { - t.Errorf("Expected 'error writing root config' in error, got %v", err) + // And the value should be correctly converted + switch targetType.Kind() { + case reflect.Uint: + if result != uint(42) { + t.Errorf("convertValue() = %v, want %v for %v", result, uint(42), targetType) + } + case reflect.Uint8: + if result != uint8(42) { + t.Errorf("convertValue() = %v, want %v for %v", result, uint8(42), targetType) + } + case reflect.Uint16: + if result != uint16(42) { + t.Errorf("convertValue() = %v, want %v for %v", result, uint16(42), targetType) + } + case reflect.Uint32: + if result != uint32(42) { + t.Errorf("convertValue() = %v, want %v for %v", result, uint32(42), targetType) + } + case reflect.Uint64: + if result != uint64(42) { + t.Errorf("convertValue() = %v, want %v for %v", result, uint64(42), targetType) + } + } } }) - t.Run("ContextDirectoryCreationError", func(t *testing.T) { - // Given a YamlConfigHandler with directory creation error - handler, mocks := setup(t) - - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil - } - - handler.context = "test-context" - - // Override Stat to return not exists (so files will be created) - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + t.Run("IntTypes", func(t *testing.T) { + // Given a string value and int target types + value := "42" + targetTypes := []reflect.Type{ + reflect.TypeOf(int8(0)), + reflect.TypeOf(int16(0)), + reflect.TypeOf(int32(0)), + reflect.TypeOf(int64(0)), } - // Mock MkdirAll to return error - handler.shims.MkdirAll = func(path string, perm os.FileMode) error { - return fmt.Errorf("mkdir error") - } + // When converting the value to each type + for _, targetType := range targetTypes { + result, err := convertValue(value, targetType) - // When SaveConfig is called - err := handler.SaveConfig() + // Then no error should be returned + if err != nil { + t.Fatalf("convertValue() unexpected error for %v: %v", targetType, err) + } - // Then an error should be returned - if err == nil { - t.Fatal("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error creating context directory") { - t.Errorf("Expected 'error creating context directory' in error, got %v", err) + // And the value should be correctly converted + switch targetType.Kind() { + case reflect.Int8: + if result != int8(42) { + t.Errorf("convertValue() = %v, want %v for %v", result, int8(42), targetType) + } + case reflect.Int16: + if result != int16(42) { + t.Errorf("convertValue() = %v, want %v for %v", result, int16(42), targetType) + } + case reflect.Int32: + if result != int32(42) { + t.Errorf("convertValue() = %v, want %v for %v", result, int32(42), targetType) + } + case reflect.Int64: + if result != int64(42) { + t.Errorf("convertValue() = %v, want %v for %v", result, int64(42), targetType) + } + } } }) - t.Run("ContextConfigMarshalError", func(t *testing.T) { - // Given a YamlConfigHandler with marshal error for context config - handler, mocks := setup(t) - - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil - } + t.Run("Float32", func(t *testing.T) { + // Given a string value and float32 target type + value := "3.14" + targetType := reflect.TypeOf(float32(0)) - handler.context = "test-context" + // When converting the value + result, err := convertValue(value, targetType) - // Override Stat to return not exists (so files will be created) - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + // Then no error should be returned + if err != nil { + t.Fatalf("convertValue() unexpected error: %v", err) } - // Track marshal calls to return error on second call (context config) - marshalCallCount := 0 - handler.shims.YamlMarshal = func(v interface{}) ([]byte, error) { - marshalCallCount++ - if marshalCallCount == 2 { - return nil, fmt.Errorf("context marshal error") - } - return []byte("version: v1alpha1"), nil + // And the value should be correctly converted + if result != float32(3.14) { + t.Errorf("convertValue() = %v, want %v", result, float32(3.14)) } + }) - // When SaveConfig is called - err := handler.SaveConfig() + t.Run("StringToFloatOverflow", func(t *testing.T) { + // Given a string value that would overflow float32 + value := "3.4028236e+38" + targetType := reflect.TypeOf(float32(0)) + + // When converting the value + _, err := convertValue(value, targetType) // Then an error should be returned if err == nil { - t.Fatal("Expected error, got nil") + t.Fatal("Expected error for float overflow") } - if !strings.Contains(err.Error(), "error marshalling context config") { - t.Errorf("Expected 'error marshalling context config' in error, got %v", err) + + // And the error message should indicate overflow + if !strings.Contains(err.Error(), "float overflow") { + t.Errorf("Expected error containing 'float overflow', got '%s'", err.Error()) } }) +} - t.Run("ContextConfigWriteError", func(t *testing.T) { - // Given a YamlConfigHandler with write error for context config - handler, mocks := setup(t) - - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil +func TestConfigHandler_SetDefault(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) } + handler.(*configHandler).shims = mocks.Shims + return handler, mocks + } - handler.context = "test-context" - - // Override Stat to return not exists (so files will be created) - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + t.Run("SetDefaultWithExistingContext", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + defaultContext := v1alpha1.Context{ + Environment: map[string]string{ + "ENV_VAR": "value", + }, } - // Track write calls to return error on second call (context config) - writeCallCount := 0 - handler.shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { - writeCallCount++ - if writeCallCount == 2 { - return fmt.Errorf("context write error") - } - return nil - } + // And a context is set + handler.Set("context", "local") - // When SaveConfig is called - err := handler.SaveConfig() + // When setting the default context + err := handler.SetDefault(defaultContext) - // Then an error should be returned - if err == nil { - t.Fatal("Expected error, got nil") + // Then no error should be returned + if err != nil { + t.Fatalf("Unexpected error: %v", err) } - if !strings.Contains(err.Error(), "error writing context config") { - t.Errorf("Expected 'error writing context config' in error, got %v", err) + + // And the default context should be set correctly + if handler.(*configHandler).defaultContextConfig.Environment["ENV_VAR"] != "value" { + t.Errorf("SetDefault() = %v, expected %v", handler.(*configHandler).defaultContextConfig.Environment["ENV_VAR"], "value") } }) - t.Run("BothFilesExist_NoOperationsPerformed", func(t *testing.T) { - // Given a YamlConfigHandler with both root and context configs existing - handler, mocks := setup(t) - - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + t.Run("SetDefaultWithNoContext", func(t *testing.T) { + // Given a handler with no context set + handler, _ := setup(t) + handler.(*configHandler).context = "" + defaultContext := v1alpha1.Context{ + Environment: map[string]string{ + "ENV_VAR": "value", + }, } - handler.context = "test-context" - - // Create both existing files - rootConfigPath := filepath.Join(tempDir, "windsor.yaml") - originalRootContent := "version: v1alpha1\nexisting: root" - os.WriteFile(rootConfigPath, []byte(originalRootContent), 0644) - - contextDir := filepath.Join(tempDir, "contexts", "test-context") - os.MkdirAll(contextDir, 0755) - contextConfigPath := filepath.Join(contextDir, "windsor.yaml") - originalContextContent := "provider: local\nexisting: context" - os.WriteFile(contextConfigPath, []byte(originalContextContent), 0644) - - // When SaveConfig is called - err := handler.SaveConfig() + // When setting the default context + err := handler.SetDefault(defaultContext) // Then no error should be returned if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - // And both files should remain unchanged - rootContent, _ := os.ReadFile(rootConfigPath) - if string(rootContent) != originalRootContent { - t.Errorf("Root config was modified when it shouldn't have been") + t.Fatalf("Unexpected error: %v", err) } - contextContent, _ := os.ReadFile(contextConfigPath) - if string(contextContent) != originalContextContent { - t.Errorf("Context config was modified when it shouldn't have been") + // And the default context should be set correctly + if handler.(*configHandler).defaultContextConfig.Environment["ENV_VAR"] != "value" { + t.Errorf("SetDefault() = %v, expected %v", handler.(*configHandler).defaultContextConfig.Environment["ENV_VAR"], "value") } }) - t.Run("EmptyVersion_UsesEmptyString", func(t *testing.T) { - // Given a YamlConfigHandler with empty version - handler, mocks := setup(t) - - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + t.Run("SetDefaultUsedInSubsequentOperations", func(t *testing.T) { + // Given a handler with an existing context + handler, _ := setup(t) + handler.(*configHandler).context = "existing-context" + handler.(*configHandler).config.Contexts = map[string]*v1alpha1.Context{ + "existing-context": {}, } - handler.context = "test-context" - handler.config.Version = "" - - // Override shims to actually work with the real filesystem - handler.shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { - return os.WriteFile(filename, data, perm) - } - handler.shims.MkdirAll = func(path string, perm os.FileMode) error { - return os.MkdirAll(path, perm) - } - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return os.Stat(name) + // And a default context configuration + defaultConf := v1alpha1.Context{ + Environment: map[string]string{"DEFAULT_VAR": "default_val"}, } - // When SaveConfig is called - err := handler.SaveConfig() + // When setting the default context + err := handler.SetDefault(defaultConf) // Then no error should be returned if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Fatalf("SetDefault() unexpected error: %v", err) } - // And the root config should contain empty version - rootConfigPath := filepath.Join(tempDir, "windsor.yaml") - content, _ := os.ReadFile(rootConfigPath) - if !strings.Contains(string(content), "version: \"\"") && !strings.Contains(string(content), "version:") { - t.Errorf("Expected version field in config, got: %s", string(content)) + // And the default context should be set correctly + if handler.(*configHandler).defaultContextConfig.Environment == nil || handler.(*configHandler).defaultContextConfig.Environment["DEFAULT_VAR"] != "default_val" { + t.Errorf("Expected defaultContextConfig environment to be %v, got %v", defaultConf.Environment, handler.(*configHandler).defaultContextConfig.Environment) } - }) - - t.Run("CreateContextConfigWhenNotInRootConfig", func(t *testing.T) { - // Given a YamlConfigHandler with existing root config - handler, mocks := setup(t) - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + // And the existing context should not be modified + if handler.(*configHandler).config.Contexts["existing-context"] == nil { + t.Errorf("SetDefault incorrectly overwrote existing context config") } + }) - // Create existing root config that doesn't include the current context - rootConfigPath := filepath.Join(tempDir, "windsor.yaml") - rootConfig := `version: v1alpha1 -contexts: - different-context: - provider: local` - os.WriteFile(rootConfigPath, []byte(rootConfig), 0644) - - // Load the existing root config - if err := handler.LoadConfig(rootConfigPath); err != nil { - t.Fatalf("Failed to load root config: %v", err) + t.Run("SetDefaultMergesWithExistingContext", func(t *testing.T) { + // Given a handler with an existing context containing some values + handler, _ := setup(t) + handler.(*configHandler).context = "test" + handler.(*configHandler).config.Contexts = map[string]*v1alpha1.Context{ + "test": { + ID: ptrString("existing-id"), + VM: &vm.VMConfig{ + Driver: ptrString("docker-desktop"), + }, + Environment: map[string]string{ + "EXISTING_VAR": "existing_value", + "OVERRIDE_VAR": "context_value", + }, + }, } - // Set the current context to one not defined in root config - handler.context = "new-context" - handler.Set("contexts.new-context.provider", "local") + // And a default context with overlapping and additional values + defaultContext := v1alpha1.Context{ + VM: &vm.VMConfig{ + CPU: ptrInt(4), + }, + Environment: map[string]string{ + "DEFAULT_VAR": "default_value", + "OVERRIDE_VAR": "default_value", + }, + } - // When SaveConfig is called - err := handler.SaveConfig() + // When setting the default context + err := handler.SetDefault(defaultContext) // Then no error should be returned if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Fatalf("SetDefault() unexpected error: %v", err) } - // And the context config should be created since the context is not in root config - contextConfigPath := filepath.Join(tempDir, "contexts", "new-context", "windsor.yaml") - if _, err := handler.shims.Stat(contextConfigPath); os.IsNotExist(err) { - t.Fatalf("Context config file was not created at %s, but should have been since context is not in root config", contextConfigPath) + // And the context should merge defaults with existing values + ctx := handler.(*configHandler).config.Contexts["test"] + if ctx == nil { + t.Fatal("Context was removed during SetDefault") } - // And the root config should not be overwritten - rootContent, _ := os.ReadFile(rootConfigPath) - if !strings.Contains(string(rootContent), "different-context") { - t.Errorf("Root config appears to have been overwritten") + // Existing values should be preserved + if ctx.ID == nil || *ctx.ID != "existing-id" { + t.Errorf("Expected ID to be preserved as 'existing-id', got %v", ctx.ID) } - }) - - t.Run("CreateContextConfigWhenRootConfigExistsWithoutContexts", func(t *testing.T) { - // Given a YamlConfigHandler with existing root config that has NO contexts section - handler, mocks := setup(t) - - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + if ctx.VM.Driver == nil || *ctx.VM.Driver != "docker-desktop" { + t.Errorf("Expected VM driver to be preserved as 'docker-desktop', got %v", ctx.VM.Driver) + } + if ctx.Environment["EXISTING_VAR"] != "existing_value" { + t.Errorf("Expected EXISTING_VAR to be preserved as 'existing_value', got '%s'", ctx.Environment["EXISTING_VAR"]) + } + if ctx.Environment["OVERRIDE_VAR"] != "context_value" { + t.Errorf("Expected OVERRIDE_VAR to keep context value 'context_value', got '%s'", ctx.Environment["OVERRIDE_VAR"]) } - // Create existing root config with only version (this is the most common case for user's issue) - rootConfigPath := filepath.Join(tempDir, "windsor.yaml") - rootConfig := `version: v1alpha1` - os.WriteFile(rootConfigPath, []byte(rootConfig), 0644) + // Default values should be added where not present + if ctx.VM.CPU == nil || *ctx.VM.CPU != 4 { + t.Errorf("Expected VM CPU to be added from default as 4, got %v", ctx.VM.CPU) + } + if ctx.Environment["DEFAULT_VAR"] != "default_value" { + t.Errorf("Expected DEFAULT_VAR to be added from default as 'default_value', got '%s'", ctx.Environment["DEFAULT_VAR"]) + } + }) - // Load the existing root config - if err := handler.LoadConfig(rootConfigPath); err != nil { - t.Fatalf("Failed to load root config: %v", err) + t.Run("SetDefaultMergesComplexNestedStructures", func(t *testing.T) { + // Given a handler with an existing context containing some values + handler, _ := setup(t) + handler.(*configHandler).context = "test" + handler.(*configHandler).config.Contexts = map[string]*v1alpha1.Context{ + "test": { + ID: ptrString("existing-id"), + Environment: map[string]string{ + "EXISTING_VAR": "existing_value", + }, + }, } - // Set the current context to local (typical init scenario) - handler.context = "local" - handler.Set("contexts.local.provider", "local") + // And a default context with additional values + defaultContext := v1alpha1.Context{ + Environment: map[string]string{ + "DEFAULT_VAR": "default_value", + }, + } - // When SaveConfig is called - err := handler.SaveConfig() + // When setting the default context + err := handler.SetDefault(defaultContext) // Then no error should be returned if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - // And the context config should be created since the context is not in root config - contextConfigPath := filepath.Join(tempDir, "contexts", "local", "windsor.yaml") - if _, err := handler.shims.Stat(contextConfigPath); os.IsNotExist(err) { - t.Fatalf("Context config file was not created at %s, but should have been since context is not in root config", contextConfigPath) + t.Fatalf("SetDefault() unexpected error: %v", err) } - // And the root config should not be overwritten - rootContent, _ := os.ReadFile(rootConfigPath) - if !strings.Contains(string(rootContent), "version: v1alpha1") { - t.Errorf("Root config appears to have been overwritten") + // And the context should have both existing and default values + ctx := handler.(*configHandler).config.Contexts["test"] + if ctx == nil { + t.Fatal("Context was removed during SetDefault") } - }) - - t.Run("SimulateInitPipelineWorkflow", func(t *testing.T) { - // Given a YamlConfigHandler simulating the exact init pipeline workflow - handler, mocks := setup(t) - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + // Existing values should be preserved + if ctx.ID == nil || *ctx.ID != "existing-id" { + t.Errorf("Expected ID to be preserved as 'existing-id', got %v", ctx.ID) } - - // Create existing root config with only version (common in real scenarios) - rootConfigPath := filepath.Join(tempDir, "windsor.yaml") - rootConfig := `version: v1alpha1` - os.WriteFile(rootConfigPath, []byte(rootConfig), 0644) - - // Step 1: Load existing config like init pipeline does in BasePipeline.Initialize - if err := handler.LoadConfig(rootConfigPath); err != nil { - t.Fatalf("Failed to load root config: %v", err) + if ctx.Environment["EXISTING_VAR"] != "existing_value" { + t.Errorf("Expected EXISTING_VAR to be preserved as 'existing_value', got '%s'", ctx.Environment["EXISTING_VAR"]) } - // Step 2: Set context like init pipeline does - if err := handler.SetContext("local"); err != nil { - t.Fatalf("Failed to set context: %v", err) + // Default values should be added where not present + if ctx.Environment["DEFAULT_VAR"] != "default_value" { + t.Errorf("Expected DEFAULT_VAR to be added from default as 'default_value', got '%s'", ctx.Environment["DEFAULT_VAR"]) } + }) - // Step 3: Set default configuration like init pipeline does - if err := handler.SetDefault(DefaultConfig); err != nil { - t.Fatalf("Failed to set default config: %v", err) - } + t.Run("SetDefaultWithNilContextsMap", func(t *testing.T) { + // Given a handler with a nil contexts map + handler, _ := setup(t) + handler.(*configHandler).context = "test" + handler.(*configHandler).config.Contexts = nil - // Step 4: Generate context ID like init pipeline does - if err := handler.GenerateContextID(); err != nil { - t.Fatalf("Failed to generate context ID: %v", err) + // And a default context + defaultContext := v1alpha1.Context{ + Environment: map[string]string{ + "DEFAULT_VAR": "default_value", + }, } - // Step 5: Save config like init pipeline does - err := handler.SaveConfig() + // When setting the default context + err := handler.SetDefault(defaultContext) // Then no error should be returned if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Fatalf("SetDefault() unexpected error: %v", err) } - // And the context config should be created since context is not defined in root - contextConfigPath := filepath.Join(tempDir, "contexts", "local", "windsor.yaml") - if _, err := handler.shims.Stat(contextConfigPath); os.IsNotExist(err) { - t.Errorf("Context config file was not created at %s, this reproduces the user's issue", contextConfigPath) + // And the contexts map should be created with the default + if handler.(*configHandler).config.Contexts == nil { + t.Fatal("Expected contexts map to be created") } - // And the root config should not be overwritten - rootContent, _ := os.ReadFile(rootConfigPath) - if !strings.Contains(string(rootContent), "version: v1alpha1") { - t.Errorf("Root config appears to have been overwritten") + ctx := handler.(*configHandler).config.Contexts["test"] + if ctx == nil { + t.Fatal("Expected test context to be created") } - }) - - t.Run("DebugSaveConfigLogic", func(t *testing.T) { - // Given a YamlConfigHandler with existing root config with no contexts - handler, mocks := setup(t) - - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + if ctx.Environment["DEFAULT_VAR"] != "default_value" { + t.Errorf("Expected DEFAULT_VAR to be 'default_value', got '%s'", ctx.Environment["DEFAULT_VAR"]) } + }) +} - // Create existing root config with only version (user's scenario) - rootConfigPath := filepath.Join(tempDir, "windsor.yaml") - rootConfig := `version: v1alpha1` - os.WriteFile(rootConfigPath, []byte(rootConfig), 0644) - - // Load the existing root config - if err := handler.LoadConfig(rootConfigPath); err != nil { - t.Fatalf("Failed to load root config: %v", err) +func TestConfigHandler_SetContextValue(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) } + handler.(*configHandler).shims = mocks.Shims + handler.(*configHandler).path = filepath.Join(t.TempDir(), "config.yaml") + return handler, mocks + } - // Set context and config values - handler.context = "local" - handler.Set("contexts.local.provider", "local") + t.Run("Success", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "test" - // Debug: Check what's in the config before SaveConfig - t.Logf("Config.Contexts before SaveConfig: %+v", handler.config.Contexts) - if handler.config.Contexts != nil { - if _, exists := handler.config.Contexts["local"]; exists { - t.Logf("local context exists in root config") - } else { - t.Logf("local context does NOT exist in root config") - } - } else { - t.Logf("Config.Contexts is nil") + // And a context with an empty environment map + actualContext := handler.GetContext() + handler.(*configHandler).config.Contexts = map[string]*v1alpha1.Context{ + actualContext: {}, } - // When SaveConfig is called - err := handler.SaveConfig() + // When setting a value in the context environment + err := handler.SetContextValue("environment.TEST_VAR", "test_value") // Then no error should be returned if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Fatalf("SetContextValue() unexpected error: %v", err) } - // Check if context config was created - contextConfigPath := filepath.Join(tempDir, "contexts", "local", "windsor.yaml") - if _, err := handler.shims.Stat(contextConfigPath); os.IsNotExist(err) { - t.Logf("Context config file was NOT created at %s", contextConfigPath) - } else { - t.Logf("Context config file WAS created at %s", contextConfigPath) + // And the value should be correctly set in the context + expected := "test_value" + if val := handler.(*configHandler).config.Contexts[actualContext].Environment["TEST_VAR"]; val != expected { + t.Errorf("SetContextValue() did not correctly set value, expected %s, got %s", expected, val) } }) - t.Run("ContextNotSetInRootConfigInitially", func(t *testing.T) { - // Given a YamlConfigHandler that mimics the exact init flow - handler, mocks := setup(t) - - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil - } + t.Run("EmptyPath", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) - // Create existing root config with only version (user's scenario) - rootConfigPath := filepath.Join(tempDir, "windsor.yaml") - rootConfig := `version: v1alpha1` - os.WriteFile(rootConfigPath, []byte(rootConfig), 0644) + // When attempting to set a value with an empty path + err := handler.SetContextValue("", "test_value") - // Load the existing root config - if err := handler.LoadConfig(rootConfigPath); err != nil { - t.Fatalf("Failed to load root config: %v", err) + // Then an error should be returned + if err == nil { + t.Errorf("SetContextValue() with empty path did not return an error") } - // Set the context but DON'T call Set() to add context data yet - handler.context = "local" - - // Debug: Check state before adding any context data - t.Logf("Config.Contexts before setting any context data: %+v", handler.config.Contexts) + // And the error message should be as expected + expectedErr := "path cannot be empty" + if err.Error() != expectedErr { + t.Errorf("Expected error message '%s', got '%s'", expectedErr, err.Error()) + } + }) - // When SaveConfig is called without any context configuration being set - err := handler.SaveConfig() + t.Run("SetFails", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "test" - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got %v", err) - } + // When attempting to set a value with an invalid path + err := handler.SetContextValue("invalid..path", "test_value") - // Check if context config was created - contextConfigPath := filepath.Join(tempDir, "contexts", "local", "windsor.yaml") - if _, err := handler.shims.Stat(contextConfigPath); os.IsNotExist(err) { - t.Errorf("Context config file was NOT created at %s - this reproduces the user's issue", contextConfigPath) - } else { - t.Logf("Context config file WAS created at %s", contextConfigPath) + // Then an error should be returned + if err == nil { + t.Errorf("SetContextValue() with invalid path did not return an error") } }) - t.Run("ReproduceActualIssue", func(t *testing.T) { - // Given a real-world scenario where a root windsor.yaml exists with only version - handler, mocks := setup(t) + t.Run("ConvertStringToBool", func(t *testing.T) { + handler, _ := setup(t) + handler.(*configHandler).context = "default" + handler.(*configHandler).config.Contexts = map[string]*v1alpha1.Context{ + "default": {}, + } - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + // Set initial bool value + if err := handler.SetContextValue("environment.BOOL_VAR", "true"); err != nil { + t.Fatalf("Failed to set initial bool value: %v", err) } - // Create existing root config with only version (exact user scenario) - rootConfigPath := filepath.Join(tempDir, "windsor.yaml") - rootConfig := `version: v1alpha1` - os.WriteFile(rootConfigPath, []byte(rootConfig), 0644) + // Override with string "false" + if err := handler.SetContextValue("environment.BOOL_VAR", "false"); err != nil { + t.Fatalf("Failed to set string bool value: %v", err) + } - // Step 1: Load existing config like init pipeline does - if err := handler.LoadConfig(rootConfigPath); err != nil { - t.Fatalf("Failed to load root config: %v", err) + val := handler.GetString("environment.BOOL_VAR") + if val != "false" { + t.Errorf("Expected false, got %v", val) } + }) - // Step 2: Set context - if err := handler.SetContext("local"); err != nil { - t.Fatalf("Failed to set context: %v", err) + t.Run("ConvertStringToInt", func(t *testing.T) { + handler, _ := setup(t) + handler.(*configHandler).context = "default" + handler.(*configHandler).config.Contexts = map[string]*v1alpha1.Context{ + "default": {}, } - // Step 3: Set default configuration (this would add context data) - if err := handler.SetDefault(DefaultConfig); err != nil { - t.Fatalf("Failed to set default config: %v", err) + // Set initial int value + if err := handler.SetContextValue("environment.INT_VAR", "42"); err != nil { + t.Fatalf("Failed to set initial int value: %v", err) } - // Step 4: Generate context ID - if err := handler.GenerateContextID(); err != nil { - t.Fatalf("Failed to generate context ID: %v", err) + // Override with string "100" + if err := handler.SetContextValue("environment.INT_VAR", "100"); err != nil { + t.Fatalf("Failed to set string int value: %v", err) } - // Debug: Check config state before SaveConfig - t.Logf("Config before SaveConfig: %+v", handler.config) - if handler.config.Contexts != nil { - if ctx, exists := handler.config.Contexts["local"]; exists { - t.Logf("local context exists in config: %+v", ctx) - } else { - t.Logf("local context does NOT exist in config") - } - } else { - t.Logf("Config.Contexts is nil") + val := handler.GetString("environment.INT_VAR") + if val != "100" { + t.Errorf("Expected 100, got %v", val) } + }) - // Step 5: Save config (the critical call) - err := handler.SaveConfig() - if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Run("ConvertStringToFloat", func(t *testing.T) { + handler, _ := setup(t) + handler.(*configHandler).context = "default" + handler.(*configHandler).config.Contexts = map[string]*v1alpha1.Context{ + "default": {}, } - // Check if context config file was created - contextConfigPath := filepath.Join(tempDir, "contexts", "local", "windsor.yaml") - if _, err := handler.shims.Stat(contextConfigPath); os.IsNotExist(err) { - t.Errorf("Context config file was NOT created at %s - this is the bug!", contextConfigPath) - } else { - content, _ := os.ReadFile(contextConfigPath) - t.Logf("Context config file WAS created with content: %s", string(content)) + // Set initial float value + if err := handler.SetContextValue("environment.FLOAT_VAR", "3.14"); err != nil { + t.Fatalf("Failed to set initial float value: %v", err) } - // Check root config wasn't overwritten - rootContent, _ := os.ReadFile(rootConfigPath) - if !strings.Contains(string(rootContent), "version: v1alpha1") { - t.Errorf("Root config appears to have been overwritten: %s", string(rootContent)) + // Override with string "6.28" + if err := handler.SetContextValue("environment.FLOAT_VAR", "6.28"); err != nil { + t.Fatalf("Failed to set string float value: %v", err) } - }) - - t.Run("SavesContextValuesWhenLoaded", func(t *testing.T) { - // Given a YamlConfigHandler with loaded contextValues - handler, mocks := setup(t) - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + val := handler.GetString("environment.FLOAT_VAR") + if val != "6.28" { + t.Errorf("Expected 6.28, got %v", val) } + }) - // Use real filesystem operations for this test - handler.shims = NewShims() - handler.shims.Getenv = func(key string) string { - if key == "WINDSOR_CONTEXT" { - return "test-context" - } - return "" + t.Run("ConvertStringToBoolPointer", func(t *testing.T) { + // Given a handler with a default context + handler, _ := setup(t) + handler.(*configHandler).context = "default" + handler.(*configHandler).config.Contexts = map[string]*v1alpha1.Context{ + "default": {}, } - handler.context = "test-context" - handler.loaded = true - handler.contextValues = map[string]any{ - "test_key": "test_value", - "number": 42, + // When setting a string "false" to a bool pointer field (dns.enabled) + if err := handler.SetContextValue("dns.enabled", "false"); err != nil { + t.Fatalf("Failed to set dns.enabled=false from string: %v", err) } - // When SaveConfig is called - err := handler.SaveConfig() - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got %v", err) + // Then the value should be correctly set as a boolean + config := handler.GetConfig() + if config.DNS == nil || config.DNS.Enabled == nil || *config.DNS.Enabled != false { + t.Errorf("Expected dns.enabled to be false, got %v", config.DNS.Enabled) } - // And values.yaml should be created with the context values - valuesPath := filepath.Join(tempDir, "contexts", "test-context", "values.yaml") - if _, err := os.Stat(valuesPath); os.IsNotExist(err) { - t.Fatalf("values.yaml was not created at %s", valuesPath) + // And when setting "true" as well + if err := handler.SetContextValue("dns.enabled", "true"); err != nil { + t.Fatalf("Failed to set dns.enabled=true from string: %v", err) } - // And the content should match contextValues - content, err := os.ReadFile(valuesPath) - if err != nil { - t.Fatalf("Failed to read values.yaml: %v", err) - } - if !strings.Contains(string(content), "test_key") { - t.Errorf("values.yaml should contain 'test_key', got: %s", string(content)) - } - if !strings.Contains(string(content), "test_value") { - t.Errorf("values.yaml should contain 'test_value', got: %s", string(content)) + config = handler.GetConfig() + if config.DNS == nil || config.DNS.Enabled == nil || *config.DNS.Enabled != true { + t.Errorf("Expected dns.enabled to be true, got %v", config.DNS.Enabled) } }) - t.Run("SkipsSavingContextValuesWhenNotLoaded", func(t *testing.T) { - // Given a YamlConfigHandler with contextValues but not loaded - handler, mocks := setup(t) + t.Run("SchemaRoutingAndInitialization", func(t *testing.T) { + handler, _ := setup(t) + handler.(*configHandler).context = "test" - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + // Test invalid path formats + err := handler.SetContextValue("..invalid", "value") + if err == nil { + t.Error("Expected error for invalid path") } - handler.context = "test-context" - handler.loaded = false - handler.contextValues = map[string]any{ - "test_key": "test_value", + // Test static schema routing (goes to context config) + err = handler.SetContextValue("environment.STATIC_VAR", "static_value") + if err != nil { + t.Fatalf("Failed to set static schema value: %v", err) + } + if handler.(*configHandler).config.Contexts["test"].Environment["STATIC_VAR"] != "static_value" { + t.Error("Static value should be in context config") } - // When SaveConfig is called - err := handler.SaveConfig() - - // Then no error should be returned + // Test dynamic schema routing (goes to contextValues) + err = handler.SetContextValue("dynamic_key", "dynamic_value") if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Fatalf("Failed to set dynamic schema value: %v", err) + } + if handler.(*configHandler).contextValues["dynamic_key"] != "dynamic_value" { + t.Error("Dynamic value should be in contextValues") } - // And values.yaml should NOT be created - valuesPath := filepath.Join(tempDir, "contexts", "test-context", "values.yaml") - if _, err := os.Stat(valuesPath); !os.IsNotExist(err) { - t.Errorf("values.yaml should not have been created when not loaded") + // Test initialization when not loaded + handler.(*configHandler).loaded = false + handler.(*configHandler).contextValues = nil + err = handler.SetContextValue("not_loaded_key", "not_loaded_value") + if err != nil { + t.Fatalf("Failed to set value when not loaded: %v", err) + } + if handler.(*configHandler).contextValues["not_loaded_key"] != "not_loaded_value" { + t.Error("contextValues should be initialized even when not loaded") } }) - t.Run("SkipsSavingContextValuesWhenNil", func(t *testing.T) { - // Given a YamlConfigHandler with nil contextValues - handler, mocks := setup(t) + t.Run("SchemaAwareTypeConversion", func(t *testing.T) { + handler, _ := setup(t) + handler.(*configHandler).context = "test" + handler.(*configHandler).loaded = true - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + // Set up schema validator with type definitions + handler.(*configHandler).schemaValidator = &SchemaValidator{ + Schema: map[string]any{ + "properties": map[string]any{ + "dev": map[string]any{ + "type": "boolean", + }, + "port": map[string]any{ + "type": "integer", + }, + "ratio": map[string]any{ + "type": "number", + }, + "name": map[string]any{ + "type": "string", + }, + }, + }, } - handler.context = "test-context" - handler.loaded = true - handler.contextValues = nil - - // When SaveConfig is called - err := handler.SaveConfig() - - // Then no error should be returned + // Test boolean conversion + err := handler.SetContextValue("dev", "true") if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Fatalf("Failed to set boolean value: %v", err) } - - // And values.yaml should NOT be created - valuesPath := filepath.Join(tempDir, "contexts", "test-context", "values.yaml") - if _, err := os.Stat(valuesPath); !os.IsNotExist(err) { - t.Errorf("values.yaml should not have been created when contextValues is nil") + if handler.(*configHandler).contextValues["dev"] != true { + t.Errorf("Expected boolean true, got %v (%T)", handler.(*configHandler).contextValues["dev"], handler.(*configHandler).contextValues["dev"]) } - }) - - t.Run("SkipsSavingContextValuesWhenEmpty", func(t *testing.T) { - // Given a YamlConfigHandler with empty contextValues - handler, mocks := setup(t) - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + // Test integer conversion + err = handler.SetContextValue("port", "8080") + if err != nil { + t.Fatalf("Failed to set integer value: %v", err) + } + if handler.(*configHandler).contextValues["port"] != 8080 { + t.Errorf("Expected integer 8080, got %v (%T)", handler.(*configHandler).contextValues["port"], handler.(*configHandler).contextValues["port"]) } - handler.context = "test-context" - handler.loaded = true - handler.contextValues = map[string]any{} - - // When SaveConfig is called - err := handler.SaveConfig() - - // Then no error should be returned + // Test number conversion + err = handler.SetContextValue("ratio", "3.14") if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Fatalf("Failed to set number value: %v", err) + } + if handler.(*configHandler).contextValues["ratio"] != 3.14 { + t.Errorf("Expected number 3.14, got %v (%T)", handler.(*configHandler).contextValues["ratio"], handler.(*configHandler).contextValues["ratio"]) } - // And values.yaml should NOT be created - valuesPath := filepath.Join(tempDir, "contexts", "test-context", "values.yaml") - if _, err := os.Stat(valuesPath); !os.IsNotExist(err) { - t.Errorf("values.yaml should not have been created when contextValues is empty") + // Test string conversion (should remain string) + err = handler.SetContextValue("name", "test") + if err != nil { + t.Fatalf("Failed to set string value: %v", err) + } + if handler.(*configHandler).contextValues["name"] != "test" { + t.Errorf("Expected string 'test', got %v (%T)", handler.(*configHandler).contextValues["name"], handler.(*configHandler).contextValues["name"]) } }) - t.Run("SaveContextValuesError", func(t *testing.T) { - // Given a YamlConfigHandler with contextValues and a write error - handler, mocks := setup(t) + t.Run("FallbackPatternConversion", func(t *testing.T) { + handler, _ := setup(t) + handler.(*configHandler).context = "test" + handler.(*configHandler).loaded = true - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + // No schema validator - should use pattern matching + + // Test boolean pattern matching + err := handler.SetContextValue("enabled", "true") + if err != nil { + t.Fatalf("Failed to set boolean value: %v", err) + } + if handler.(*configHandler).contextValues["enabled"] != true { + t.Errorf("Expected boolean true, got %v (%T)", handler.(*configHandler).contextValues["enabled"], handler.(*configHandler).contextValues["enabled"]) } - // Use real filesystem operations but mock WriteFile to fail for values.yaml - handler.shims = NewShims() - handler.shims.Getenv = func(key string) string { - if key == "WINDSOR_CONTEXT" { - return "test-context" - } - return "" + // Test integer pattern matching + err = handler.SetContextValue("count", "42") + if err != nil { + t.Fatalf("Failed to set integer value: %v", err) } - handler.shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { - if strings.Contains(filename, "values.yaml") { - return fmt.Errorf("write error") - } - return os.WriteFile(filename, data, perm) + if handler.(*configHandler).contextValues["count"] != 42 { + t.Errorf("Expected integer 42, got %v (%T)", handler.(*configHandler).contextValues["count"], handler.(*configHandler).contextValues["count"]) } - handler.context = "test-context" - handler.loaded = true - handler.contextValues = map[string]any{ - "test_key": "test_value", + // Test float pattern matching + err = handler.SetContextValue("rate", "2.5") + if err != nil { + t.Fatalf("Failed to set float value: %v", err) + } + if handler.(*configHandler).contextValues["rate"] != 2.5 { + t.Errorf("Expected float 2.5, got %v (%T)", handler.(*configHandler).contextValues["rate"], handler.(*configHandler).contextValues["rate"]) } + }) - // When SaveConfig is called - err := handler.SaveConfig() + t.Run("SchemaConversionFailure", func(t *testing.T) { + handler, _ := setup(t) + handler.(*configHandler).context = "test" + handler.(*configHandler).loaded = true - // Then an error should be returned + // Set up schema validator with boolean type and validation support + mockShell := handler.(*configHandler).shell + mockValidator := NewSchemaValidator(mockShell) + mockValidator.Schema = map[string]any{ + "properties": map[string]any{ + "dev": map[string]any{ + "type": "boolean", + }, + }, + } + handler.(*configHandler).schemaValidator = mockValidator + + // Test invalid boolean value - should now fail validation + err := handler.SetContextValue("dev", "invalid") if err == nil { - t.Fatal("Expected error, got nil") + t.Fatal("Expected validation error for invalid boolean value, got nil") } - if !strings.Contains(err.Error(), "error saving values.yaml") { - t.Errorf("Expected 'error saving values.yaml' in error, got %v", err) + if !strings.Contains(err.Error(), "validation failed") && !strings.Contains(err.Error(), "type mismatch") { + t.Errorf("Expected validation error, got: %v", err) } }) } -func TestYamlConfigHandler_GetString(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { +func TestConfigHandler_convertStringValue(t *testing.T) { + setup := func(t *testing.T) ConfigHandler { mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims + handler := NewConfigHandler(mocks.Injector) if err := handler.Initialize(); err != nil { t.Fatalf("Failed to initialize handler: %v", err) } - return handler, mocks + handler.(*configHandler).shims = mocks.Shims + return handler } - t.Run("WithNonExistentKey", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - handler.context = "default" - - // When getting a non-existent key - got := handler.GetString("nonExistentKey") + t.Run("NonStringValue", func(t *testing.T) { + handler := setup(t) - // Then an empty string should be returned - expectedValue := "" - if got != expectedValue { - t.Errorf("GetString() = %v, expected %v", got, expectedValue) + // Non-string values should be returned as-is + result := handler.(*configHandler).convertStringValue(42) + if result != 42 { + t.Errorf("Expected 42, got %v", result) } - }) - - t.Run("GetStringWithDefaultValue", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - handler.context = "default" - // When getting a non-existent key with a default value - defaultValue := "defaultString" - value := handler.GetString("non.existent.key", defaultValue) - - // Then the default value should be returned - if value != defaultValue { - t.Errorf("Expected value '%v', got '%v'", defaultValue, value) + result = handler.(*configHandler).convertStringValue(true) + if result != true { + t.Errorf("Expected true, got %v", result) } }) - t.Run("WithExistingKey", func(t *testing.T) { - // Given a handler with a context and existing key-value pair - handler, _ := setup(t) - handler.context = "default" - handler.config = v1alpha1.Config{ - Contexts: map[string]*v1alpha1.Context{ - "default": { - Environment: map[string]string{ - "existingKey": "existingValue", + t.Run("SchemaAwareConversion", func(t *testing.T) { + handler := setup(t) + + // Set up schema validator + handler.(*configHandler).schemaValidator = &SchemaValidator{ + Schema: map[string]any{ + "properties": map[string]any{ + "enabled": map[string]any{ + "type": "boolean", + }, + "count": map[string]any{ + "type": "integer", + }, + "rate": map[string]any{ + "type": "number", }, }, }, } - // When getting an existing key - got := handler.GetString("environment.existingKey") + // Test boolean conversion + result := handler.(*configHandler).convertStringValue("true") + if result != true { + t.Errorf("Expected boolean true, got %v (%T)", result, result) + } - // Then the value should be returned as a string - expectedValue := "existingValue" - if got != expectedValue { - t.Errorf("GetString() = %v, expected %v", got, expectedValue) + // Test integer conversion + result = handler.(*configHandler).convertStringValue("42") + if result != 42 { + t.Errorf("Expected integer 42, got %v (%T)", result, result) } - }) -} -func TestYamlConfigHandler_GetInt(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - return handler, mocks - } - - t.Run("WithExistingNonIntegerKey", func(t *testing.T) { - // Given a handler with a context and non-integer value - handler, _ := setup(t) - handler.context = "default" - handler.config = v1alpha1.Config{ - Contexts: map[string]*v1alpha1.Context{ - "default": { - AWS: &aws.AWSConfig{ - AWSEndpointURL: ptrString("notAnInt"), - }, - }, - }, - } - - // When getting a key with non-integer value - value := handler.GetInt("aws.aws_endpoint_url") - - // Then the default integer value should be returned - expectedValue := 0 - if value != expectedValue { - t.Errorf("Expected value %v, got %v", expectedValue, value) + // Test number conversion + result = handler.(*configHandler).convertStringValue("3.14") + if result != 3.14 { + t.Errorf("Expected number 3.14, got %v (%T)", result, result) } }) - t.Run("WithNonExistentKey", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - handler.context = "default" - - // When getting a non-existent key - value := handler.GetInt("nonExistentKey") + t.Run("PatternMatchingFallback", func(t *testing.T) { + handler := setup(t) + // No schema validator - should use pattern matching - // Then the default integer value should be returned - expectedValue := 0 - if value != expectedValue { - t.Errorf("Expected value %v, got %v", expectedValue, value) + // Test boolean pattern + result := handler.(*configHandler).convertStringValue("true") + if result != true { + t.Errorf("Expected boolean true, got %v (%T)", result, result) } - }) - - t.Run("WithNonExistentKeyAndDefaultValue", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - handler.context = "default" - // When getting a non-existent key with a default value - got := handler.GetInt("nonExistentKey", 99) - - // Then the provided default value should be returned - expectedValue := 99 - if got != expectedValue { - t.Errorf("GetInt() = %v, expected %v", got, expectedValue) + result = handler.(*configHandler).convertStringValue("false") + if result != false { + t.Errorf("Expected boolean false, got %v (%T)", result, result) } - }) - t.Run("WithExistingIntegerKey", func(t *testing.T) { - // Given a handler with a context and integer value - handler, _ := setup(t) - handler.context = "default" - handler.config = v1alpha1.Config{ - Contexts: map[string]*v1alpha1.Context{ - "default": { - Cluster: &cluster.ClusterConfig{ - ControlPlanes: cluster.NodeGroupConfig{ - Count: ptrInt(3), - }, - }, - }, - }, + // Test integer pattern + result = handler.(*configHandler).convertStringValue("123") + if result != 123 { + t.Errorf("Expected integer 123, got %v (%T)", result, result) } - // When getting an existing integer key - got := handler.GetInt("cluster.controlplanes.count") + // Test float pattern + result = handler.(*configHandler).convertStringValue("45.67") + if result != 45.67 { + t.Errorf("Expected float 45.67, got %v (%T)", result, result) + } - // Then the integer value should be returned - expectedValue := 3 - if got != expectedValue { - t.Errorf("GetInt() = %v, expected %v", got, expectedValue) + // Test string (no conversion) + result = handler.(*configHandler).convertStringValue("hello") + if result != "hello" { + t.Errorf("Expected string 'hello', got %v (%T)", result, result) } }) } -func TestYamlConfigHandler_GetBool(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { +func TestConfigHandler_getExpectedTypeFromSchema(t *testing.T) { + setup := func(t *testing.T) ConfigHandler { mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims + handler := NewConfigHandler(mocks.Injector) if err := handler.Initialize(); err != nil { t.Fatalf("Failed to initialize handler: %v", err) } - return handler, mocks + handler.(*configHandler).shims = mocks.Shims + return handler } - t.Run("WithExistingBooleanKey", func(t *testing.T) { - // Given a handler with a context and boolean value - handler, _ := setup(t) - handler.context = "default" - handler.config = v1alpha1.Config{ - Contexts: map[string]*v1alpha1.Context{ - "default": { - AWS: &aws.AWSConfig{ - Enabled: ptrBool(true), + t.Run("ValidSchema", func(t *testing.T) { + handler := setup(t) + + handler.(*configHandler).schemaValidator = &SchemaValidator{ + Schema: map[string]any{ + "properties": map[string]any{ + "enabled": map[string]any{ + "type": "boolean", + }, + "count": map[string]any{ + "type": "integer", }, }, }, } - // When getting an existing boolean key - got := handler.GetBool("aws.enabled") - - // Then the boolean value should be returned - expectedValue := true - if got != expectedValue { - t.Errorf("GetBool() = %v, expected %v", got, expectedValue) + // Test existing property + result := handler.(*configHandler).getExpectedTypeFromSchema("enabled") + if result != "boolean" { + t.Errorf("Expected 'boolean', got '%s'", result) } - }) - - t.Run("WithExistingNonBooleanKey", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - handler.context = "default" - // When setting a non-boolean value for the key - handler.Set("contexts.default.aws.aws_endpoint_url", "notABool") - - // When getting an existing key with a non-boolean value - value := handler.GetBool("aws.aws_endpoint_url") - expectedValue := false + result = handler.(*configHandler).getExpectedTypeFromSchema("count") + if result != "integer" { + t.Errorf("Expected 'integer', got '%s'", result) + } - // Then the default boolean value should be returned - if value != expectedValue { - t.Errorf("Expected value %v, got %v", expectedValue, value) + // Test non-existing property + result = handler.(*configHandler).getExpectedTypeFromSchema("nonexistent") + if result != "" { + t.Errorf("Expected empty string, got '%s'", result) } }) - t.Run("WithNonExistentKey", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - handler.context = "default" - - // When getting a non-existent key - value := handler.GetBool("nonExistentKey") - expectedValue := false + t.Run("NoSchemaValidator", func(t *testing.T) { + handler := setup(t) + // No schema validator - // Then the default boolean value should be returned - if value != expectedValue { - t.Errorf("Expected value %v, got %v", expectedValue, value) + result := handler.(*configHandler).getExpectedTypeFromSchema("anykey") + if result != "" { + t.Errorf("Expected empty string, got '%s'", result) } }) - t.Run("WithNonExistentKeyAndDefaultValue", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - handler.context = "default" + t.Run("InvalidSchema", func(t *testing.T) { + handler := setup(t) - // When getting a non-existent key with a default value - got := handler.GetBool("nonExistentKey", false) + handler.(*configHandler).schemaValidator = &SchemaValidator{ + Schema: map[string]any{ + "properties": "invalid", // Should be map[string]any + }, + } - // Then the provided default value should be returned - expectedValue := false - if got != expectedValue { - t.Errorf("GetBool() = %v, expected %v", got, expectedValue) + result := handler.(*configHandler).getExpectedTypeFromSchema("anykey") + if result != "" { + t.Errorf("Expected empty string, got '%s'", result) } }) } -func TestYamlConfigHandler_GetStringSlice(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { +func TestConfigHandler_convertStringToType(t *testing.T) { + setup := func(t *testing.T) ConfigHandler { mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims + handler := NewConfigHandler(mocks.Injector) if err := handler.Initialize(); err != nil { t.Fatalf("Failed to initialize handler: %v", err) } - return handler, mocks + handler.(*configHandler).shims = mocks.Shims + return handler } - t.Run("Success", func(t *testing.T) { - // Given a handler with a context containing a slice value - handler, _ := setup(t) - handler.context = "default" - handler.config.Contexts = map[string]*v1alpha1.Context{ - "default": { - Cluster: &cluster.ClusterConfig{ - Workers: cluster.NodeGroupConfig{ - HostPorts: []string{"50000:50002/tcp", "30080:8080/tcp", "30443:8443/tcp"}, - }, - }, - }, + handler := setup(t) + + t.Run("BooleanConversion", func(t *testing.T) { + result := handler.(*configHandler).convertStringToType("true", "boolean") + if result != true { + t.Errorf("Expected true, got %v", result) } - // When retrieving the slice value using GetStringSlice - value := handler.GetStringSlice("cluster.workers.hostports") + result = handler.(*configHandler).convertStringToType("false", "boolean") + if result != false { + t.Errorf("Expected false, got %v", result) + } - // Then the returned slice should match the expected slice - expectedSlice := []string{"50000:50002/tcp", "30080:8080/tcp", "30443:8443/tcp"} - if !reflect.DeepEqual(value, expectedSlice) { - t.Errorf("Expected GetStringSlice to return %v, got %v", expectedSlice, value) + result = handler.(*configHandler).convertStringToType("invalid", "boolean") + if result != nil { + t.Errorf("Expected nil, got %v", result) } }) - t.Run("WithNonExistentKey", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - handler.context = "default" - - // When retrieving a non-existent key using GetStringSlice - value := handler.GetStringSlice("nonExistentKey") + t.Run("IntegerConversion", func(t *testing.T) { + result := handler.(*configHandler).convertStringToType("42", "integer") + if result != 42 { + t.Errorf("Expected 42, got %v", result) + } - // Then the returned value should be an empty slice - if len(value) != 0 { - t.Errorf("Expected GetStringSlice with non-existent key to return an empty slice, got %v", value) + result = handler.(*configHandler).convertStringToType("invalid", "integer") + if result != nil { + t.Errorf("Expected nil, got %v", result) } }) - t.Run("WithNonExistentKeyAndDefaultValue", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - handler.context = "default" - defaultValue := []string{"default1", "default2"} - - // When retrieving a non-existent key with a default value - value := handler.GetStringSlice("nonExistentKey", defaultValue) + t.Run("NumberConversion", func(t *testing.T) { + result := handler.(*configHandler).convertStringToType("3.14", "number") + if result != 3.14 { + t.Errorf("Expected 3.14, got %v", result) + } - // Then the returned value should match the default value - if !reflect.DeepEqual(value, defaultValue) { - t.Errorf("Expected GetStringSlice with default to return %v, got %v", defaultValue, value) + result = handler.(*configHandler).convertStringToType("invalid", "number") + if result != nil { + t.Errorf("Expected nil, got %v", result) } }) - t.Run("TypeMismatch", func(t *testing.T) { - // Given a handler where the key exists but is not a slice - handler, _ := setup(t) - handler.context = "default" - handler.Set("contexts.default.cluster.workers.hostports", 123) // Set an int instead of slice - - // When retrieving the value using GetStringSlice - value := handler.GetStringSlice("cluster.workers.hostports") - - // Then the returned slice should be empty - if len(value) != 0 { - t.Errorf("Expected empty slice due to type mismatch, got %v", value) + t.Run("StringConversion", func(t *testing.T) { + result := handler.(*configHandler).convertStringToType("hello", "string") + if result != "hello" { + t.Errorf("Expected 'hello', got %v", result) } }) } -func TestYamlConfigHandler_GetStringMap(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { +func TestConfigHandler_LoadConfigString(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims if err := handler.Initialize(); err != nil { t.Fatalf("Failed to initialize handler: %v", err) } @@ -1595,1698 +1415,127 @@ func TestYamlConfigHandler_GetStringMap(t *testing.T) { t.Run("Success", func(t *testing.T) { // Given a handler with a context set handler, _ := setup(t) - handler.context = "default" - handler.config.Contexts = map[string]*v1alpha1.Context{ - "default": { - Environment: map[string]string{ - "KEY1": "value1", - "KEY2": "value2", - }, - }, - } + handler.SetContext("test") + + // And a valid YAML configuration string + yamlContent := ` +version: v1alpha1 +contexts: + test: + environment: + TEST_VAR: test_value` - // When retrieving the map value using GetStringMap - value := handler.GetStringMap("environment") + // When loading the configuration string + err := handler.LoadConfigString(yamlContent) + + // Then no error should be returned + if err != nil { + t.Fatalf("LoadConfigString() unexpected error: %v", err) + } - // Then the returned map should match the expected map - expectedMap := map[string]string{"KEY1": "value1", "KEY2": "value2"} - if !reflect.DeepEqual(value, expectedMap) { - t.Errorf("Expected GetStringMap to return %v, got %v", expectedMap, value) + // And the value should be correctly loaded + value := handler.GetString("environment.TEST_VAR") + if value != "test_value" { + t.Errorf("Expected TEST_VAR = 'test_value', got '%s'", value) } }) - t.Run("WithNonExistentKey", func(t *testing.T) { + t.Run("EmptyContent", func(t *testing.T) { // Given a handler with a context set handler, _ := setup(t) - handler.context = "default" - // When retrieving a non-existent key using GetStringMap - value := handler.GetStringMap("nonExistentKey") + // When loading an empty configuration string + err := handler.LoadConfigString("") - // Then the returned value should be an empty map - if !reflect.DeepEqual(value, map[string]string{}) { - t.Errorf("Expected GetStringMap with non-existent key to return an empty map, got %v", value) + // Then no error should be returned + if err != nil { + t.Fatalf("LoadConfigString() unexpected error: %v", err) } }) - t.Run("WithNonExistentKeyAndDefaultValue", func(t *testing.T) { + t.Run("InvalidYAML", func(t *testing.T) { // Given a handler with a context set handler, _ := setup(t) - handler.context = "default" - defaultValue := map[string]string{"defaultKey1": "defaultValue1", "defaultKey2": "defaultValue2"} - // When retrieving a non-existent key with a default value - value := handler.GetStringMap("nonExistentKey", defaultValue) + // And an invalid YAML string + yamlContent := `invalid: yaml: content: [}` + + // When loading the invalid YAML + err := handler.LoadConfigString(yamlContent) + + // Then an error should be returned + if err == nil { + t.Fatal("LoadConfigString() expected error for invalid YAML") + } - // Then the returned value should match the default value - if !reflect.DeepEqual(value, defaultValue) { - t.Errorf("Expected GetStringMap with default to return %v, got %v", defaultValue, value) + // And the error message should indicate YAML unmarshalling failure + if !strings.Contains(err.Error(), "error unmarshalling yaml") { + t.Errorf("Expected error about invalid YAML, got: %v", err) } }) - t.Run("TypeMismatch", func(t *testing.T) { - // Given a handler where the key exists but is not a map[string]string + t.Run("UnsupportedVersion", func(t *testing.T) { + // Given a handler with a context set handler, _ := setup(t) - handler.context = "default" - handler.Set("contexts.default.environment", 123) // Set an int instead of map - // When retrieving the value using GetStringMap - value := handler.GetStringMap("environment") + // And a YAML string with an unsupported version + yamlContent := ` +version: v2alpha1 +contexts: + test: {}` + + // When loading the YAML with unsupported version + err := handler.LoadConfigString(yamlContent) - // Then the returned map should be empty - if len(value) != 0 { - t.Errorf("Expected empty map due to type mismatch, got %v", value) + // Then an error should be returned + if err == nil { + t.Fatal("LoadConfigString() expected error for unsupported version") } - }) -} -func TestYamlConfigHandler_GetConfig(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize handler: %v", err) + // And the error message should indicate unsupported version + if !strings.Contains(err.Error(), "unsupported config version") { + t.Errorf("Expected error about unsupported version, got: %v", err) } - handler.shims = mocks.Shims - return handler, mocks - } + }) +} - t.Run("EmptyContext", func(t *testing.T) { - // Given a handler with no context set - handler, _ := setup(t) +func Test_makeAddressable(t *testing.T) { + t.Run("AlreadyAddressable", func(t *testing.T) { + // Given an addressable value + var x int = 42 + v := reflect.ValueOf(&x).Elem() - // When getting the config - config := handler.GetConfig() + // When making it addressable + result := makeAddressable(v) - // Then the default config should be returned - if config == nil { - t.Fatal("Expected default config, got nil") + // Then the same value should be returned + if result.Interface() != v.Interface() { + t.Errorf("makeAddressable() = %v, want %v", result.Interface(), v.Interface()) } }) - t.Run("NonExistentContext", func(t *testing.T) { - // Given a handler with a non-existent context - handler, _ := setup(t) - handler.context = "nonexistent" + t.Run("NonAddressable", func(t *testing.T) { + // Given a non-addressable value + v := reflect.ValueOf(42) - // When getting the config - config := handler.GetConfig() + // When making it addressable + result := makeAddressable(v) - // Then the default config should be returned - if config == nil { - t.Fatal("Expected default config, got nil") + // Then a new addressable value should be returned + if !result.CanAddr() { + t.Error("makeAddressable() returned non-addressable value") + } + if result.Interface() != v.Interface() { + t.Errorf("makeAddressable() = %v, want %v", result.Interface(), v.Interface()) } }) - t.Run("ExistingContext", func(t *testing.T) { - // Given a handler with an existing context - handler, _ := setup(t) - handler.context = "test" + t.Run("NilValue", func(t *testing.T) { + // Given a nil value + var v reflect.Value - // And a context with environment variables - handler.config.Contexts = map[string]*v1alpha1.Context{ - "test": { - Environment: map[string]string{ - "TEST_VAR": "test_value", - }, - }, - } - - // And default context with different environment variables - handler.defaultContextConfig = v1alpha1.Context{ - Environment: map[string]string{ - "DEFAULT_VAR": "default_value", - }, - } - - // When getting the config - config := handler.GetConfig() - - // Then the merged config should be returned - if config == nil { - t.Fatal("Expected merged config, got nil") - } - - // And it should contain both environment variables - if config.Environment["TEST_VAR"] != "test_value" { - t.Errorf("Expected TEST_VAR to be 'test_value', got '%s'", config.Environment["TEST_VAR"]) - } - if config.Environment["DEFAULT_VAR"] != "default_value" { - t.Errorf("Expected DEFAULT_VAR to be 'default_value', got '%s'", config.Environment["DEFAULT_VAR"]) - } - }) - - t.Run("ContextOverridesDefault", func(t *testing.T) { - // Given a handler with an existing context - handler, _ := setup(t) - handler.context = "test" - - // And a context with environment variables that override defaults - handler.config.Contexts = map[string]*v1alpha1.Context{ - "test": { - Environment: map[string]string{ - "SHARED_VAR": "context_value", - }, - }, - } - - // And default context with the same environment variable - handler.defaultContextConfig = v1alpha1.Context{ - Environment: map[string]string{ - "SHARED_VAR": "default_value", - }, - } - - // When getting the config - config := handler.GetConfig() - - // Then the context value should override the default - if config.Environment["SHARED_VAR"] != "context_value" { - t.Errorf("Expected SHARED_VAR to be 'context_value', got '%s'", config.Environment["SHARED_VAR"]) - } - }) -} - -// TestGetValueByPath tests the getValueByPath function -func Test_getValueByPath(t *testing.T) { - t.Run("EmptyPathKeys", func(t *testing.T) { - // Given an empty pathKeys slice for value lookup - var current any - pathKeys := []string{} - - // When calling getValueByPath with empty pathKeys - value := getValueByPath(current, pathKeys) - - // Then nil should be returned as the path is invalid - if value != nil { - t.Errorf("Expected value to be nil, got %v", value) - } - }) - - t.Run("InvalidCurrentValue", func(t *testing.T) { - // Given a nil current value and a valid path key - var current any = nil - pathKeys := []string{"key"} - - // When calling getValueByPath with nil current value - value := getValueByPath(current, pathKeys) - - // Then nil should be returned as the current value is invalid - if value != nil { - t.Errorf("Expected value to be nil, got %v", value) - } - }) - - t.Run("MapKeyTypeMismatch", func(t *testing.T) { - // Given a map with int keys but attempting to access with a string key - current := map[int]string{1: "one", 2: "two"} - pathKeys := []string{"1"} - - // When calling getValueByPath with mismatched key type - value := getValueByPath(current, pathKeys) - - // Then nil should be returned due to key type mismatch - if value != nil { - t.Errorf("Expected value to be nil, got %v", value) - } - }) - - t.Run("MapSuccess", func(t *testing.T) { - // Given a map with a string key and corresponding value - current := map[string]string{"key": "testValue"} - pathKeys := []string{"key"} - - // When calling getValueByPath with a valid key - value := getValueByPath(current, pathKeys) - - // Then the corresponding value should be returned successfully - if value == nil { - t.Errorf("Expected value to be 'testValue', got nil") - } - expectedValue := "testValue" - if value != expectedValue { - t.Errorf("Expected value '%s', got '%v'", expectedValue, value) - } - }) - - t.Run("CannotSetField", func(t *testing.T) { - // Given a struct with an unexported field that cannot be set - type TestStruct struct { - unexportedField string `yaml:"unexportedfield"` - } - testStruct := &TestStruct{} - currValue := reflect.ValueOf(testStruct) - pathKeys := []string{"unexportedfield"} - value := "testValue" - fullPath := "unexportedfield" - - // When attempting to set a value on the unexported field - err := setValueByPath(currValue, pathKeys, value, fullPath) - - // Then an error should be returned indicating the field cannot be set - expectedErr := "cannot set field" - if err == nil || err.Error() != expectedErr { - t.Errorf("Expected error '%s', got '%v'", expectedErr, err) - } - }) - - t.Run("RecursiveFailure", func(t *testing.T) { - // Given a nested map structure without the target field - level3Map := map[string]any{} - level2Map := map[string]any{"level3": level3Map} - level1Map := map[string]any{"level2": level2Map} - testMap := map[string]any{"level1": level1Map} - currValue := reflect.ValueOf(testMap) - pathKeys := []string{"level1", "level2", "nonexistentfield"} - value := "newValue" - fullPath := "level1.level2.nonexistentfield" - - // When attempting to set a value at a non-existent nested path - err := setValueByPath(currValue, pathKeys, value, fullPath) - - // Then an error should be returned indicating the invalid path - expectedErr := "Invalid path: level1.level2.nonexistentfield" - if err == nil || err.Error() != expectedErr { - t.Errorf("Expected error '%s', got '%v'", expectedErr, err) - } - }) - - t.Run("AssignValueTypeMismatch", func(t *testing.T) { - // Given a struct with an int field that cannot accept a string slice - type TestStruct struct { - IntField int `yaml:"intfield"` - } - testStruct := &TestStruct{} - currValue := reflect.ValueOf(testStruct) - pathKeys := []string{"intfield"} - value := []string{"incompatibleType"} // A slice, which is incompatible with int - fullPath := "intfield" - - // When attempting to assign an incompatible value type - err := setValueByPath(currValue, pathKeys, value, fullPath) - - // Then an error should be returned indicating the type mismatch - expectedErr := "cannot assign value of type []string to field of type int" - if err == nil || err.Error() != expectedErr { - t.Errorf("Expected error '%s', got '%v'", expectedErr, err) - } - }) - - t.Run("AssignPointerValueTypeMismatch", func(t *testing.T) { - // Given a struct with a pointer field that cannot accept a string slice - type TestStruct struct { - IntPtrField *int `yaml:"intptrfield"` - } - testStruct := &TestStruct{} - currValue := reflect.ValueOf(testStruct) - pathKeys := []string{"intptrfield"} - value := []string{"incompatibleType"} // A slice, which is incompatible with *int - fullPath := "intptrfield" - - // When attempting to assign an incompatible value type to a pointer field - err := setValueByPath(currValue, pathKeys, value, fullPath) - - // Then an error should be returned indicating the pointer type mismatch - expectedErr := "cannot assign value of type []string to field of type *int" - if err == nil || err.Error() != expectedErr { - t.Errorf("Expected error '%s', got '%v'", expectedErr, err) - } - }) - - t.Run("AssignNonPointerField", func(t *testing.T) { - // Given a struct with a string field that can be directly assigned - type TestStruct struct { - StringField string `yaml:"stringfield"` - } - testStruct := &TestStruct{} - currValue := reflect.ValueOf(testStruct) - pathKeys := []string{"stringfield"} - value := "testValue" // Directly assignable to string - fullPath := "stringfield" - - // When assigning a compatible value to the field - err := setValueByPath(currValue, pathKeys, value, fullPath) - - // Then the field should be set without error - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if testStruct.StringField != "testValue" { - t.Errorf("Expected StringField to be 'testValue', got '%v'", testStruct.StringField) - } - }) - - t.Run("AssignConvertibleType", func(t *testing.T) { - // Given a struct with an int field that can accept a convertible float value - type TestStruct struct { - IntField int `yaml:"intfield"` - } - testStruct := &TestStruct{} - currValue := reflect.ValueOf(testStruct) - pathKeys := []string{"intfield"} - value := 42.0 // A float64, which is convertible to int - fullPath := "intfield" - - // When assigning a value that can be converted to the field's type - err := setValueByPath(currValue, pathKeys, value, fullPath) - - // Then the field should be set without error - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if testStruct.IntField != 42 { - t.Errorf("Expected IntField to be 42, got '%v'", testStruct.IntField) - } - }) -} - -func Test_parsePath(t *testing.T) { - t.Run("EmptyPath", func(t *testing.T) { - // Given an empty path string to parse - path := "" - - // When calling parsePath with the empty string - pathKeys := parsePath(path) - - // Then an empty slice should be returned - if len(pathKeys) != 0 { - t.Errorf("Expected pathKeys to be empty, got %v", pathKeys) - } - }) - - t.Run("SingleKey", func(t *testing.T) { - // Given a path with a single key - path := "key" - - // When calling parsePath with a single key - pathKeys := parsePath(path) - - // Then a slice with only that key should be returned - expected := []string{"key"} - if !reflect.DeepEqual(pathKeys, expected) { - t.Errorf("Expected pathKeys to be %v, got %v", expected, pathKeys) - } - }) - - t.Run("MultipleKeys", func(t *testing.T) { - // Given a path with multiple keys separated by dots - path := "key1.key2.key3" - - // When calling parsePath with dot notation - pathKeys := parsePath(path) - - // Then a slice containing all the keys should be returned - expected := []string{"key1", "key2", "key3"} - if !reflect.DeepEqual(pathKeys, expected) { - t.Errorf("Expected pathKeys to be %v, got %v", expected, pathKeys) - } - }) - - t.Run("KeysWithBrackets", func(t *testing.T) { - // Given a path with keys using bracket notation - path := "key1[key2][key3]" - - // When calling parsePath with bracket notation - pathKeys := parsePath(path) - - // Then a slice containing all the keys without brackets should be returned - expected := []string{"key1", "key2", "key3"} - if !reflect.DeepEqual(pathKeys, expected) { - t.Errorf("Expected pathKeys to be %v, got %v", expected, pathKeys) - } - }) - - t.Run("MixedDotAndBracketNotation", func(t *testing.T) { - // Given a path with mixed dot and bracket notation - path := "key1.key2[key3].key4[key5]" - - // When calling parsePath with mixed notation - pathKeys := parsePath(path) - - // Then a slice with all keys regardless of notation should be returned - expected := []string{"key1", "key2", "key3", "key4", "key5"} - if !reflect.DeepEqual(pathKeys, expected) { - t.Errorf("Expected pathKeys to be %v, got %v", expected, pathKeys) - } - }) - - t.Run("DotInsideBrackets", func(t *testing.T) { - // Given a path with a dot inside bracket notation - path := "key1[key2.key3]" - - // When calling parsePath with a dot inside brackets - pathKeys := parsePath(path) - - // Then the dot inside brackets should be treated as part of the key - expected := []string{"key1", "key2.key3"} - if !reflect.DeepEqual(pathKeys, expected) { - t.Errorf("Expected pathKeys to be %v, got %v", expected, pathKeys) - } - }) -} - -func Test_assignValue(t *testing.T) { - t.Run("CannotSetField", func(t *testing.T) { - // Given an unexported field that cannot be set - var unexportedField struct { - unexported int - } - fieldValue := reflect.ValueOf(&unexportedField).Elem().Field(0) - - // When attempting to assign a value to it - _, err := assignValue(fieldValue, 10) - - // Then an error should be returned - if err == nil { - t.Errorf("Expected an error for non-settable field, got nil") - } - expectedError := "cannot set field" - if err.Error() != expectedError { - t.Errorf("Expected error %q, got %q", expectedError, err.Error()) - } - }) - - t.Run("PointerTypeMismatchNonConvertible", func(t *testing.T) { - // Given a pointer field of type *int - var field *int - fieldValue := reflect.ValueOf(&field).Elem() - - // When attempting to assign a string value to it - value := "not an int" - _, err := assignValue(fieldValue, value) - - // Then an error should be returned indicating type mismatch - if err == nil { - t.Errorf("Expected an error for pointer type mismatch, got nil") - } - expectedError := "cannot assign value of type string to field of type *int" - if err.Error() != expectedError { - t.Errorf("Expected error %q, got %q", expectedError, err.Error()) - } - }) - - t.Run("ValueTypeMismatchNonConvertible", func(t *testing.T) { - // Given a field of type int - var field int - fieldValue := reflect.ValueOf(&field).Elem() - - // When attempting to assign a non-convertible string value to it - value := "not convertible to int" - _, err := assignValue(fieldValue, value) - - // Then an error should be returned indicating type mismatch - if err == nil { - t.Errorf("Expected an error for non-convertible type mismatch, got nil") - } - expectedError := "cannot assign value of type string to field of type int" - if err.Error() != expectedError { - t.Errorf("Expected error %q, got %q", expectedError, err.Error()) - } - }) -} - -func Test_convertValue(t *testing.T) { - t.Run("ConvertStringToBool", func(t *testing.T) { - // Given a string value that can be converted to bool - value := "true" - targetType := reflect.TypeOf(true) - - // When converting the value - result, err := convertValue(value, targetType) - - // Then no error should be returned - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - // And the result should be a bool - if result != true { - t.Errorf("Expected true, got %v", result) - } - }) - - t.Run("ConvertStringToInt", func(t *testing.T) { - // Given a string value that can be converted to int - value := "42" - targetType := reflect.TypeOf(int(0)) - - // When converting the value - result, err := convertValue(value, targetType) - - // Then no error should be returned - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - // And the result should be an int - if result != 42 { - t.Errorf("Expected 42, got %v", result) - } - }) - - t.Run("ConvertStringToFloat", func(t *testing.T) { - // Given a string value that can be converted to float - value := "3.14" - targetType := reflect.TypeOf(float64(0)) - - // When converting the value - result, err := convertValue(value, targetType) - - // Then no error should be returned - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - // And the result should be a float - if result != 3.14 { - t.Errorf("Expected 3.14, got %v", result) - } - }) - - t.Run("ConvertStringToPointer", func(t *testing.T) { - // Given a string value that can be converted to a pointer type - value := "42" - targetType := reflect.TypeOf((*int)(nil)) - - // When converting the value - result, err := convertValue(value, targetType) - - // Then no error should be returned - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - // And the result should be a pointer to int - if ptr, ok := result.(*int); !ok || *ptr != 42 { - t.Errorf("Expected *int(42), got %v", result) - } - }) - - t.Run("UnsupportedType", func(t *testing.T) { - // Given a string value and an unsupported target type - value := "test" - targetType := reflect.TypeOf([]string{}) - - // When converting the value - _, err := convertValue(value, targetType) - - // Then an error should be returned - if err == nil { - t.Fatal("Expected error for unsupported type") - } - - // And the error message should indicate the unsupported type - expectedErr := "unsupported type conversion from string to []string" - if err.Error() != expectedErr { - t.Errorf("Expected error '%s', got '%s'", expectedErr, err.Error()) - } - }) - - t.Run("InvalidNumericValue", func(t *testing.T) { - // Given an invalid numeric string value - value := "not a number" - targetType := reflect.TypeOf(int(0)) - - // When converting the value - _, err := convertValue(value, targetType) - - // Then an error should be returned - if err == nil { - t.Fatal("Expected error for invalid numeric value") - } - }) - - t.Run("UintTypes", func(t *testing.T) { - // Given a string value and uint target types - value := "42" - targetTypes := []reflect.Type{ - reflect.TypeOf(uint(0)), - reflect.TypeOf(uint8(0)), - reflect.TypeOf(uint16(0)), - reflect.TypeOf(uint32(0)), - reflect.TypeOf(uint64(0)), - } - - // When converting the value to each type - for _, targetType := range targetTypes { - result, err := convertValue(value, targetType) - - // Then no error should be returned - if err != nil { - t.Fatalf("convertValue() unexpected error for %v: %v", targetType, err) - } - - // And the value should be correctly converted - switch targetType.Kind() { - case reflect.Uint: - if result != uint(42) { - t.Errorf("convertValue() = %v, want %v for %v", result, uint(42), targetType) - } - case reflect.Uint8: - if result != uint8(42) { - t.Errorf("convertValue() = %v, want %v for %v", result, uint8(42), targetType) - } - case reflect.Uint16: - if result != uint16(42) { - t.Errorf("convertValue() = %v, want %v for %v", result, uint16(42), targetType) - } - case reflect.Uint32: - if result != uint32(42) { - t.Errorf("convertValue() = %v, want %v for %v", result, uint32(42), targetType) - } - case reflect.Uint64: - if result != uint64(42) { - t.Errorf("convertValue() = %v, want %v for %v", result, uint64(42), targetType) - } - } - } - }) - - t.Run("IntTypes", func(t *testing.T) { - // Given a string value and int target types - value := "42" - targetTypes := []reflect.Type{ - reflect.TypeOf(int8(0)), - reflect.TypeOf(int16(0)), - reflect.TypeOf(int32(0)), - reflect.TypeOf(int64(0)), - } - - // When converting the value to each type - for _, targetType := range targetTypes { - result, err := convertValue(value, targetType) - - // Then no error should be returned - if err != nil { - t.Fatalf("convertValue() unexpected error for %v: %v", targetType, err) - } - - // And the value should be correctly converted - switch targetType.Kind() { - case reflect.Int8: - if result != int8(42) { - t.Errorf("convertValue() = %v, want %v for %v", result, int8(42), targetType) - } - case reflect.Int16: - if result != int16(42) { - t.Errorf("convertValue() = %v, want %v for %v", result, int16(42), targetType) - } - case reflect.Int32: - if result != int32(42) { - t.Errorf("convertValue() = %v, want %v for %v", result, int32(42), targetType) - } - case reflect.Int64: - if result != int64(42) { - t.Errorf("convertValue() = %v, want %v for %v", result, int64(42), targetType) - } - } - } - }) - - t.Run("Float32", func(t *testing.T) { - // Given a string value and float32 target type - value := "3.14" - targetType := reflect.TypeOf(float32(0)) - - // When converting the value - result, err := convertValue(value, targetType) - - // Then no error should be returned - if err != nil { - t.Fatalf("convertValue() unexpected error: %v", err) - } - - // And the value should be correctly converted - if result != float32(3.14) { - t.Errorf("convertValue() = %v, want %v", result, float32(3.14)) - } - }) - - t.Run("StringToFloatOverflow", func(t *testing.T) { - // Given a string value that would overflow float32 - value := "3.4028236e+38" - targetType := reflect.TypeOf(float32(0)) - - // When converting the value - _, err := convertValue(value, targetType) - - // Then an error should be returned - if err == nil { - t.Fatal("Expected error for float overflow") - } - - // And the error message should indicate overflow - if !strings.Contains(err.Error(), "float overflow") { - t.Errorf("Expected error containing 'float overflow', got '%s'", err.Error()) - } - }) -} - -func TestYamlConfigHandler_SetDefault(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - handler.shims = mocks.Shims - return handler, mocks - } - - t.Run("SetDefaultWithExistingContext", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - defaultContext := v1alpha1.Context{ - Environment: map[string]string{ - "ENV_VAR": "value", - }, - } - - // And a context is set - handler.Set("context", "local") - - // When setting the default context - err := handler.SetDefault(defaultContext) - - // Then no error should be returned - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - // And the default context should be set correctly - if handler.defaultContextConfig.Environment["ENV_VAR"] != "value" { - t.Errorf("SetDefault() = %v, expected %v", handler.defaultContextConfig.Environment["ENV_VAR"], "value") - } - }) - - t.Run("SetDefaultWithNoContext", func(t *testing.T) { - // Given a handler with no context set - handler, _ := setup(t) - handler.context = "" - defaultContext := v1alpha1.Context{ - Environment: map[string]string{ - "ENV_VAR": "value", - }, - } - - // When setting the default context - err := handler.SetDefault(defaultContext) - - // Then no error should be returned - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - // And the default context should be set correctly - if handler.defaultContextConfig.Environment["ENV_VAR"] != "value" { - t.Errorf("SetDefault() = %v, expected %v", handler.defaultContextConfig.Environment["ENV_VAR"], "value") - } - }) - - t.Run("SetDefaultUsedInSubsequentOperations", func(t *testing.T) { - // Given a handler with an existing context - handler, _ := setup(t) - handler.context = "existing-context" - handler.config.Contexts = map[string]*v1alpha1.Context{ - "existing-context": {}, - } - - // And a default context configuration - defaultConf := v1alpha1.Context{ - Environment: map[string]string{"DEFAULT_VAR": "default_val"}, - } - - // When setting the default context - err := handler.SetDefault(defaultConf) - - // Then no error should be returned - if err != nil { - t.Fatalf("SetDefault() unexpected error: %v", err) - } - - // And the default context should be set correctly - if handler.defaultContextConfig.Environment == nil || handler.defaultContextConfig.Environment["DEFAULT_VAR"] != "default_val" { - t.Errorf("Expected defaultContextConfig environment to be %v, got %v", defaultConf.Environment, handler.defaultContextConfig.Environment) - } - - // And the existing context should not be modified - if handler.config.Contexts["existing-context"] == nil { - t.Errorf("SetDefault incorrectly overwrote existing context config") - } - }) - - t.Run("SetDefaultMergesWithExistingContext", func(t *testing.T) { - // Given a handler with an existing context containing some values - handler, _ := setup(t) - handler.context = "test" - handler.config.Contexts = map[string]*v1alpha1.Context{ - "test": { - ID: ptrString("existing-id"), - VM: &vm.VMConfig{ - Driver: ptrString("docker-desktop"), - }, - Environment: map[string]string{ - "EXISTING_VAR": "existing_value", - "OVERRIDE_VAR": "context_value", - }, - }, - } - - // And a default context with overlapping and additional values - defaultContext := v1alpha1.Context{ - VM: &vm.VMConfig{ - CPU: ptrInt(4), - }, - Environment: map[string]string{ - "DEFAULT_VAR": "default_value", - "OVERRIDE_VAR": "default_value", - }, - } - - // When setting the default context - err := handler.SetDefault(defaultContext) - - // Then no error should be returned - if err != nil { - t.Fatalf("SetDefault() unexpected error: %v", err) - } - - // And the context should merge defaults with existing values - ctx := handler.config.Contexts["test"] - if ctx == nil { - t.Fatal("Context was removed during SetDefault") - } - - // Existing values should be preserved - if ctx.ID == nil || *ctx.ID != "existing-id" { - t.Errorf("Expected ID to be preserved as 'existing-id', got %v", ctx.ID) - } - if ctx.VM.Driver == nil || *ctx.VM.Driver != "docker-desktop" { - t.Errorf("Expected VM driver to be preserved as 'docker-desktop', got %v", ctx.VM.Driver) - } - if ctx.Environment["EXISTING_VAR"] != "existing_value" { - t.Errorf("Expected EXISTING_VAR to be preserved as 'existing_value', got '%s'", ctx.Environment["EXISTING_VAR"]) - } - if ctx.Environment["OVERRIDE_VAR"] != "context_value" { - t.Errorf("Expected OVERRIDE_VAR to keep context value 'context_value', got '%s'", ctx.Environment["OVERRIDE_VAR"]) - } - - // Default values should be added where not present - if ctx.VM.CPU == nil || *ctx.VM.CPU != 4 { - t.Errorf("Expected VM CPU to be added from default as 4, got %v", ctx.VM.CPU) - } - if ctx.Environment["DEFAULT_VAR"] != "default_value" { - t.Errorf("Expected DEFAULT_VAR to be added from default as 'default_value', got '%s'", ctx.Environment["DEFAULT_VAR"]) - } - }) - - t.Run("SetDefaultMergesComplexNestedStructures", func(t *testing.T) { - // Given a handler with an existing context containing some values - handler, _ := setup(t) - handler.context = "test" - handler.config.Contexts = map[string]*v1alpha1.Context{ - "test": { - ID: ptrString("existing-id"), - Environment: map[string]string{ - "EXISTING_VAR": "existing_value", - }, - }, - } - - // And a default context with additional values - defaultContext := v1alpha1.Context{ - Environment: map[string]string{ - "DEFAULT_VAR": "default_value", - }, - } - - // When setting the default context - err := handler.SetDefault(defaultContext) - - // Then no error should be returned - if err != nil { - t.Fatalf("SetDefault() unexpected error: %v", err) - } - - // And the context should have both existing and default values - ctx := handler.config.Contexts["test"] - if ctx == nil { - t.Fatal("Context was removed during SetDefault") - } - - // Existing values should be preserved - if ctx.ID == nil || *ctx.ID != "existing-id" { - t.Errorf("Expected ID to be preserved as 'existing-id', got %v", ctx.ID) - } - if ctx.Environment["EXISTING_VAR"] != "existing_value" { - t.Errorf("Expected EXISTING_VAR to be preserved as 'existing_value', got '%s'", ctx.Environment["EXISTING_VAR"]) - } - - // Default values should be added where not present - if ctx.Environment["DEFAULT_VAR"] != "default_value" { - t.Errorf("Expected DEFAULT_VAR to be added from default as 'default_value', got '%s'", ctx.Environment["DEFAULT_VAR"]) - } - }) - - t.Run("SetDefaultWithNilContextsMap", func(t *testing.T) { - // Given a handler with a nil contexts map - handler, _ := setup(t) - handler.context = "test" - handler.config.Contexts = nil - - // And a default context - defaultContext := v1alpha1.Context{ - Environment: map[string]string{ - "DEFAULT_VAR": "default_value", - }, - } - - // When setting the default context - err := handler.SetDefault(defaultContext) - - // Then no error should be returned - if err != nil { - t.Fatalf("SetDefault() unexpected error: %v", err) - } - - // And the contexts map should be created with the default - if handler.config.Contexts == nil { - t.Fatal("Expected contexts map to be created") - } - - ctx := handler.config.Contexts["test"] - if ctx == nil { - t.Fatal("Expected test context to be created") - } - if ctx.Environment["DEFAULT_VAR"] != "default_value" { - t.Errorf("Expected DEFAULT_VAR to be 'default_value', got '%s'", ctx.Environment["DEFAULT_VAR"]) - } - }) -} - -func TestYamlConfigHandler_SetContextValue(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - handler.shims = mocks.Shims - handler.path = filepath.Join(t.TempDir(), "config.yaml") - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - handler.context = "test" - - // And a context with an empty environment map - actualContext := handler.GetContext() - handler.config.Contexts = map[string]*v1alpha1.Context{ - actualContext: {}, - } - - // When setting a value in the context environment - err := handler.SetContextValue("environment.TEST_VAR", "test_value") - - // Then no error should be returned - if err != nil { - t.Fatalf("SetContextValue() unexpected error: %v", err) - } - - // And the value should be correctly set in the context - expected := "test_value" - if val := handler.config.Contexts[actualContext].Environment["TEST_VAR"]; val != expected { - t.Errorf("SetContextValue() did not correctly set value, expected %s, got %s", expected, val) - } - }) - - t.Run("EmptyPath", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - - // When attempting to set a value with an empty path - err := handler.SetContextValue("", "test_value") - - // Then an error should be returned - if err == nil { - t.Errorf("SetContextValue() with empty path did not return an error") - } - - // And the error message should be as expected - expectedErr := "path cannot be empty" - if err.Error() != expectedErr { - t.Errorf("Expected error message '%s', got '%s'", expectedErr, err.Error()) - } - }) - - t.Run("SetFails", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - handler.context = "test" - - // When attempting to set a value with an invalid path - err := handler.SetContextValue("invalid..path", "test_value") - - // Then an error should be returned - if err == nil { - t.Errorf("SetContextValue() with invalid path did not return an error") - } - }) - - t.Run("ConvertStringToBool", func(t *testing.T) { - handler, _ := setup(t) - handler.context = "default" - handler.config.Contexts = map[string]*v1alpha1.Context{ - "default": {}, - } - - // Set initial bool value - if err := handler.SetContextValue("environment.BOOL_VAR", "true"); err != nil { - t.Fatalf("Failed to set initial bool value: %v", err) - } - - // Override with string "false" - if err := handler.SetContextValue("environment.BOOL_VAR", "false"); err != nil { - t.Fatalf("Failed to set string bool value: %v", err) - } - - val := handler.GetString("environment.BOOL_VAR") - if val != "false" { - t.Errorf("Expected false, got %v", val) - } - }) - - t.Run("ConvertStringToInt", func(t *testing.T) { - handler, _ := setup(t) - handler.context = "default" - handler.config.Contexts = map[string]*v1alpha1.Context{ - "default": {}, - } - - // Set initial int value - if err := handler.SetContextValue("environment.INT_VAR", "42"); err != nil { - t.Fatalf("Failed to set initial int value: %v", err) - } - - // Override with string "100" - if err := handler.SetContextValue("environment.INT_VAR", "100"); err != nil { - t.Fatalf("Failed to set string int value: %v", err) - } - - val := handler.GetString("environment.INT_VAR") - if val != "100" { - t.Errorf("Expected 100, got %v", val) - } - }) - - t.Run("ConvertStringToFloat", func(t *testing.T) { - handler, _ := setup(t) - handler.context = "default" - handler.config.Contexts = map[string]*v1alpha1.Context{ - "default": {}, - } - - // Set initial float value - if err := handler.SetContextValue("environment.FLOAT_VAR", "3.14"); err != nil { - t.Fatalf("Failed to set initial float value: %v", err) - } - - // Override with string "6.28" - if err := handler.SetContextValue("environment.FLOAT_VAR", "6.28"); err != nil { - t.Fatalf("Failed to set string float value: %v", err) - } - - val := handler.GetString("environment.FLOAT_VAR") - if val != "6.28" { - t.Errorf("Expected 6.28, got %v", val) - } - }) - - t.Run("ConvertStringToBoolPointer", func(t *testing.T) { - // Given a handler with a default context - handler, _ := setup(t) - handler.context = "default" - handler.config.Contexts = map[string]*v1alpha1.Context{ - "default": {}, - } - - // When setting a string "false" to a bool pointer field (dns.enabled) - if err := handler.SetContextValue("dns.enabled", "false"); err != nil { - t.Fatalf("Failed to set dns.enabled=false from string: %v", err) - } - - // Then the value should be correctly set as a boolean - config := handler.GetConfig() - if config.DNS == nil || config.DNS.Enabled == nil || *config.DNS.Enabled != false { - t.Errorf("Expected dns.enabled to be false, got %v", config.DNS.Enabled) - } - - // And when setting "true" as well - if err := handler.SetContextValue("dns.enabled", "true"); err != nil { - t.Fatalf("Failed to set dns.enabled=true from string: %v", err) - } - - config = handler.GetConfig() - if config.DNS == nil || config.DNS.Enabled == nil || *config.DNS.Enabled != true { - t.Errorf("Expected dns.enabled to be true, got %v", config.DNS.Enabled) - } - }) - - t.Run("SchemaRoutingAndInitialization", func(t *testing.T) { - handler, _ := setup(t) - handler.context = "test" - - // Test invalid path formats - err := handler.SetContextValue("..invalid", "value") - if err == nil { - t.Error("Expected error for invalid path") - } - - // Test static schema routing (goes to context config) - err = handler.SetContextValue("environment.STATIC_VAR", "static_value") - if err != nil { - t.Fatalf("Failed to set static schema value: %v", err) - } - if handler.config.Contexts["test"].Environment["STATIC_VAR"] != "static_value" { - t.Error("Static value should be in context config") - } - - // Test dynamic schema routing (goes to contextValues) - err = handler.SetContextValue("dynamic_key", "dynamic_value") - if err != nil { - t.Fatalf("Failed to set dynamic schema value: %v", err) - } - if handler.contextValues["dynamic_key"] != "dynamic_value" { - t.Error("Dynamic value should be in contextValues") - } - - // Test initialization when not loaded - handler.loaded = false - handler.contextValues = nil - err = handler.SetContextValue("not_loaded_key", "not_loaded_value") - if err != nil { - t.Fatalf("Failed to set value when not loaded: %v", err) - } - if handler.contextValues["not_loaded_key"] != "not_loaded_value" { - t.Error("contextValues should be initialized even when not loaded") - } - }) - - t.Run("SchemaAwareTypeConversion", func(t *testing.T) { - handler, _ := setup(t) - handler.context = "test" - handler.loaded = true - - // Set up schema validator with type definitions - handler.schemaValidator = &SchemaValidator{ - Schema: map[string]any{ - "properties": map[string]any{ - "dev": map[string]any{ - "type": "boolean", - }, - "port": map[string]any{ - "type": "integer", - }, - "ratio": map[string]any{ - "type": "number", - }, - "name": map[string]any{ - "type": "string", - }, - }, - }, - } - - // Test boolean conversion - err := handler.SetContextValue("dev", "true") - if err != nil { - t.Fatalf("Failed to set boolean value: %v", err) - } - if handler.contextValues["dev"] != true { - t.Errorf("Expected boolean true, got %v (%T)", handler.contextValues["dev"], handler.contextValues["dev"]) - } - - // Test integer conversion - err = handler.SetContextValue("port", "8080") - if err != nil { - t.Fatalf("Failed to set integer value: %v", err) - } - if handler.contextValues["port"] != 8080 { - t.Errorf("Expected integer 8080, got %v (%T)", handler.contextValues["port"], handler.contextValues["port"]) - } - - // Test number conversion - err = handler.SetContextValue("ratio", "3.14") - if err != nil { - t.Fatalf("Failed to set number value: %v", err) - } - if handler.contextValues["ratio"] != 3.14 { - t.Errorf("Expected number 3.14, got %v (%T)", handler.contextValues["ratio"], handler.contextValues["ratio"]) - } - - // Test string conversion (should remain string) - err = handler.SetContextValue("name", "test") - if err != nil { - t.Fatalf("Failed to set string value: %v", err) - } - if handler.contextValues["name"] != "test" { - t.Errorf("Expected string 'test', got %v (%T)", handler.contextValues["name"], handler.contextValues["name"]) - } - }) - - t.Run("FallbackPatternConversion", func(t *testing.T) { - handler, _ := setup(t) - handler.context = "test" - handler.loaded = true - - // No schema validator - should use pattern matching - - // Test boolean pattern matching - err := handler.SetContextValue("enabled", "true") - if err != nil { - t.Fatalf("Failed to set boolean value: %v", err) - } - if handler.contextValues["enabled"] != true { - t.Errorf("Expected boolean true, got %v (%T)", handler.contextValues["enabled"], handler.contextValues["enabled"]) - } - - // Test integer pattern matching - err = handler.SetContextValue("count", "42") - if err != nil { - t.Fatalf("Failed to set integer value: %v", err) - } - if handler.contextValues["count"] != 42 { - t.Errorf("Expected integer 42, got %v (%T)", handler.contextValues["count"], handler.contextValues["count"]) - } - - // Test float pattern matching - err = handler.SetContextValue("rate", "2.5") - if err != nil { - t.Fatalf("Failed to set float value: %v", err) - } - if handler.contextValues["rate"] != 2.5 { - t.Errorf("Expected float 2.5, got %v (%T)", handler.contextValues["rate"], handler.contextValues["rate"]) - } - }) - - t.Run("SchemaConversionFailure", func(t *testing.T) { - handler, _ := setup(t) - handler.context = "test" - handler.loaded = true - - // Set up schema validator with boolean type and validation support - mockShell := handler.shell - mockValidator := NewSchemaValidator(mockShell) - mockValidator.Schema = map[string]any{ - "properties": map[string]any{ - "dev": map[string]any{ - "type": "boolean", - }, - }, - } - handler.schemaValidator = mockValidator - - // Test invalid boolean value - should now fail validation - err := handler.SetContextValue("dev", "invalid") - if err == nil { - t.Fatal("Expected validation error for invalid boolean value, got nil") - } - if !strings.Contains(err.Error(), "validation failed") && !strings.Contains(err.Error(), "type mismatch") { - t.Errorf("Expected validation error, got: %v", err) - } - }) -} - -func TestYamlConfigHandler_convertStringValue(t *testing.T) { - setup := func(t *testing.T) *YamlConfigHandler { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - handler.shims = mocks.Shims - return handler - } - - t.Run("NonStringValue", func(t *testing.T) { - handler := setup(t) - - // Non-string values should be returned as-is - result := handler.convertStringValue(42) - if result != 42 { - t.Errorf("Expected 42, got %v", result) - } - - result = handler.convertStringValue(true) - if result != true { - t.Errorf("Expected true, got %v", result) - } - }) - - t.Run("SchemaAwareConversion", func(t *testing.T) { - handler := setup(t) - - // Set up schema validator - handler.schemaValidator = &SchemaValidator{ - Schema: map[string]any{ - "properties": map[string]any{ - "enabled": map[string]any{ - "type": "boolean", - }, - "count": map[string]any{ - "type": "integer", - }, - "rate": map[string]any{ - "type": "number", - }, - }, - }, - } - - // Test boolean conversion - result := handler.convertStringValue("true") - if result != true { - t.Errorf("Expected boolean true, got %v (%T)", result, result) - } - - // Test integer conversion - result = handler.convertStringValue("42") - if result != 42 { - t.Errorf("Expected integer 42, got %v (%T)", result, result) - } - - // Test number conversion - result = handler.convertStringValue("3.14") - if result != 3.14 { - t.Errorf("Expected number 3.14, got %v (%T)", result, result) - } - }) - - t.Run("PatternMatchingFallback", func(t *testing.T) { - handler := setup(t) - // No schema validator - should use pattern matching - - // Test boolean pattern - result := handler.convertStringValue("true") - if result != true { - t.Errorf("Expected boolean true, got %v (%T)", result, result) - } - - result = handler.convertStringValue("false") - if result != false { - t.Errorf("Expected boolean false, got %v (%T)", result, result) - } - - // Test integer pattern - result = handler.convertStringValue("123") - if result != 123 { - t.Errorf("Expected integer 123, got %v (%T)", result, result) - } - - // Test float pattern - result = handler.convertStringValue("45.67") - if result != 45.67 { - t.Errorf("Expected float 45.67, got %v (%T)", result, result) - } - - // Test string (no conversion) - result = handler.convertStringValue("hello") - if result != "hello" { - t.Errorf("Expected string 'hello', got %v (%T)", result, result) - } - }) -} - -func TestYamlConfigHandler_getExpectedTypeFromSchema(t *testing.T) { - setup := func(t *testing.T) *YamlConfigHandler { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - handler.shims = mocks.Shims - return handler - } - - t.Run("ValidSchema", func(t *testing.T) { - handler := setup(t) - - handler.schemaValidator = &SchemaValidator{ - Schema: map[string]any{ - "properties": map[string]any{ - "enabled": map[string]any{ - "type": "boolean", - }, - "count": map[string]any{ - "type": "integer", - }, - }, - }, - } - - // Test existing property - result := handler.getExpectedTypeFromSchema("enabled") - if result != "boolean" { - t.Errorf("Expected 'boolean', got '%s'", result) - } - - result = handler.getExpectedTypeFromSchema("count") - if result != "integer" { - t.Errorf("Expected 'integer', got '%s'", result) - } - - // Test non-existing property - result = handler.getExpectedTypeFromSchema("nonexistent") - if result != "" { - t.Errorf("Expected empty string, got '%s'", result) - } - }) - - t.Run("NoSchemaValidator", func(t *testing.T) { - handler := setup(t) - // No schema validator - - result := handler.getExpectedTypeFromSchema("anykey") - if result != "" { - t.Errorf("Expected empty string, got '%s'", result) - } - }) - - t.Run("InvalidSchema", func(t *testing.T) { - handler := setup(t) - - handler.schemaValidator = &SchemaValidator{ - Schema: map[string]any{ - "properties": "invalid", // Should be map[string]any - }, - } - - result := handler.getExpectedTypeFromSchema("anykey") - if result != "" { - t.Errorf("Expected empty string, got '%s'", result) - } - }) -} - -func TestYamlConfigHandler_convertStringToType(t *testing.T) { - setup := func(t *testing.T) *YamlConfigHandler { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - handler.shims = mocks.Shims - return handler - } - - handler := setup(t) - - t.Run("BooleanConversion", func(t *testing.T) { - result := handler.convertStringToType("true", "boolean") - if result != true { - t.Errorf("Expected true, got %v", result) - } - - result = handler.convertStringToType("false", "boolean") - if result != false { - t.Errorf("Expected false, got %v", result) - } - - result = handler.convertStringToType("invalid", "boolean") - if result != nil { - t.Errorf("Expected nil, got %v", result) - } - }) - - t.Run("IntegerConversion", func(t *testing.T) { - result := handler.convertStringToType("42", "integer") - if result != 42 { - t.Errorf("Expected 42, got %v", result) - } - - result = handler.convertStringToType("invalid", "integer") - if result != nil { - t.Errorf("Expected nil, got %v", result) - } - }) - - t.Run("NumberConversion", func(t *testing.T) { - result := handler.convertStringToType("3.14", "number") - if result != 3.14 { - t.Errorf("Expected 3.14, got %v", result) - } - - result = handler.convertStringToType("invalid", "number") - if result != nil { - t.Errorf("Expected nil, got %v", result) - } - }) - - t.Run("StringConversion", func(t *testing.T) { - result := handler.convertStringToType("hello", "string") - if result != "hello" { - t.Errorf("Expected 'hello', got %v", result) - } - }) -} - -func TestYamlConfigHandler_LoadConfigString(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - handler.SetContext("test") - - // And a valid YAML configuration string - yamlContent := ` -version: v1alpha1 -contexts: - test: - environment: - TEST_VAR: test_value` - - // When loading the configuration string - err := handler.LoadConfigString(yamlContent) - - // Then no error should be returned - if err != nil { - t.Fatalf("LoadConfigString() unexpected error: %v", err) - } - - // And the value should be correctly loaded - value := handler.GetString("environment.TEST_VAR") - if value != "test_value" { - t.Errorf("Expected TEST_VAR = 'test_value', got '%s'", value) - } - }) - - t.Run("EmptyContent", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - - // When loading an empty configuration string - err := handler.LoadConfigString("") - - // Then no error should be returned - if err != nil { - t.Fatalf("LoadConfigString() unexpected error: %v", err) - } - }) - - t.Run("InvalidYAML", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - - // And an invalid YAML string - yamlContent := `invalid: yaml: content: [}` - - // When loading the invalid YAML - err := handler.LoadConfigString(yamlContent) - - // Then an error should be returned - if err == nil { - t.Fatal("LoadConfigString() expected error for invalid YAML") - } - - // And the error message should indicate YAML unmarshalling failure - if !strings.Contains(err.Error(), "error unmarshalling yaml") { - t.Errorf("Expected error about invalid YAML, got: %v", err) - } - }) - - t.Run("UnsupportedVersion", func(t *testing.T) { - // Given a handler with a context set - handler, _ := setup(t) - - // And a YAML string with an unsupported version - yamlContent := ` -version: v2alpha1 -contexts: - test: {}` - - // When loading the YAML with unsupported version - err := handler.LoadConfigString(yamlContent) - - // Then an error should be returned - if err == nil { - t.Fatal("LoadConfigString() expected error for unsupported version") - } - - // And the error message should indicate unsupported version - if !strings.Contains(err.Error(), "unsupported config version") { - t.Errorf("Expected error about unsupported version, got: %v", err) - } - }) -} - -func Test_makeAddressable(t *testing.T) { - t.Run("AlreadyAddressable", func(t *testing.T) { - // Given an addressable value - var x int = 42 - v := reflect.ValueOf(&x).Elem() - - // When making it addressable - result := makeAddressable(v) - - // Then the same value should be returned - if result.Interface() != v.Interface() { - t.Errorf("makeAddressable() = %v, want %v", result.Interface(), v.Interface()) - } - }) - - t.Run("NonAddressable", func(t *testing.T) { - // Given a non-addressable value - v := reflect.ValueOf(42) - - // When making it addressable - result := makeAddressable(v) - - // Then a new addressable value should be returned - if !result.CanAddr() { - t.Error("makeAddressable() returned non-addressable value") - } - if result.Interface() != v.Interface() { - t.Errorf("makeAddressable() = %v, want %v", result.Interface(), v.Interface()) - } - }) - - t.Run("NilValue", func(t *testing.T) { - // Given a nil value - var v reflect.Value - - // When making it addressable - result := makeAddressable(v) + // When making it addressable + result := makeAddressable(v) // Then a zero value should be returned if result.IsValid() { @@ -3295,7 +1544,7 @@ func Test_makeAddressable(t *testing.T) { }) } -func TestYamlConfigHandler_ConvertValue(t *testing.T) { +func TestConfigHandler_ConvertValue(t *testing.T) { t.Run("StringToString", func(t *testing.T) { // Given a string value and target type value := "test" @@ -3431,11 +1680,11 @@ func TestYamlConfigHandler_ConvertValue(t *testing.T) { }) } -func TestYamlConfigHandler_Set(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { +func TestConfigHandler_Set(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims if err := handler.Initialize(); err != nil { t.Fatalf("Failed to initialize handler: %v", err) } @@ -3460,7 +1709,7 @@ func TestYamlConfigHandler_Set(t *testing.T) { handler, _ := setup(t) // And a mocked setValueByPath that returns an error - handler.shims.YamlMarshal = func(v any) ([]byte, error) { + handler.(*configHandler).shims.YamlMarshal = func(v any) ([]byte, error) { return nil, fmt.Errorf("mocked error") } @@ -3767,11 +2016,11 @@ func Test_setValueByPath(t *testing.T) { }) } -func TestYamlConfigHandler_GenerateContextID(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { +func TestConfigHandler_GenerateContextID(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims if err := handler.Initialize(); err != nil { t.Fatalf("Failed to initialize handler: %v", err) } @@ -3779,7 +2028,7 @@ func TestYamlConfigHandler_GenerateContextID(t *testing.T) { } t.Run("WhenContextIDExists", func(t *testing.T) { - // Given a set of safe mocks and a YamlConfigHandler + // Given a set of safe mocks and a configHandler handler, _ := setup(t) // And an existing context ID @@ -3801,7 +2050,7 @@ func TestYamlConfigHandler_GenerateContextID(t *testing.T) { }) t.Run("WhenContextIDDoesNotExist", func(t *testing.T) { - // Given a set of safe mocks and a YamlConfigHandler + // Given a set of safe mocks and a configHandler handler, _ := setup(t) // When GenerateContextID is called @@ -3825,11 +2074,11 @@ func TestYamlConfigHandler_GenerateContextID(t *testing.T) { }) t.Run("WhenRandomGenerationFails", func(t *testing.T) { - // Given a set of safe mocks and a YamlConfigHandler + // Given a set of safe mocks and a configHandler handler, _ := setup(t) // And a mocked crypto/rand that fails - handler.shims.CryptoRandRead = func([]byte) (int, error) { + handler.(*configHandler).shims.CryptoRandRead = func([]byte) (int, error) { return 0, fmt.Errorf("mocked crypto/rand error") } @@ -3850,21 +2099,21 @@ func TestYamlConfigHandler_GenerateContextID(t *testing.T) { } // Test specifically for the flag override issue we're experiencing -func TestYamlConfigHandler_FlagOverrideIssue(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { +func TestConfigHandler_FlagOverrideIssue(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) + handler := NewConfigHandler(mocks.Injector) if err := handler.Initialize(); err != nil { t.Fatalf("Failed to initialize handler: %v", err) } - handler.shims = mocks.Shims + handler.(*configHandler).shims = mocks.Shims return handler, mocks } t.Run("FlagValuePreservedAfterSetDefault", func(t *testing.T) { // Given a handler that simulates loading existing config (like init pipeline does) handler, _ := setup(t) - handler.context = "local" + handler.(*configHandler).context = "local" // Simulate existing config with different VM driver existingConfig := `version: v1alpha1 @@ -3923,11 +2172,11 @@ contexts: // LoadContextConfig Tests // ============================================================================= -func TestYamlConfigHandler_LoadContextConfig(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { +func TestConfigHandler_LoadContextConfig(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims if err := handler.Initialize(); err != nil { t.Fatalf("Failed to initialize handler: %v", err) } @@ -3935,7 +2184,7 @@ func TestYamlConfigHandler_LoadContextConfig(t *testing.T) { } t.Run("SuccessWithContextConfig", func(t *testing.T) { - // Given a YamlConfigHandler with existing config + // Given a configHandler with existing config handler, mocks := setup(t) // Load base configuration @@ -3955,7 +2204,7 @@ contexts: } // Override the shim to return the correct context - handler.shims.Getenv = func(key string) string { + handler.(*configHandler).shims.Getenv = func(key string) string { if key == "WINDSOR_CONTEXT" { return "production" } @@ -3982,10 +2231,10 @@ aws: } // Override shims to allow reading the actual context file - handler.shims.Stat = func(name string) (os.FileInfo, error) { + handler.(*configHandler).shims.Stat = func(name string) (os.FileInfo, error) { return os.Stat(name) } - handler.shims.ReadFile = func(filename string) ([]byte, error) { + handler.(*configHandler).shims.ReadFile = func(filename string) ([]byte, error) { return os.ReadFile(filename) } @@ -4013,14 +2262,14 @@ aws: }) t.Run("SuccessWithYmlExtension", func(t *testing.T) { - // Given a YamlConfigHandler + // Given a configHandler handler, mocks := setup(t) if err := handler.SetContext("local"); err != nil { t.Fatalf("Failed to set context: %v", err) } // Override the shim to return the correct context - handler.shims.Getenv = func(key string) string { + handler.(*configHandler).shims.Getenv = func(key string) string { if key == "WINDSOR_CONTEXT" { return "local" } @@ -4044,10 +2293,10 @@ environment: } // Override shims to allow reading the actual context file - handler.shims.Stat = func(name string) (os.FileInfo, error) { + handler.(*configHandler).shims.Stat = func(name string) (os.FileInfo, error) { return os.Stat(name) } - handler.shims.ReadFile = func(filename string) ([]byte, error) { + handler.(*configHandler).shims.ReadFile = func(filename string) ([]byte, error) { return os.ReadFile(filename) } @@ -4069,7 +2318,7 @@ environment: }) t.Run("SuccessWithoutContextConfig", func(t *testing.T) { - // Given a YamlConfigHandler without context-specific config + // Given a configHandler without context-specific config handler, _ := setup(t) if err := handler.SetContext("nonexistent"); err != nil { t.Fatalf("Failed to set context: %v", err) @@ -4085,7 +2334,7 @@ environment: }) t.Run("ErrorReadingContextConfigFile", func(t *testing.T) { - // Given a YamlConfigHandler + // Given a configHandler handler, mocks := setup(t) if err := handler.SetContext("test"); err != nil { t.Fatalf("Failed to set context: %v", err) @@ -4104,7 +2353,7 @@ environment: } // Mock ReadFile to return an error - handler.shims.ReadFile = func(filename string) ([]byte, error) { + handler.(*configHandler).shims.ReadFile = func(filename string) ([]byte, error) { // Normalize path separators for cross-platform compatibility normalizedPath := filepath.ToSlash(filename) if strings.Contains(normalizedPath, "contexts/test/windsor.yaml") { @@ -4129,14 +2378,14 @@ environment: }) t.Run("ErrorUnmarshallingContextConfig", func(t *testing.T) { - // Given a YamlConfigHandler + // Given a configHandler handler, mocks := setup(t) if err := handler.SetContext("test"); err != nil { t.Fatalf("Failed to set context: %v", err) } // Override the shim to return the correct context - handler.shims.Getenv = func(key string) string { + handler.(*configHandler).shims.Getenv = func(key string) string { if key == "WINDSOR_CONTEXT" { return "test" } @@ -4159,10 +2408,10 @@ invalid yaml: [ } // Override shims to allow reading the actual context file - handler.shims.Stat = func(name string) (os.FileInfo, error) { + handler.(*configHandler).shims.Stat = func(name string) (os.FileInfo, error) { return os.Stat(name) } - handler.shims.ReadFile = func(filename string) ([]byte, error) { + handler.(*configHandler).shims.ReadFile = func(filename string) ([]byte, error) { return os.ReadFile(filename) } @@ -4181,9 +2430,9 @@ invalid yaml: [ }) t.Run("ErrorShellNotInitialized", func(t *testing.T) { - // Given a YamlConfigHandler without shell + // Given a configHandler without shell handler, _ := setup(t) - handler.shell = nil + handler.(*configHandler).shell = nil // When LoadContextConfig is called err := handler.LoadContextConfig() @@ -4201,7 +2450,7 @@ invalid yaml: [ }) t.Run("ErrorGettingProjectRoot", func(t *testing.T) { - // Given a YamlConfigHandler with shell that returns error + // Given a configHandler with shell that returns error handler, mocks := setup(t) mocks.Shell.GetProjectRootFunc = func() (string, error) { return "", fmt.Errorf("mocked project root error") @@ -4223,7 +2472,7 @@ invalid yaml: [ }) t.Run("SimulateInitPipelineWorkflow", func(t *testing.T) { - // Given a YamlConfigHandler simulating the exact init pipeline workflow + // Given a configHandler simulating the exact init pipeline workflow handler, mocks := setup(t) tempDir := t.TempDir() @@ -4266,7 +2515,7 @@ invalid yaml: [ // And the context config should be created since context is not defined in root contextConfigPath := filepath.Join(tempDir, "contexts", "local", "windsor.yaml") - if _, err := handler.shims.Stat(contextConfigPath); os.IsNotExist(err) { + if _, err := handler.(*configHandler).shims.Stat(contextConfigPath); os.IsNotExist(err) { t.Errorf("Context config file was not created at %s, this reproduces the user's issue", contextConfigPath) } @@ -4278,7 +2527,7 @@ invalid yaml: [ }) t.Run("ContextNotSetInRootConfigInitially", func(t *testing.T) { - // Given a YamlConfigHandler that mimics the exact init flow + // Given a configHandler that mimics the exact init flow handler, mocks := setup(t) tempDir := t.TempDir() @@ -4297,10 +2546,10 @@ invalid yaml: [ } // Set the context but DON'T call Set() to add context data yet - handler.context = "local" + handler.(*configHandler).context = "local" // Debug: Check state before adding any context data - t.Logf("Config.Contexts before setting any context data: %+v", handler.config.Contexts) + t.Logf("Config.Contexts before setting any context data: %+v", handler.(*configHandler).config.Contexts) // When SaveConfig is called without any context configuration being set err := handler.SaveConfig() @@ -4312,7 +2561,7 @@ invalid yaml: [ // Check if context config was created contextConfigPath := filepath.Join(tempDir, "contexts", "local", "windsor.yaml") - if _, err := handler.shims.Stat(contextConfigPath); os.IsNotExist(err) { + if _, err := handler.(*configHandler).shims.Stat(contextConfigPath); os.IsNotExist(err) { t.Errorf("Context config file was NOT created at %s - this reproduces the user's issue", contextConfigPath) } else { t.Logf("Context config file WAS created at %s", contextConfigPath) @@ -4354,9 +2603,9 @@ invalid yaml: [ } // Debug: Check config state before SaveConfig - t.Logf("Config before SaveConfig: %+v", handler.config) - if handler.config.Contexts != nil { - if ctx, exists := handler.config.Contexts["local"]; exists { + t.Logf("Config before SaveConfig: %+v", handler.(*configHandler).config) + if handler.(*configHandler).config.Contexts != nil { + if ctx, exists := handler.(*configHandler).config.Contexts["local"]; exists { t.Logf("local context exists in config: %+v", ctx) } else { t.Logf("local context does NOT exist in config") @@ -4373,7 +2622,7 @@ invalid yaml: [ // Check if context config file was created contextConfigPath := filepath.Join(tempDir, "contexts", "local", "windsor.yaml") - if _, err := handler.shims.Stat(contextConfigPath); os.IsNotExist(err) { + if _, err := handler.(*configHandler).shims.Stat(contextConfigPath); os.IsNotExist(err) { t.Errorf("Context config file was NOT created at %s - this is the bug!", contextConfigPath) } else { content, _ := os.ReadFile(contextConfigPath) @@ -4387,11 +2636,11 @@ invalid yaml: [ } }) } -func TestYamlConfigHandler_saveContextValues(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { +func TestConfigHandler_saveContextValues(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims if err := handler.Initialize(); err != nil { t.Fatalf("Failed to initialize handler: %v", err) } @@ -4399,7 +2648,7 @@ func TestYamlConfigHandler_saveContextValues(t *testing.T) { } t.Run("Success", func(t *testing.T) { - // Given a YamlConfigHandler with context values + // Given a configHandler with context values handler, mocks := setup(t) tempDir := t.TempDir() @@ -4407,199 +2656,564 @@ func TestYamlConfigHandler_saveContextValues(t *testing.T) { return tempDir, nil } - handler.context = "test" - handler.contextValues = map[string]any{ + handler.(*configHandler).context = "test" + handler.(*configHandler).contextValues = map[string]any{ "database_url": "postgres://localhost:5432/test", "api_key": "secret123", } - // When saveContextValues is called - err := handler.saveContextValues() + // When saveContextValues is called + err := handler.(*configHandler).saveContextValues() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the values.yaml file should be created + valuesPath := filepath.Join(tempDir, "contexts", "test", "values.yaml") + if _, err := handler.(*configHandler).shims.Stat(valuesPath); os.IsNotExist(err) { + t.Fatalf("values.yaml file was not created at %s", valuesPath) + } + }) + + t.Run("GetConfigRootError", func(t *testing.T) { + // Given a configHandler with a shell that returns an error + handler, mocks := setup(t) + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("failed to get project root") + } + + handler.(*configHandler).context = "test" + handler.(*configHandler).contextValues = map[string]any{"key": "value"} + + // When saveContextValues is called + err := handler.(*configHandler).saveContextValues() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + + expectedError := "error getting config root: failed to get project root" + if err.Error() != expectedError { + t.Errorf("Expected error: %s, got: %s", expectedError, err.Error()) + } + }) + + t.Run("MkdirAllError", func(t *testing.T) { + // Given a configHandler with a shims that fails on MkdirAll + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + // Mock MkdirAll to return an error + originalMkdirAll := mocks.Shims.MkdirAll + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return fmt.Errorf("mkdir failed") + } + + handler.(*configHandler).context = "test" + handler.(*configHandler).contextValues = map[string]any{"key": "value"} + + // When saveContextValues is called + err := handler.(*configHandler).saveContextValues() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + + expectedError := "error creating context directory: mkdir failed" + if err.Error() != expectedError { + t.Errorf("Expected error: %s, got: %s", expectedError, err.Error()) + } + + // Restore original function + mocks.Shims.MkdirAll = originalMkdirAll + }) +} + +func TestConfigHandler_ensureValuesYamlLoaded(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + return handler, mocks + } + + t.Run("AlreadyLoaded", func(t *testing.T) { + // Given a handler with contextValues already loaded + handler, _ := setup(t) + handler.(*configHandler).contextValues = map[string]any{"existing": "value"} + + // When ensureValuesYamlLoaded is called + err := handler.(*configHandler).ensureValuesYamlLoaded() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And contextValues should remain unchanged + if handler.(*configHandler).contextValues["existing"] != "value" { + t.Error("contextValues should remain unchanged") + } + }) + + t.Run("ShellNotInitialized", func(t *testing.T) { + // Given a handler with no shell initialized + handler := &configHandler{} + + // When ensureValuesYamlLoaded is called + err := handler.ensureValuesYamlLoaded() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And contextValues should be initialized as empty + if handler.contextValues == nil { + t.Error("contextValues should be initialized") + } + if len(handler.contextValues) != 0 { + t.Errorf("Expected empty contextValues, got: %v", handler.contextValues) + } + }) + + t.Run("ConfigNotLoaded", func(t *testing.T) { + // Given a handler with shell but not loaded + handler, _ := setup(t) + handler.(*configHandler).loaded = false + + // When ensureValuesYamlLoaded is called + err := handler.(*configHandler).ensureValuesYamlLoaded() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And contextValues should be initialized as empty + if handler.(*configHandler).contextValues == nil { + t.Error("contextValues should be initialized") + } + if len(handler.(*configHandler).contextValues) != 0 { + t.Errorf("Expected empty contextValues, got: %v", handler.(*configHandler).contextValues) + } + }) + + t.Run("ErrorGettingProjectRoot", func(t *testing.T) { + // Given a handler with shell returning error on GetProjectRoot + handler, mocks := setup(t) + handler.(*configHandler).loaded = true + handler.(*configHandler).contextValues = nil // Ensure it's not already loaded + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("project root error") + } + + // When ensureValuesYamlLoaded is called + err := handler.(*configHandler).ensureValuesYamlLoaded() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error retrieving project root") { + t.Errorf("Expected 'error retrieving project root', got: %v", err) + } + }) + + t.Run("LoadsSchemaIfNotLoaded", func(t *testing.T) { + // Given a handler with schema validator but no schema loaded + handler, mocks := setup(t) + handler.(*configHandler).loaded = true + handler.(*configHandler).context = "test" + handler.(*configHandler).contextValues = nil + handler.(*configHandler).schemaValidator = NewSchemaValidator(mocks.Shell) + handler.(*configHandler).schemaValidator.Shims = NewShims() // Use real filesystem for schema validator + + // Use real filesystem operations for this test + handler.(*configHandler).shims = NewShims() + + // Create temp directory structure + tmpDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + + // Create schema file + schemaDir := filepath.Join(tmpDir, "contexts", "_template") + if err := os.MkdirAll(schemaDir, 0755); err != nil { + t.Fatalf("Failed to create schema directory: %v", err) + } + schemaPath := filepath.Join(schemaDir, "schema.yaml") + schemaContent := ` +$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + test_key: + type: string +` + if err := os.WriteFile(schemaPath, []byte(schemaContent), 0644); err != nil { + t.Fatalf("Failed to write schema file: %v", err) + } + + // Create context directory but no values.yaml + contextDir := filepath.Join(tmpDir, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + // When ensureValuesYamlLoaded is called + err := handler.(*configHandler).ensureValuesYamlLoaded() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And schema should be loaded + if handler.(*configHandler).schemaValidator.Schema == nil { + t.Error("Schema should be loaded") + } + + // And contextValues should be initialized as empty + if len(handler.(*configHandler).contextValues) != 0 { + t.Errorf("Expected empty contextValues, got: %v", handler.(*configHandler).contextValues) + } + }) + + t.Run("ErrorLoadingSchema", func(t *testing.T) { + // Given a handler with schema validator and malformed schema file + handler, mocks := setup(t) + handler.(*configHandler).loaded = true + handler.(*configHandler).context = "test" + handler.(*configHandler).contextValues = nil + handler.(*configHandler).schemaValidator = NewSchemaValidator(mocks.Shell) + handler.(*configHandler).schemaValidator.Shims = NewShims() // Use real filesystem for schema validator + + // Use real filesystem operations for this test + handler.(*configHandler).shims = NewShims() + + // Create temp directory structure + tmpDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + + // Create malformed schema file + schemaDir := filepath.Join(tmpDir, "contexts", "_template") + if err := os.MkdirAll(schemaDir, 0755); err != nil { + t.Fatalf("Failed to create schema directory: %v", err) + } + schemaPath := filepath.Join(schemaDir, "schema.yaml") + if err := os.WriteFile(schemaPath, []byte("invalid: yaml: content:"), 0644); err != nil { + t.Fatalf("Failed to write schema file: %v", err) + } + + // When ensureValuesYamlLoaded is called + err := handler.(*configHandler).ensureValuesYamlLoaded() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error for malformed schema, got nil") + } + if !strings.Contains(err.Error(), "error loading schema") { + t.Errorf("Expected 'error loading schema', got: %v", err) + } + }) + + t.Run("LoadsValuesYamlSuccessfully", func(t *testing.T) { + // Given a standalone handler with valid values.yaml + tmpDir := t.TempDir() + injector := di.NewInjector() + + mockShell := shell.NewMockShell(injector) + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("shell", mockShell) + + handler := NewConfigHandler(injector) + handler.(*configHandler).shims = NewShims() + handler.(*configHandler).shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test" + } + return "" + } + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize: %v", err) + } + handler.(*configHandler).loaded = true + handler.(*configHandler).context = "test" + handler.(*configHandler).contextValues = nil // Ensure values aren't already loaded + + // Create context directory and values.yaml + contextDir := filepath.Join(tmpDir, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + valuesPath := filepath.Join(contextDir, "values.yaml") + valuesContent := `test_key: test_value +another_key: 123 +` + if err := os.WriteFile(valuesPath, []byte(valuesContent), 0644); err != nil { + t.Fatalf("Failed to write values.yaml: %v", err) + } + + // When ensureValuesYamlLoaded is called + err := handler.(*configHandler).ensureValuesYamlLoaded() // Then no error should be returned if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Errorf("Expected no error, got: %v", err) } - // And the values.yaml file should be created - valuesPath := filepath.Join(tempDir, "contexts", "test", "values.yaml") - if _, err := handler.shims.Stat(valuesPath); os.IsNotExist(err) { - t.Fatalf("values.yaml file was not created at %s", valuesPath) + // And contextValues should contain the loaded values + if handler.(*configHandler).contextValues == nil { + t.Fatal("contextValues is nil") + } + if len(handler.(*configHandler).contextValues) == 0 { + t.Fatal("contextValues is empty") + } + if handler.(*configHandler).contextValues["test_key"] != "test_value" { + t.Errorf("Expected test_key='test_value', got: %v", handler.(*configHandler).contextValues["test_key"]) + } + anotherKey, ok := handler.(*configHandler).contextValues["another_key"] + if !ok { + t.Error("Expected another_key to be present") + } else { + // YAML unmarshals numbers as different types depending on their value + // Check if it's 123 regardless of the specific integer type + switch v := anotherKey.(type) { + case int: + if v != 123 { + t.Errorf("Expected another_key=123, got: %v", v) + } + case int64: + if v != 123 { + t.Errorf("Expected another_key=123, got: %v", v) + } + case uint64: + if v != 123 { + t.Errorf("Expected another_key=123, got: %v", v) + } + default: + t.Errorf("Expected another_key to be numeric, got: %v (type: %T)", v, v) + } } }) - t.Run("GetConfigRootError", func(t *testing.T) { - // Given a YamlConfigHandler with a shell that returns an error - handler, mocks := setup(t) + t.Run("ErrorReadingValuesYaml", func(t *testing.T) { + // Given a standalone handler with values.yaml that cannot be read + tmpDir := t.TempDir() + injector := di.NewInjector() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "", fmt.Errorf("failed to get project root") + mockShell := shell.NewMockShell(injector) + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil } + injector.Register("shell", mockShell) - handler.context = "test" - handler.contextValues = map[string]any{"key": "value"} - - // When saveContextValues is called - err := handler.saveContextValues() - - // Then an error should be returned - if err == nil { - t.Fatal("Expected error, got nil") + handler := NewConfigHandler(injector) + handler.(*configHandler).shims = NewShims() + handler.(*configHandler).shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test" + } + return "" } - - expectedError := "error getting config root: failed to get project root" - if err.Error() != expectedError { - t.Errorf("Expected error: %s, got: %s", expectedError, err.Error()) + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize: %v", err) } - }) - - t.Run("MkdirAllError", func(t *testing.T) { - // Given a YamlConfigHandler with a shims that fails on MkdirAll - handler, mocks := setup(t) + handler.(*configHandler).loaded = true + handler.(*configHandler).context = "test" + handler.(*configHandler).contextValues = nil // Ensure values aren't already loaded - tempDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tempDir, nil + // Create context directory and values.yaml + contextDir := filepath.Join(tmpDir, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) } - - // Mock MkdirAll to return an error - originalMkdirAll := mocks.Shims.MkdirAll - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return fmt.Errorf("mkdir failed") + valuesPath := filepath.Join(contextDir, "values.yaml") + if err := os.WriteFile(valuesPath, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to write values.yaml: %v", err) } - handler.context = "test" - handler.contextValues = map[string]any{"key": "value"} + // Mock ReadFile to return error for values.yaml + handler.(*configHandler).shims.ReadFile = func(filename string) ([]byte, error) { + if strings.Contains(filename, "values.yaml") { + return nil, fmt.Errorf("read error") + } + return os.ReadFile(filename) + } - // When saveContextValues is called - err := handler.saveContextValues() + // When ensureValuesYamlLoaded is called + err := handler.(*configHandler).ensureValuesYamlLoaded() // Then an error should be returned if err == nil { t.Fatal("Expected error, got nil") } - - expectedError := "error creating context directory: mkdir failed" - if err.Error() != expectedError { - t.Errorf("Expected error: %s, got: %s", expectedError, err.Error()) + if !strings.Contains(err.Error(), "error reading values.yaml") { + t.Errorf("Expected 'error reading values.yaml', got: %v", err) } - - // Restore original function - mocks.Shims.MkdirAll = originalMkdirAll }) -} -func TestYamlConfigHandler_ensureValuesYamlLoaded(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims + t.Run("ErrorUnmarshallingValuesYaml", func(t *testing.T) { + // Given a standalone handler with malformed values.yaml + tmpDir := t.TempDir() + injector := di.NewInjector() + + mockShell := shell.NewMockShell(injector) + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("shell", mockShell) + + handler := NewConfigHandler(injector) + handler.(*configHandler).shims = NewShims() + handler.(*configHandler).shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test" + } + return "" + } if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize handler: %v", err) + t.Fatalf("Failed to initialize: %v", err) } - return handler, mocks - } + handler.(*configHandler).loaded = true + handler.(*configHandler).context = "test" + handler.(*configHandler).contextValues = nil // Ensure values aren't already loaded - t.Run("AlreadyLoaded", func(t *testing.T) { - // Given a handler with contextValues already loaded - handler, _ := setup(t) - handler.contextValues = map[string]any{"existing": "value"} + // Create context directory and malformed values.yaml + contextDir := filepath.Join(tmpDir, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + valuesPath := filepath.Join(contextDir, "values.yaml") + if err := os.WriteFile(valuesPath, []byte("invalid: yaml: content:"), 0644); err != nil { + t.Fatalf("Failed to write values.yaml: %v", err) + } // When ensureValuesYamlLoaded is called - err := handler.ensureValuesYamlLoaded() + err := handler.(*configHandler).ensureValuesYamlLoaded() - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") } - - // And contextValues should remain unchanged - if handler.contextValues["existing"] != "value" { - t.Error("contextValues should remain unchanged") + if !strings.Contains(err.Error(), "error unmarshalling values.yaml") { + t.Errorf("Expected 'error unmarshalling values.yaml', got: %v", err) } }) - t.Run("ShellNotInitialized", func(t *testing.T) { - // Given a handler with no shell initialized - handler := &YamlConfigHandler{} + t.Run("ValidatesValuesYamlWithSchema", func(t *testing.T) { + // Given a standalone handler with schema and values.yaml + tmpDir := t.TempDir() + injector := di.NewInjector() - // When ensureValuesYamlLoaded is called - err := handler.ensureValuesYamlLoaded() + mockShell := shell.NewMockShell(injector) + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("shell", mockShell) - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) + handler := NewConfigHandler(injector) + handler.(*configHandler).shims = NewShims() + handler.(*configHandler).shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test" + } + return "" + } + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize: %v", err) } + handler.(*configHandler).loaded = true + handler.(*configHandler).context = "test" + handler.(*configHandler).contextValues = nil // Ensure values aren't already loaded + handler.(*configHandler).schemaValidator.Shims = NewShims() - // And contextValues should be initialized as empty - if handler.contextValues == nil { - t.Error("contextValues should be initialized") + // Create schema file + schemaDir := filepath.Join(tmpDir, "contexts", "_template") + if err := os.MkdirAll(schemaDir, 0755); err != nil { + t.Fatalf("Failed to create schema directory: %v", err) } - if len(handler.contextValues) != 0 { - t.Errorf("Expected empty contextValues, got: %v", handler.contextValues) + schemaPath := filepath.Join(schemaDir, "schema.yaml") + schemaContent := `$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + test_key: + type: string +additionalProperties: false +` + if err := os.WriteFile(schemaPath, []byte(schemaContent), 0644); err != nil { + t.Fatalf("Failed to write schema file: %v", err) } - }) - t.Run("ConfigNotLoaded", func(t *testing.T) { - // Given a handler with shell but not loaded - handler, _ := setup(t) - handler.loaded = false + // Create context directory and values.yaml with valid content + contextDir := filepath.Join(tmpDir, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + valuesPath := filepath.Join(contextDir, "values.yaml") + valuesContent := `test_key: test_value +` + if err := os.WriteFile(valuesPath, []byte(valuesContent), 0644); err != nil { + t.Fatalf("Failed to write values.yaml: %v", err) + } // When ensureValuesYamlLoaded is called - err := handler.ensureValuesYamlLoaded() + err := handler.(*configHandler).ensureValuesYamlLoaded() // Then no error should be returned if err != nil { t.Errorf("Expected no error, got: %v", err) } - // And contextValues should be initialized as empty - if handler.contextValues == nil { - t.Error("contextValues should be initialized") - } - if len(handler.contextValues) != 0 { - t.Errorf("Expected empty contextValues, got: %v", handler.contextValues) + // And contextValues should contain validated values + if handler.(*configHandler).contextValues["test_key"] != "test_value" { + t.Errorf("Expected test_key='test_value', got: %v", handler.(*configHandler).contextValues["test_key"]) } }) - t.Run("ErrorGettingProjectRoot", func(t *testing.T) { - // Given a handler with shell returning error on GetProjectRoot - handler, mocks := setup(t) - handler.loaded = true - handler.contextValues = nil // Ensure it's not already loaded - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "", fmt.Errorf("project root error") - } - - // When ensureValuesYamlLoaded is called - err := handler.ensureValuesYamlLoaded() + t.Run("ValidationFailsForInvalidValuesYaml", func(t *testing.T) { + // Given a standalone handler with schema and invalid values.yaml + tmpDir := t.TempDir() + injector := di.NewInjector() - // Then an error should be returned - if err == nil { - t.Fatal("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error retrieving project root") { - t.Errorf("Expected 'error retrieving project root', got: %v", err) + mockShell := shell.NewMockShell(injector) + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil } - }) - - t.Run("LoadsSchemaIfNotLoaded", func(t *testing.T) { - // Given a handler with schema validator but no schema loaded - handler, mocks := setup(t) - handler.loaded = true - handler.context = "test" - handler.contextValues = nil - handler.schemaValidator = NewSchemaValidator(mocks.Shell) - handler.schemaValidator.Shims = NewShims() // Use real filesystem for schema validator - - // Use real filesystem operations for this test - handler.shims = NewShims() + injector.Register("shell", mockShell) - // Create temp directory structure - tmpDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tmpDir, nil + handler := NewConfigHandler(injector) + handler.(*configHandler).shims = NewShims() + handler.(*configHandler).shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test" + } + return "" } + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize: %v", err) + } + handler.(*configHandler).loaded = true + handler.(*configHandler).context = "test" + handler.(*configHandler).contextValues = nil // Ensure values aren't already loaded + handler.(*configHandler).schemaValidator.Shims = NewShims() // Create schema file schemaDir := filepath.Join(tmpDir, "contexts", "_template") @@ -4607,53 +3221,50 @@ func TestYamlConfigHandler_ensureValuesYamlLoaded(t *testing.T) { t.Fatalf("Failed to create schema directory: %v", err) } schemaPath := filepath.Join(schemaDir, "schema.yaml") - schemaContent := ` -$schema: https://json-schema.org/draft/2020-12/schema + schemaContent := `$schema: https://json-schema.org/draft/2020-12/schema type: object properties: test_key: type: string +additionalProperties: false ` if err := os.WriteFile(schemaPath, []byte(schemaContent), 0644); err != nil { t.Fatalf("Failed to write schema file: %v", err) } - // Create context directory but no values.yaml + // Create context directory and values.yaml with invalid content contextDir := filepath.Join(tmpDir, "contexts", "test") if err := os.MkdirAll(contextDir, 0755); err != nil { t.Fatalf("Failed to create context directory: %v", err) } + valuesPath := filepath.Join(contextDir, "values.yaml") + valuesContent := `invalid_key: should_not_be_allowed +` + if err := os.WriteFile(valuesPath, []byte(valuesContent), 0644); err != nil { + t.Fatalf("Failed to write values.yaml: %v", err) + } // When ensureValuesYamlLoaded is called - err := handler.ensureValuesYamlLoaded() + err := handler.(*configHandler).ensureValuesYamlLoaded() - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - - // And schema should be loaded - if handler.schemaValidator.Schema == nil { - t.Error("Schema should be loaded") + // Then a validation error should be returned + if err == nil { + t.Fatal("Expected validation error, got nil") } - - // And contextValues should be initialized as empty - if len(handler.contextValues) != 0 { - t.Errorf("Expected empty contextValues, got: %v", handler.contextValues) + if !strings.Contains(err.Error(), "validation failed") { + t.Errorf("Expected 'validation failed', got: %v", err) } }) - t.Run("ErrorLoadingSchema", func(t *testing.T) { - // Given a handler with schema validator and malformed schema file + t.Run("NoValuesYamlFileInitializesEmpty", func(t *testing.T) { + // Given a handler with no values.yaml file handler, mocks := setup(t) - handler.loaded = true - handler.context = "test" - handler.contextValues = nil - handler.schemaValidator = NewSchemaValidator(mocks.Shell) - handler.schemaValidator.Shims = NewShims() // Use real filesystem for schema validator + handler.(*configHandler).loaded = true + handler.(*configHandler).context = "test" + handler.(*configHandler).contextValues = nil // Use real filesystem operations for this test - handler.shims = NewShims() + handler.(*configHandler).shims = NewShims() // Create temp directory structure tmpDir := t.TempDir() @@ -4661,388 +3272,592 @@ properties: return tmpDir, nil } - // Create malformed schema file - schemaDir := filepath.Join(tmpDir, "contexts", "_template") - if err := os.MkdirAll(schemaDir, 0755); err != nil { - t.Fatalf("Failed to create schema directory: %v", err) - } - schemaPath := filepath.Join(schemaDir, "schema.yaml") - if err := os.WriteFile(schemaPath, []byte("invalid: yaml: content:"), 0644); err != nil { - t.Fatalf("Failed to write schema file: %v", err) + // Create context directory but NO values.yaml + contextDir := filepath.Join(tmpDir, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) } // When ensureValuesYamlLoaded is called - err := handler.ensureValuesYamlLoaded() + err := handler.(*configHandler).ensureValuesYamlLoaded() - // Then an error should be returned - if err == nil { - t.Fatal("Expected error for malformed schema, got nil") + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) } - if !strings.Contains(err.Error(), "error loading schema") { - t.Errorf("Expected 'error loading schema', got: %v", err) + + // And contextValues should be initialized as empty + if handler.(*configHandler).contextValues == nil { + t.Error("contextValues should be initialized") + } + if len(handler.(*configHandler).contextValues) != 0 { + t.Errorf("Expected empty contextValues, got: %v", handler.(*configHandler).contextValues) } }) +} - t.Run("LoadsValuesYamlSuccessfully", func(t *testing.T) { - // Given a standalone handler with valid values.yaml - tmpDir := t.TempDir() - injector := di.NewInjector() +// ============================================================================= +// Additional Tests for Full Coverage (from config_handler_test.go) +// ============================================================================= - mockShell := shell.NewMockShell(injector) - mockShell.GetProjectRootFunc = func() (string, error) { - return tmpDir, nil +func TestConfigHandler_IsLoaded(t *testing.T) { + setup := func(t *testing.T) ConfigHandler { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + return handler + } + + t.Run("IsLoadedTrue", func(t *testing.T) { + handler := setup(t) + handler.(*configHandler).loaded = true + + isLoaded := handler.IsLoaded() + + if !isLoaded { + t.Errorf("expected IsLoaded to return true, got false") } - injector.Register("shell", mockShell) + }) - handler := NewYamlConfigHandler(injector) - handler.shims = NewShims() - handler.shims.Getenv = func(key string) string { - if key == "WINDSOR_CONTEXT" { - return "test" - } - return "" + t.Run("IsLoadedFalse", func(t *testing.T) { + handler := setup(t) + handler.(*configHandler).loaded = false + + isLoaded := handler.IsLoaded() + + if isLoaded { + t.Errorf("expected IsLoaded to return false, got true") } - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize: %v", err) + }) +} + +func TestConfigHandler_IsContextConfigLoaded(t *testing.T) { + setup := func(t *testing.T) ConfigHandler { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + return handler + } + + t.Run("ReturnsFalseWhenBaseConfigNotLoaded", func(t *testing.T) { + handler := setup(t) + handler.(*configHandler).loaded = false + + isLoaded := handler.IsContextConfigLoaded() + + if isLoaded { + t.Errorf("expected IsContextConfigLoaded to return false when base config not loaded, got true") } + }) + + t.Run("ReturnsFalseWhenContextNotSet", func(t *testing.T) { + handler, mocks := setup(t).(*configHandler), setupMocks(t) handler.loaded = true - handler.context = "test" - handler.contextValues = nil // Ensure values aren't already loaded + handler.shims = mocks.Shims - // Create context directory and values.yaml - contextDir := filepath.Join(tmpDir, "contexts", "test") - if err := os.MkdirAll(contextDir, 0755); err != nil { - t.Fatalf("Failed to create context directory: %v", err) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project/root", nil } - valuesPath := filepath.Join(contextDir, "values.yaml") - valuesContent := `test_key: test_value -another_key: 123 -` - if err := os.WriteFile(valuesPath, []byte(valuesContent), 0644); err != nil { - t.Fatalf("Failed to write values.yaml: %v", err) + mocks.Shims.ReadFile = func(filename string) ([]byte, error) { + return []byte(""), nil + } + mocks.Shims.Getenv = func(key string) string { + return "" } + handler.shell = mocks.Shell - // When ensureValuesYamlLoaded is called - err := handler.ensureValuesYamlLoaded() + isLoaded := handler.IsContextConfigLoaded() - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) + if isLoaded { + t.Errorf("expected IsContextConfigLoaded to return false when context not set, got true") } + }) - // And contextValues should contain the loaded values - if handler.contextValues == nil { - t.Fatal("contextValues is nil") + t.Run("ReturnsTrueWhenContextExistsAndIsValid", func(t *testing.T) { + handler := setup(t) + handler.(*configHandler).loaded = true + handler.(*configHandler).config = v1alpha1.Config{ + Contexts: map[string]*v1alpha1.Context{ + "test-context": { + Cluster: &cluster.ClusterConfig{ + Workers: cluster.NodeGroupConfig{ + Volumes: []string{"/var/blah"}, + }, + }, + }, + }, } - if len(handler.contextValues) == 0 { - t.Fatal("contextValues is empty") + + mocks := setupMocks(t) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project/root", nil } - if handler.contextValues["test_key"] != "test_value" { - t.Errorf("Expected test_key='test_value', got: %v", handler.contextValues["test_key"]) + mocks.Shims.ReadFile = func(filename string) ([]byte, error) { + return []byte("test-context"), nil } - anotherKey, ok := handler.contextValues["another_key"] - if !ok { - t.Error("Expected another_key to be present") - } else { - // YAML unmarshals numbers as different types depending on their value - // Check if it's 123 regardless of the specific integer type - switch v := anotherKey.(type) { - case int: - if v != 123 { - t.Errorf("Expected another_key=123, got: %v", v) - } - case int64: - if v != 123 { - t.Errorf("Expected another_key=123, got: %v", v) - } - case uint64: - if v != 123 { - t.Errorf("Expected another_key=123, got: %v", v) - } - default: - t.Errorf("Expected another_key to be numeric, got: %v (type: %T)", v, v) + mocks.Shims.Getenv = func(key string) string { + return "" + } + handler.(*configHandler).shims = mocks.Shims + handler.(*configHandler).shell = mocks.Shell + handler.(*configHandler).loadedContexts["test-context"] = true + + isLoaded := handler.IsContextConfigLoaded() + + if !isLoaded { + t.Errorf("expected IsContextConfigLoaded to return true when context exists and is valid, got false") + } + }) +} + +func TestConfigHandler_GetContext(t *testing.T) { + setup := func(t *testing.T) ConfigHandler { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + return handler + } + + t.Run("Success", func(t *testing.T) { + handler := setup(t) + mocks := setupMocks(t) + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project/root", nil + } + mocks.Shims.ReadFile = func(filename string) ([]byte, error) { + if filename == filepath.Join("/mock/project/root", ".windsor", "context") { + return []byte("test-context"), nil } + return nil, fmt.Errorf("file not found") + } + mocks.Shims.Getenv = func(key string) string { + return "" + } + handler.(*configHandler).shims = mocks.Shims + handler.(*configHandler).shell = mocks.Shell + + contextValue := handler.GetContext() + + if contextValue != "test-context" { + t.Errorf("expected context 'test-context', got %s", contextValue) } }) - t.Run("ErrorReadingValuesYaml", func(t *testing.T) { - // Given a standalone handler with values.yaml that cannot be read - tmpDir := t.TempDir() - injector := di.NewInjector() + t.Run("GetContextDefaultsToLocal", func(t *testing.T) { + handler := setup(t) + mocks := setupMocks(t) - mockShell := shell.NewMockShell(injector) - mockShell.GetProjectRootFunc = func() (string, error) { - return tmpDir, nil + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project/root", nil } - injector.Register("shell", mockShell) + mocks.Shims.ReadFile = func(_ string) ([]byte, error) { + return nil, fmt.Errorf("file not found") + } + mocks.Shims.Getenv = func(key string) string { + return "" + } + handler.(*configHandler).shims = mocks.Shims + handler.(*configHandler).shell = mocks.Shell + + actualContext := handler.GetContext() + + expectedContext := "local" + if actualContext != expectedContext { + t.Errorf("Expected context %q, got %q", expectedContext, actualContext) + } + }) - handler := NewYamlConfigHandler(injector) - handler.shims = NewShims() - handler.shims.Getenv = func(key string) string { + t.Run("ContextFromEnvironment", func(t *testing.T) { + handler := setup(t) + mocks := setupMocks(t) + + mocks.Shims.Getenv = func(key string) string { if key == "WINDSOR_CONTEXT" { - return "test" + return "env-context" } return "" } - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize: %v", err) + handler.(*configHandler).shims = mocks.Shims + + actualContext := handler.GetContext() + + if actualContext != "env-context" { + t.Errorf("Expected context 'env-context', got %q", actualContext) } - handler.loaded = true - handler.context = "test" - handler.contextValues = nil // Ensure values aren't already loaded + }) +} - // Create context directory and values.yaml - contextDir := filepath.Join(tmpDir, "contexts", "test") - if err := os.MkdirAll(contextDir, 0755); err != nil { - t.Fatalf("Failed to create context directory: %v", err) +func TestConfigHandler_SetContext(t *testing.T) { + setup := func(t *testing.T) ConfigHandler { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + handler.(*configHandler).shell = mocks.Shell + return handler + } + + t.Run("Success", func(t *testing.T) { + handler := setup(t) + mocks := setupMocks(t) + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project/root", nil } - valuesPath := filepath.Join(contextDir, "values.yaml") - if err := os.WriteFile(valuesPath, []byte("test"), 0644); err != nil { - t.Fatalf("Failed to write values.yaml: %v", err) + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + mocks.Shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + return nil + } + mocks.Shims.Setenv = func(key, value string) error { + return nil } + handler.(*configHandler).shims = mocks.Shims + handler.(*configHandler).shell = mocks.Shell - // Mock ReadFile to return error for values.yaml - handler.shims.ReadFile = func(filename string) ([]byte, error) { - if strings.Contains(filename, "values.yaml") { - return nil, fmt.Errorf("read error") - } - return os.ReadFile(filename) + err := handler.SetContext("new-context") + + if err != nil { + t.Fatalf("expected no error, got %v", err) } + }) - // When ensureValuesYamlLoaded is called - err := handler.ensureValuesYamlLoaded() + t.Run("GetProjectRootError", func(t *testing.T) { + handler := setup(t) + mocks := setupMocks(t) + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("mocked error inside GetProjectRoot") + } + handler.(*configHandler).shell = mocks.Shell + + err := handler.SetContext("new-context") - // Then an error should be returned if err == nil { - t.Fatal("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error reading values.yaml") { - t.Errorf("Expected 'error reading values.yaml', got: %v", err) + t.Fatal("expected error, got nil") } }) +} - t.Run("ErrorUnmarshallingValuesYaml", func(t *testing.T) { - // Given a standalone handler with malformed values.yaml - tmpDir := t.TempDir() - injector := di.NewInjector() +func TestConfigHandler_Clean(t *testing.T) { + setup := func(t *testing.T) ConfigHandler { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + handler.(*configHandler).shell = mocks.Shell + return handler + } - mockShell := shell.NewMockShell(injector) - mockShell.GetProjectRootFunc = func() (string, error) { - return tmpDir, nil - } - injector.Register("shell", mockShell) + t.Run("Success", func(t *testing.T) { + handler := setup(t) + mocks := setupMocks(t) - handler := NewYamlConfigHandler(injector) - handler.shims = NewShims() - handler.shims.Getenv = func(key string) string { - if key == "WINDSOR_CONTEXT" { - return "test" - } - return "" + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project/root", nil } - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize: %v", err) + mocks.Shims.RemoveAll = func(path string) error { + return nil } - handler.loaded = true - handler.context = "test" - handler.contextValues = nil // Ensure values aren't already loaded + handler.(*configHandler).shims = mocks.Shims + handler.(*configHandler).shell = mocks.Shell - // Create context directory and malformed values.yaml - contextDir := filepath.Join(tmpDir, "contexts", "test") - if err := os.MkdirAll(contextDir, 0755); err != nil { - t.Fatalf("Failed to create context directory: %v", err) + err := handler.Clean() + + if err != nil { + t.Fatalf("expected no error, got %v", err) } - valuesPath := filepath.Join(contextDir, "values.yaml") - if err := os.WriteFile(valuesPath, []byte("invalid: yaml: content:"), 0644); err != nil { - t.Fatalf("Failed to write values.yaml: %v", err) + }) + + t.Run("ErrorGettingConfigRoot", func(t *testing.T) { + handler := setup(t) + mocks := setupMocks(t) + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("error getting project root") } + handler.(*configHandler).shell = mocks.Shell - // When ensureValuesYamlLoaded is called - err := handler.ensureValuesYamlLoaded() + err := handler.Clean() - // Then an error should be returned if err == nil { - t.Fatal("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error unmarshalling values.yaml") { - t.Errorf("Expected 'error unmarshalling values.yaml', got: %v", err) + t.Fatalf("expected error, got none") } }) +} - t.Run("ValidatesValuesYamlWithSchema", func(t *testing.T) { - // Given a standalone handler with schema and values.yaml - tmpDir := t.TempDir() - injector := di.NewInjector() +func TestConfigHandler_SetSecretsProvider(t *testing.T) { + t.Run("AddsProvider", func(t *testing.T) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) - mockShell := shell.NewMockShell(injector) - mockShell.GetProjectRootFunc = func() (string, error) { - return tmpDir, nil - } - injector.Register("shell", mockShell) + mockProvider := secrets.NewMockSecretsProvider(mocks.Injector) - handler := NewYamlConfigHandler(injector) - handler.shims = NewShims() - handler.shims.Getenv = func(key string) string { - if key == "WINDSOR_CONTEXT" { - return "test" - } - return "" + handler.SetSecretsProvider(mockProvider) + + if len(handler.(*configHandler).secretsProviders) != 1 { + t.Errorf("Expected 1 secrets provider, got %d", len(handler.(*configHandler).secretsProviders)) } - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize: %v", err) + }) + + t.Run("AddsMultipleProviders", func(t *testing.T) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + + mockProvider1 := secrets.NewMockSecretsProvider(mocks.Injector) + mockProvider2 := secrets.NewMockSecretsProvider(mocks.Injector) + + handler.SetSecretsProvider(mockProvider1) + handler.SetSecretsProvider(mockProvider2) + + if len(handler.(*configHandler).secretsProviders) != 2 { + t.Errorf("Expected 2 secrets providers, got %d", len(handler.(*configHandler).secretsProviders)) } - handler.loaded = true - handler.context = "test" - handler.contextValues = nil // Ensure values aren't already loaded - handler.schemaValidator.Shims = NewShims() + }) +} - // Create schema file - schemaDir := filepath.Join(tmpDir, "contexts", "_template") - if err := os.MkdirAll(schemaDir, 0755); err != nil { - t.Fatalf("Failed to create schema directory: %v", err) +func TestConfigHandler_LoadSchema(t *testing.T) { + setup := func(t *testing.T) ConfigHandler { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + err := handler.Initialize() + if err != nil { + t.Fatalf("failed to initialize: %v", err) } - schemaPath := filepath.Join(schemaDir, "schema.yaml") - schemaContent := `$schema: https://json-schema.org/draft/2020-12/schema + return handler + } + + t.Run("Success", func(t *testing.T) { + handler := setup(t) + mocks := setupMocks(t) + + schemaContent := []byte(` +$schema: https://schemas.windsorcli.dev/blueprint-config/v1alpha1 type: object properties: test_key: - type: string -additionalProperties: false -` - if err := os.WriteFile(schemaPath, []byte(schemaContent), 0644); err != nil { - t.Fatalf("Failed to write schema file: %v", err) + type: string`) + + mocks.Shims.ReadFile = func(filename string) ([]byte, error) { + return schemaContent, nil + } + handler.(*configHandler).shims = mocks.Shims + if handler.(*configHandler).schemaValidator != nil { + handler.(*configHandler).schemaValidator.Shims = mocks.Shims } - // Create context directory and values.yaml with valid content - contextDir := filepath.Join(tmpDir, "contexts", "test") - if err := os.MkdirAll(contextDir, 0755); err != nil { - t.Fatalf("Failed to create context directory: %v", err) + err := handler.LoadSchema("/path/to/schema.yaml") + if err != nil { + t.Fatalf("expected no error, got %v", err) } - valuesPath := filepath.Join(contextDir, "values.yaml") - valuesContent := `test_key: test_value -` - if err := os.WriteFile(valuesPath, []byte(valuesContent), 0644); err != nil { - t.Fatalf("Failed to write values.yaml: %v", err) + }) + + t.Run("ErrorReadingFile", func(t *testing.T) { + handler := setup(t) + mocks := setupMocks(t) + + mocks.Shims.ReadFile = func(filename string) ([]byte, error) { + return nil, fmt.Errorf("read error") } + handler.(*configHandler).shims = mocks.Shims - // When ensureValuesYamlLoaded is called - err := handler.ensureValuesYamlLoaded() + err := handler.LoadSchema("/path/to/schema.yaml") + if err == nil { + t.Fatal("expected error") + } + }) +} - // Then no error should be returned +func TestConfigHandler_LoadSchemaFromBytes(t *testing.T) { + setup := func(t *testing.T) ConfigHandler { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + err := handler.Initialize() if err != nil { - t.Errorf("Expected no error, got: %v", err) + t.Fatalf("failed to initialize: %v", err) } + return handler + } - // And contextValues should contain validated values - if handler.contextValues["test_key"] != "test_value" { - t.Errorf("Expected test_key='test_value', got: %v", handler.contextValues["test_key"]) + t.Run("Success", func(t *testing.T) { + handler := setup(t) + + schemaContent := []byte(`{ + "$schema": "https://schemas.windsorcli.dev/blueprint-config/v1alpha1", + "type": "object", + "properties": { + "test_key": { + "type": "string" + } + } + }`) + + err := handler.LoadSchemaFromBytes(schemaContent) + if err != nil { + t.Fatalf("expected no error, got %v", err) } }) - t.Run("ValidationFailsForInvalidValuesYaml", func(t *testing.T) { - // Given a standalone handler with schema and invalid values.yaml - tmpDir := t.TempDir() - injector := di.NewInjector() + t.Run("ErrorInvalidSchema", func(t *testing.T) { + handler := setup(t) - mockShell := shell.NewMockShell(injector) - mockShell.GetProjectRootFunc = func() (string, error) { - return tmpDir, nil + schemaContent := []byte(`invalid json`) + + err := handler.LoadSchemaFromBytes(schemaContent) + if err == nil { + t.Fatal("expected error for invalid schema") } - injector.Register("shell", mockShell) + }) +} - handler := NewYamlConfigHandler(injector) - handler.shims = NewShims() - handler.shims.Getenv = func(key string) string { - if key == "WINDSOR_CONTEXT" { - return "test" +func TestConfigHandler_GetSchemaDefaults(t *testing.T) { + setup := func(t *testing.T) ConfigHandler { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + err := handler.Initialize() + if err != nil { + t.Fatalf("failed to initialize: %v", err) + } + return handler + } + + t.Run("ReturnsDefaults", func(t *testing.T) { + handler := setup(t) + + schemaContent := []byte(`{ + "$schema": "https://schemas.windsorcli.dev/blueprint-config/v1alpha1", + "type": "object", + "properties": { + "test_key": { + "type": "string", + "default": "test_value" + } } - return "" + }`) + + err := handler.LoadSchemaFromBytes(schemaContent) + if err != nil { + t.Fatalf("failed to load schema: %v", err) } - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize: %v", err) + + defaults, err := handler.GetSchemaDefaults() + if err != nil { + t.Fatalf("expected no error, got %v", err) } - handler.loaded = true - handler.context = "test" - handler.contextValues = nil // Ensure values aren't already loaded - handler.schemaValidator.Shims = NewShims() - // Create schema file - schemaDir := filepath.Join(tmpDir, "contexts", "_template") - if err := os.MkdirAll(schemaDir, 0755); err != nil { - t.Fatalf("Failed to create schema directory: %v", err) + if defaults["test_key"] != "test_value" { + t.Errorf("expected default value 'test_value', got %v", defaults["test_key"]) } - schemaPath := filepath.Join(schemaDir, "schema.yaml") - schemaContent := `$schema: https://json-schema.org/draft/2020-12/schema -type: object -properties: - test_key: - type: string -additionalProperties: false -` - if err := os.WriteFile(schemaPath, []byte(schemaContent), 0644); err != nil { - t.Fatalf("Failed to write schema file: %v", err) + }) + + t.Run("ErrorWhenSchemaNotLoaded", func(t *testing.T) { + handler := setup(t) + + _, err := handler.GetSchemaDefaults() + if err == nil { + t.Fatal("expected error when schema not loaded") } + }) +} - // Create context directory and values.yaml with invalid content - contextDir := filepath.Join(tmpDir, "contexts", "test") - if err := os.MkdirAll(contextDir, 0755); err != nil { - t.Fatalf("Failed to create context directory: %v", err) +func TestConfigHandler_GetContextValues(t *testing.T) { + setup := func(t *testing.T) ConfigHandler { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + return handler + } + + t.Run("MergesConfigAndValues", func(t *testing.T) { + handler := setup(t) + + h := handler.(*configHandler) + h.context = "test" + h.loaded = true + h.config.Contexts = map[string]*v1alpha1.Context{ + "test": { + Cluster: &cluster.ClusterConfig{ + Enabled: ptrBool(true), + }, + }, } - valuesPath := filepath.Join(contextDir, "values.yaml") - valuesContent := `invalid_key: should_not_be_allowed -` - if err := os.WriteFile(valuesPath, []byte(valuesContent), 0644); err != nil { - t.Fatalf("Failed to write values.yaml: %v", err) + h.contextValues = map[string]any{ + "custom_key": "custom_value", } - // When ensureValuesYamlLoaded is called - err := handler.ensureValuesYamlLoaded() - - // Then a validation error should be returned - if err == nil { - t.Fatal("Expected validation error, got nil") + values, err := handler.GetContextValues() + if err != nil { + t.Fatalf("expected no error, got %v", err) } - if !strings.Contains(err.Error(), "validation failed") { - t.Errorf("Expected 'validation failed', got: %v", err) + + if values["custom_key"] != "custom_value" { + t.Error("expected custom_value to be in merged values") } }) +} - t.Run("NoValuesYamlFileInitializesEmpty", func(t *testing.T) { - // Given a handler with no values.yaml file - handler, mocks := setup(t) - handler.loaded = true - handler.context = "test" - handler.contextValues = nil +func TestConfigHandler_deepMerge(t *testing.T) { + setup := func(t *testing.T) *configHandler { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + return handler.(*configHandler) + } - // Use real filesystem operations for this test - handler.shims = NewShims() + t.Run("MergesSimpleValues", func(t *testing.T) { + handler := setup(t) - // Create temp directory structure - tmpDir := t.TempDir() - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return tmpDir, nil + base := map[string]any{ + "key1": "value1", + "key2": "value2", + } + overlay := map[string]any{ + "key2": "override2", + "key3": "value3", } - // Create context directory but NO values.yaml - contextDir := filepath.Join(tmpDir, "contexts", "test") - if err := os.MkdirAll(contextDir, 0755); err != nil { - t.Fatalf("Failed to create context directory: %v", err) + result := handler.deepMerge(base, overlay) + + if result["key1"] != "value1" { + t.Errorf("expected key1 to remain from base") + } + if result["key2"] != "override2" { + t.Errorf("expected key2 to be overridden") } + if result["key3"] != "value3" { + t.Errorf("expected key3 to be added from overlay") + } + }) - // When ensureValuesYamlLoaded is called - err := handler.ensureValuesYamlLoaded() + t.Run("MergesNestedMaps", func(t *testing.T) { + handler := setup(t) - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) + base := map[string]any{ + "nested": map[string]any{ + "key1": "value1", + "key2": "value2", + }, + } + overlay := map[string]any{ + "nested": map[string]any{ + "key2": "override2", + "key3": "value3", + }, } - // And contextValues should be initialized as empty - if handler.contextValues == nil { - t.Error("contextValues should be initialized") + result := handler.deepMerge(base, overlay) + + nested := result["nested"].(map[string]any) + if nested["key1"] != "value1" { + t.Errorf("expected nested.key1 to remain from base") } - if len(handler.contextValues) != 0 { - t.Errorf("Expected empty contextValues, got: %v", handler.contextValues) + if nested["key2"] != "override2" { + t.Errorf("expected nested.key2 to be overridden") + } + if nested["key3"] != "value3" { + t.Errorf("expected nested.key3 to be added from overlay") } }) } diff --git a/pkg/config/config_handler_public_test.go b/pkg/config/config_handler_public_test.go new file mode 100644 index 000000000..cebda999c --- /dev/null +++ b/pkg/config/config_handler_public_test.go @@ -0,0 +1,1862 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/api/v1alpha1/aws" + "github.com/windsorcli/cli/api/v1alpha1/cluster" + "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/secrets" + "github.com/windsorcli/cli/pkg/shell" +) + +// ============================================================================= +// Test Setup +// ============================================================================= + +type Mocks struct { + Injector di.Injector + ConfigHandler *ConfigHandler + Shell *shell.MockShell + SecretsProvider *secrets.MockSecretsProvider + Shims *Shims +} + +type SetupOptions struct { + Injector di.Injector + ConfigHandler ConfigHandler + ConfigStr string +} + +func setupShims(t *testing.T) *Shims { + t.Helper() + shims := NewShims() + shims.Stat = func(name string) (os.FileInfo, error) { + return nil, nil + } + shims.ReadFile = func(filename string) ([]byte, error) { + return []byte("dummy: data"), nil + } + shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + return nil + } + shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test" + } + return "" + } + return shims +} + +func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { + t.Helper() + + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + os.Setenv("WINDSOR_PROJECT_ROOT", tmpDir) + + var injector di.Injector + if len(opts) > 0 { + injector = opts[0].Injector + } else { + injector = di.NewInjector() + } + + mockShell := shell.NewMockShell(injector) + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("shell", mockShell) + + mockSecretsProvider := secrets.NewMockSecretsProvider(injector) + injector.Register("secretsProvider", mockSecretsProvider) + + mockConfigHandler := NewMockConfigHandler() + mockConfigHandler.GetConfigFunc = func() *v1alpha1.Context { + return &v1alpha1.Context{} + } + injector.Register("configHandler", mockConfigHandler) + + mockShims := setupShims(t) + + t.Cleanup(func() { + os.Unsetenv("WINDSOR_PROJECT_ROOT") + + if err := os.Chdir(origDir); err != nil { + t.Logf("Warning: Failed to change back to original directory: %v", err) + } + }) + + return &Mocks{ + Shell: mockShell, + SecretsProvider: mockSecretsProvider, + Injector: injector, + Shims: mockShims, + } +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +// stringPtr returns a pointer to the provided string +func stringPtr(s string) *string { + return &s +} + +// ============================================================================= +// Constructor +// ============================================================================= + +func TestNewConfigHandler(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + + return handler, mocks + } + t.Run("Success", func(t *testing.T) { + handler, _ := setup(t) + + // Then the handler should be successfully created and not be nil + if handler == nil { + t.Fatal("Expected non-nil configHandler") + } + }) +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +func TestConfigHandler_LoadConfig(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + return handler, mocks + } + + t.Run("Success", func(t *testing.T) { + // Given a set of safe mocks and a configHandler + handler, _ := setup(t) + + // And a valid config path + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.yaml") + + // When LoadConfig is called with the valid path + err := handler.LoadConfig(configPath) + + // Then no error should be returned + if err != nil { + t.Fatalf("LoadConfig() unexpected error: %v", err) + } + + // And the path should be set correctly + if handler.(*configHandler).path != configPath { + t.Errorf("Expected path = %v, got = %v", configPath, handler.(*configHandler).path) + } + }) + + t.Run("CreateEmptyConfigFileIfNotExist", func(t *testing.T) { + // Given a set of safe mocks and a configHandler + handler, _ := setup(t) + + // And a mocked osStat that returns ErrNotExist + handler.(*configHandler).shims.Stat = func(_ string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // When LoadConfig is called with a non-existent path + err := handler.LoadConfig("test_config.yaml") + + // Then no error should be returned + if err != nil { + t.Fatalf("LoadConfig() unexpected error: %v", err) + } + }) + + t.Run("ReadFileError", func(t *testing.T) { + // Given a set of safe mocks and a configHandler + handler, _ := setup(t) + + // And a mocked osReadFile that returns an error + handler.(*configHandler).shims.ReadFile = func(filename string) ([]byte, error) { + return nil, fmt.Errorf("mocked error reading file") + } + + // When LoadConfig is called + err := handler.LoadConfig("mocked_config.yaml") + + // Then an error should be returned + if err == nil { + t.Fatalf("LoadConfig() expected error, got nil") + } + + // And the error message should be as expected + expectedError := "error reading config file: mocked error reading file" + if err.Error() != expectedError { + t.Errorf("LoadConfig() error = %v, expected '%s'", err, expectedError) + } + }) + + t.Run("UnmarshalError", func(t *testing.T) { + // Given a set of safe mocks and a configHandler + handler, _ := setup(t) + + // And a mocked yamlUnmarshal that returns an error + handler.(*configHandler).shims.YamlUnmarshal = func(data []byte, v any) error { + return fmt.Errorf("mocked error unmarshalling yaml") + } + + // When LoadConfig is called + err := handler.LoadConfig("mocked_path.yaml") + + // Then an error should be returned + if err == nil { + t.Fatalf("LoadConfig() expected error, got nil") + } + + // And the error message should be as expected + expectedError := "error unmarshalling yaml: mocked error unmarshalling yaml" + if err.Error() != expectedError { + t.Errorf("LoadConfig() error = %v, expected '%s'", err, expectedError) + } + }) + + t.Run("UnsupportedConfigVersion", func(t *testing.T) { + // Given a set of safe mocks and a configHandler + handler, _ := setup(t) + + // And a mocked yamlUnmarshal that sets an unsupported version + handler.(*configHandler).shims.YamlUnmarshal = func(data []byte, v any) error { + if config, ok := v.(*v1alpha1.Config); ok { + config.Version = "unsupported_version" + } + return nil + } + + // When LoadConfig is called + err := handler.LoadConfig("mocked_path.yaml") + + // Then an error should be returned + if err == nil { + t.Fatalf("LoadConfig() expected error, got nil") + } + + // And the error message should be as expected + expectedError := "unsupported config version: unsupported_version" + if err.Error() != expectedError { + t.Errorf("LoadConfig() error = %v, expected '%s'", err, expectedError) + } + }) +} + +func TestConfigHandler_Get(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + return handler, mocks + } + + t.Run("KeyNotUnderContexts", func(t *testing.T) { + // Given a set of safe mocks and a configHandler + handler, mocks := setup(t) + + // And a mocked shell that returns a project root + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project/root", nil + } + + // And a mocked shims that handles context file + handler.(*configHandler).shims.ReadFile = func(filename string) ([]byte, error) { + if filename == "/mock/project/root/.windsor/context" { + return []byte("local"), nil + } + return nil, fmt.Errorf("file not found") + } + + // And a config with proper initialization + handler.(*configHandler).config = v1alpha1.Config{ + Version: "v1alpha1", + Contexts: map[string]*v1alpha1.Context{ + "local": { + Environment: map[string]string{}, + }, + }, + } + + // And the context is set + handler.(*configHandler).context = "local" + + // When getting a key not under contexts + val := handler.Get("nonContextKey") + + // Then nil should be returned + if val != nil { + t.Errorf("Expected nil for non-context key, got %v", val) + } + }) + + t.Run("InvalidPath", func(t *testing.T) { + // Given a set of safe mocks and a configHandler + handler, _ := setup(t) + + // When calling Get with an empty path + value := handler.Get("") + + // Then nil should be returned + if value != nil { + t.Errorf("Expected nil for empty path, got %v", value) + } + }) + + t.Run("PrecedenceAndSchemaDefaults", func(t *testing.T) { + // Given a handler with schema validator and various data sources + handler, _ := setup(t) + handler.(*configHandler).context = "test" + handler.(*configHandler).loaded = true + + // Set up schema validator with defaults + handler.(*configHandler).schemaValidator = &SchemaValidator{ + Schema: map[string]any{ + "properties": map[string]any{ + "SCHEMA_KEY": map[string]any{ + "default": "schema_default_value", + }, + }, + }, + } + + // Test schema defaults for single-key path + value := handler.Get("SCHEMA_KEY") + expected := "schema_default_value" + if value != expected { + t.Errorf("Expected schema default value '%s', got '%v'", expected, value) + } + + // Test that multi-key paths don't use schema defaults + value = handler.Get("contexts.test.SCHEMA_KEY") + if value != nil { + t.Errorf("Expected nil for multi-key path, got '%v'", value) + } + + // Test contextValues precedence + handler.(*configHandler).contextValues = map[string]any{ + "TEST_VAR": "values_value", + } + value = handler.Get("contexts.test.TEST_VAR") + expected = "values_value" + if value != expected { + t.Errorf("Expected contextValues value '%s', got '%v'", expected, value) + } + + // Test that contextValues are not checked when not loaded + handler.(*configHandler).loaded = false + value = handler.Get("contexts.test.TEST_VAR") + if value != nil { + t.Errorf("Expected nil when not loaded, got '%v'", value) + } + }) +} + +func TestConfigHandler_SaveConfig(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + return handler, mocks + } + + t.Run("Success", func(t *testing.T) { + // Given a configHandler with a mocked shell + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + // And a context is set + handler.(*configHandler).context = "test-context" + + // And some configuration data + handler.Set("contexts.test-context.provider", "local") + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the root windsor.yaml should exist with only version + rootConfigPath := filepath.Join(tempDir, "windsor.yaml") + if _, err := handler.(*configHandler).shims.Stat(rootConfigPath); os.IsNotExist(err) { + t.Fatalf("Root config file was not created at %s", rootConfigPath) + } + + // And the context config should exist + contextConfigPath := filepath.Join(tempDir, "contexts", "test-context", "windsor.yaml") + if _, err := handler.(*configHandler).shims.Stat(contextConfigPath); os.IsNotExist(err) { + t.Fatalf("Context config file was not created at %s", contextConfigPath) + } + }) + + t.Run("WithOverwriteFalse", func(t *testing.T) { + // Given a configHandler with existing config files + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "test-context" + + // Create existing files + rootConfigPath := filepath.Join(tempDir, "windsor.yaml") + os.WriteFile(rootConfigPath, []byte("existing content"), 0644) + + contextDir := filepath.Join(tempDir, "contexts", "test-context") + os.MkdirAll(contextDir, 0755) + contextConfigPath := filepath.Join(contextDir, "windsor.yaml") + os.WriteFile(contextConfigPath, []byte("existing context content"), 0644) + + // When SaveConfig is called with overwrite false + err := handler.SaveConfig(false) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the files should still contain the original content + rootContent, _ := os.ReadFile(rootConfigPath) + if string(rootContent) != "existing content" { + t.Errorf("Root config file was overwritten when it shouldn't have been") + } + + contextContent, _ := os.ReadFile(contextConfigPath) + if string(contextContent) != "existing context content" { + t.Errorf("Context config file was overwritten when it shouldn't have been") + } + }) + + t.Run("ShellNotInitialized", func(t *testing.T) { + // Given a configHandler without initialized shell + handler, _ := setup(t) + handler.(*configHandler).shell = nil + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + if err.Error() != "shell not initialized" { + t.Errorf("Expected 'shell not initialized' error, got %v", err) + } + }) + + t.Run("GetProjectRootError", func(t *testing.T) { + // Given a configHandler with shell that fails to get project root + handler, mocks := setup(t) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("project root failed") + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error retrieving project root") { + t.Errorf("Expected 'error retrieving project root' in error, got %v", err) + } + }) + + t.Run("RootConfigExists_SkipsRootCreation", func(t *testing.T) { + // Given a configHandler with existing root config + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "test-context" + + // Create existing root config + rootConfigPath := filepath.Join(tempDir, "windsor.yaml") + originalContent := "version: v1alpha1\nexisting: config" + os.WriteFile(rootConfigPath, []byte(originalContent), 0644) + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the root config should not be overwritten + content, _ := os.ReadFile(rootConfigPath) + if string(content) != originalContent { + t.Errorf("Root config was overwritten when it should be preserved") + } + }) + + t.Run("ContextExistsInRoot_SkipsContextCreation", func(t *testing.T) { + // Given a configHandler with context existing in root config + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "existing-context" + + // Setup config with existing context in root + handler.(*configHandler).config.Contexts = map[string]*v1alpha1.Context{ + "existing-context": { + Provider: stringPtr("local"), + }, + } + + // Create existing root config file + rootConfigPath := filepath.Join(tempDir, "windsor.yaml") + os.WriteFile(rootConfigPath, []byte("version: v1alpha1"), 0644) + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the context config should not be created + contextConfigPath := filepath.Join(tempDir, "contexts", "existing-context", "windsor.yaml") + if _, err := os.Stat(contextConfigPath); !os.IsNotExist(err) { + t.Errorf("Context config was created when it shouldn't have been") + } + }) + + t.Run("ContextConfigExists_SkipsContextCreation", func(t *testing.T) { + // Given a configHandler with existing context config file + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "test-context" + + // Create existing context config + contextDir := filepath.Join(tempDir, "contexts", "test-context") + os.MkdirAll(contextDir, 0755) + contextConfigPath := filepath.Join(contextDir, "windsor.yaml") + originalContent := "provider: local\nexisting: config" + os.WriteFile(contextConfigPath, []byte(originalContent), 0644) + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the context config should not be overwritten + content, _ := os.ReadFile(contextConfigPath) + if string(content) != originalContent { + t.Errorf("Context config was overwritten when it should be preserved") + } + }) + + t.Run("RootConfigMarshalError", func(t *testing.T) { + // Given a configHandler with marshal error for root config + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "test-context" + + // Override Stat to return not exists (so files will be created) + handler.(*configHandler).shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // Mock YamlMarshal to return error + handler.(*configHandler).shims.YamlMarshal = func(v interface{}) ([]byte, error) { + return nil, fmt.Errorf("marshal error") + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error marshalling root config") { + t.Errorf("Expected 'error marshalling root config' in error, got %v", err) + } + }) + + t.Run("RootConfigWriteError", func(t *testing.T) { + // Given a configHandler with write error for root config + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "test-context" + + // Override Stat to return not exists (so files will be created) + handler.(*configHandler).shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // Mock WriteFile to return error + handler.(*configHandler).shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + return fmt.Errorf("write error") + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error writing root config") { + t.Errorf("Expected 'error writing root config' in error, got %v", err) + } + }) + + t.Run("ContextDirectoryCreationError", func(t *testing.T) { + // Given a configHandler with directory creation error + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "test-context" + + // Override Stat to return not exists (so files will be created) + handler.(*configHandler).shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // Mock MkdirAll to return error + handler.(*configHandler).shims.MkdirAll = func(path string, perm os.FileMode) error { + return fmt.Errorf("mkdir error") + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error creating context directory") { + t.Errorf("Expected 'error creating context directory' in error, got %v", err) + } + }) + + t.Run("ContextConfigMarshalError", func(t *testing.T) { + // Given a configHandler with marshal error for context config + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "test-context" + + // Override Stat to return not exists (so files will be created) + handler.(*configHandler).shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // Track marshal calls to return error on second call (context config) + marshalCallCount := 0 + handler.(*configHandler).shims.YamlMarshal = func(v interface{}) ([]byte, error) { + marshalCallCount++ + if marshalCallCount == 2 { + return nil, fmt.Errorf("context marshal error") + } + return []byte("version: v1alpha1"), nil + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error marshalling context config") { + t.Errorf("Expected 'error marshalling context config' in error, got %v", err) + } + }) + + t.Run("ContextConfigWriteError", func(t *testing.T) { + // Given a configHandler with write error for context config + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "test-context" + + // Override Stat to return not exists (so files will be created) + handler.(*configHandler).shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // Track write calls to return error on second call (context config) + writeCallCount := 0 + handler.(*configHandler).shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + writeCallCount++ + if writeCallCount == 2 { + return fmt.Errorf("context write error") + } + return nil + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error writing context config") { + t.Errorf("Expected 'error writing context config' in error, got %v", err) + } + }) + + t.Run("BothFilesExist_NoOperationsPerformed", func(t *testing.T) { + // Given a configHandler with both root and context configs existing + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "test-context" + + // Create both existing files + rootConfigPath := filepath.Join(tempDir, "windsor.yaml") + originalRootContent := "version: v1alpha1\nexisting: root" + os.WriteFile(rootConfigPath, []byte(originalRootContent), 0644) + + contextDir := filepath.Join(tempDir, "contexts", "test-context") + os.MkdirAll(contextDir, 0755) + contextConfigPath := filepath.Join(contextDir, "windsor.yaml") + originalContextContent := "provider: local\nexisting: context" + os.WriteFile(contextConfigPath, []byte(originalContextContent), 0644) + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And both files should remain unchanged + rootContent, _ := os.ReadFile(rootConfigPath) + if string(rootContent) != originalRootContent { + t.Errorf("Root config was modified when it shouldn't have been") + } + + contextContent, _ := os.ReadFile(contextConfigPath) + if string(contextContent) != originalContextContent { + t.Errorf("Context config was modified when it shouldn't have been") + } + }) + + t.Run("EmptyVersion_UsesEmptyString", func(t *testing.T) { + // Given a configHandler with empty version + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "test-context" + handler.(*configHandler).config.Version = "" + + // Override shims to actually work with the real filesystem + handler.(*configHandler).shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + return os.WriteFile(filename, data, perm) + } + handler.(*configHandler).shims.MkdirAll = func(path string, perm os.FileMode) error { + return os.MkdirAll(path, perm) + } + handler.(*configHandler).shims.Stat = func(name string) (os.FileInfo, error) { + return os.Stat(name) + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the root config should contain empty version + rootConfigPath := filepath.Join(tempDir, "windsor.yaml") + content, _ := os.ReadFile(rootConfigPath) + if !strings.Contains(string(content), "version: \"\"") && !strings.Contains(string(content), "version:") { + t.Errorf("Expected version field in config, got: %s", string(content)) + } + }) + + t.Run("CreateContextConfigWhenNotInRootConfig", func(t *testing.T) { + // Given a configHandler with existing root config + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + // Create existing root config that doesn't include the current context + rootConfigPath := filepath.Join(tempDir, "windsor.yaml") + rootConfig := `version: v1alpha1 +contexts: + different-context: + provider: local` + os.WriteFile(rootConfigPath, []byte(rootConfig), 0644) + + // Load the existing root config + if err := handler.LoadConfig(rootConfigPath); err != nil { + t.Fatalf("Failed to load root config: %v", err) + } + + // Set the current context to one not defined in root config + handler.(*configHandler).context = "new-context" + handler.Set("contexts.new-context.provider", "local") + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the context config should be created since the context is not in root config + contextConfigPath := filepath.Join(tempDir, "contexts", "new-context", "windsor.yaml") + if _, err := handler.(*configHandler).shims.Stat(contextConfigPath); os.IsNotExist(err) { + t.Fatalf("Context config file was not created at %s, but should have been since context is not in root config", contextConfigPath) + } + + // And the root config should not be overwritten + rootContent, _ := os.ReadFile(rootConfigPath) + if !strings.Contains(string(rootContent), "different-context") { + t.Errorf("Root config appears to have been overwritten") + } + }) + + t.Run("CreateContextConfigWhenRootConfigExistsWithoutContexts", func(t *testing.T) { + // Given a configHandler with existing root config that has NO contexts section + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + // Create existing root config with only version (this is the most common case for user's issue) + rootConfigPath := filepath.Join(tempDir, "windsor.yaml") + rootConfig := `version: v1alpha1` + os.WriteFile(rootConfigPath, []byte(rootConfig), 0644) + + // Load the existing root config + if err := handler.LoadConfig(rootConfigPath); err != nil { + t.Fatalf("Failed to load root config: %v", err) + } + + // Set the current context to local (typical init scenario) + handler.(*configHandler).context = "local" + handler.Set("contexts.local.provider", "local") + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the context config should be created since the context is not in root config + contextConfigPath := filepath.Join(tempDir, "contexts", "local", "windsor.yaml") + if _, err := handler.(*configHandler).shims.Stat(contextConfigPath); os.IsNotExist(err) { + t.Fatalf("Context config file was not created at %s, but should have been since context is not in root config", contextConfigPath) + } + + // And the root config should not be overwritten + rootContent, _ := os.ReadFile(rootConfigPath) + if !strings.Contains(string(rootContent), "version: v1alpha1") { + t.Errorf("Root config appears to have been overwritten") + } + }) + + t.Run("SimulateInitPipelineWorkflow", func(t *testing.T) { + // Given a configHandler simulating the exact init pipeline workflow + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + // Create existing root config with only version (common in real scenarios) + rootConfigPath := filepath.Join(tempDir, "windsor.yaml") + rootConfig := `version: v1alpha1` + os.WriteFile(rootConfigPath, []byte(rootConfig), 0644) + + // Step 1: Load existing config like init pipeline does in BasePipeline.Initialize + if err := handler.LoadConfig(rootConfigPath); err != nil { + t.Fatalf("Failed to load root config: %v", err) + } + + // Step 2: Set context like init pipeline does + if err := handler.SetContext("local"); err != nil { + t.Fatalf("Failed to set context: %v", err) + } + + // Step 3: Set default configuration like init pipeline does + if err := handler.SetDefault(DefaultConfig); err != nil { + t.Fatalf("Failed to set default config: %v", err) + } + + // Step 4: Generate context ID like init pipeline does + if err := handler.GenerateContextID(); err != nil { + t.Fatalf("Failed to generate context ID: %v", err) + } + + // Step 5: Save config like init pipeline does + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the context config should be created since context is not defined in root + contextConfigPath := filepath.Join(tempDir, "contexts", "local", "windsor.yaml") + if _, err := handler.(*configHandler).shims.Stat(contextConfigPath); os.IsNotExist(err) { + t.Errorf("Context config file was not created at %s, this reproduces the user's issue", contextConfigPath) + } + + // And the root config should not be overwritten + rootContent, _ := os.ReadFile(rootConfigPath) + if !strings.Contains(string(rootContent), "version: v1alpha1") { + t.Errorf("Root config appears to have been overwritten") + } + }) + + t.Run("DebugSaveConfigLogic", func(t *testing.T) { + // Given a configHandler with existing root config with no contexts + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + // Create existing root config with only version (user's scenario) + rootConfigPath := filepath.Join(tempDir, "windsor.yaml") + rootConfig := `version: v1alpha1` + os.WriteFile(rootConfigPath, []byte(rootConfig), 0644) + + // Load the existing root config + if err := handler.LoadConfig(rootConfigPath); err != nil { + t.Fatalf("Failed to load root config: %v", err) + } + + // Set context and config values + handler.(*configHandler).context = "local" + handler.Set("contexts.local.provider", "local") + + // Debug: Check what's in the config before SaveConfig + t.Logf("Config.Contexts before SaveConfig: %+v", handler.(*configHandler).config.Contexts) + if handler.(*configHandler).config.Contexts != nil { + if _, exists := handler.(*configHandler).config.Contexts["local"]; exists { + t.Logf("local context exists in root config") + } else { + t.Logf("local context does NOT exist in root config") + } + } else { + t.Logf("Config.Contexts is nil") + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // Check if context config was created + contextConfigPath := filepath.Join(tempDir, "contexts", "local", "windsor.yaml") + if _, err := handler.(*configHandler).shims.Stat(contextConfigPath); os.IsNotExist(err) { + t.Logf("Context config file was NOT created at %s", contextConfigPath) + } else { + t.Logf("Context config file WAS created at %s", contextConfigPath) + } + }) + + t.Run("ContextNotSetInRootConfigInitially", func(t *testing.T) { + // Given a configHandler that mimics the exact init flow + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + // Create existing root config with only version (user's scenario) + rootConfigPath := filepath.Join(tempDir, "windsor.yaml") + rootConfig := `version: v1alpha1` + os.WriteFile(rootConfigPath, []byte(rootConfig), 0644) + + // Load the existing root config + if err := handler.LoadConfig(rootConfigPath); err != nil { + t.Fatalf("Failed to load root config: %v", err) + } + + // Set the context but DON'T call Set() to add context data yet + handler.(*configHandler).context = "local" + + // Debug: Check state before adding any context data + t.Logf("Config.Contexts before setting any context data: %+v", handler.(*configHandler).config.Contexts) + + // When SaveConfig is called without any context configuration being set + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // Check if context config was created + contextConfigPath := filepath.Join(tempDir, "contexts", "local", "windsor.yaml") + if _, err := handler.(*configHandler).shims.Stat(contextConfigPath); os.IsNotExist(err) { + t.Errorf("Context config file was NOT created at %s - this reproduces the user's issue", contextConfigPath) + } else { + t.Logf("Context config file WAS created at %s", contextConfigPath) + } + }) + + t.Run("ReproduceActualIssue", func(t *testing.T) { + // Given a real-world scenario where a root windsor.yaml exists with only version + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + // Create existing root config with only version (exact user scenario) + rootConfigPath := filepath.Join(tempDir, "windsor.yaml") + rootConfig := `version: v1alpha1` + os.WriteFile(rootConfigPath, []byte(rootConfig), 0644) + + // Step 1: Load existing config like init pipeline does + if err := handler.LoadConfig(rootConfigPath); err != nil { + t.Fatalf("Failed to load root config: %v", err) + } + + // Step 2: Set context + if err := handler.SetContext("local"); err != nil { + t.Fatalf("Failed to set context: %v", err) + } + + // Step 3: Set default configuration (this would add context data) + if err := handler.SetDefault(DefaultConfig); err != nil { + t.Fatalf("Failed to set default config: %v", err) + } + + // Step 4: Generate context ID + if err := handler.GenerateContextID(); err != nil { + t.Fatalf("Failed to generate context ID: %v", err) + } + + // Debug: Check config state before SaveConfig + t.Logf("Config before SaveConfig: %+v", handler.(*configHandler).config) + if handler.(*configHandler).config.Contexts != nil { + if ctx, exists := handler.(*configHandler).config.Contexts["local"]; exists { + t.Logf("local context exists in config: %+v", ctx) + } else { + t.Logf("local context does NOT exist in config") + } + } else { + t.Logf("Config.Contexts is nil") + } + + // Step 5: Save config (the critical call) + err := handler.SaveConfig() + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // Check if context config file was created + contextConfigPath := filepath.Join(tempDir, "contexts", "local", "windsor.yaml") + if _, err := handler.(*configHandler).shims.Stat(contextConfigPath); os.IsNotExist(err) { + t.Errorf("Context config file was NOT created at %s - this is the bug!", contextConfigPath) + } else { + content, _ := os.ReadFile(contextConfigPath) + t.Logf("Context config file WAS created with content: %s", string(content)) + } + + // Check root config wasn't overwritten + rootContent, _ := os.ReadFile(rootConfigPath) + if !strings.Contains(string(rootContent), "version: v1alpha1") { + t.Errorf("Root config appears to have been overwritten: %s", string(rootContent)) + } + }) + + t.Run("SavesContextValuesWhenLoaded", func(t *testing.T) { + // Given a configHandler with loaded contextValues + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + // Use real filesystem operations for this test + handler.(*configHandler).shims = NewShims() + handler.(*configHandler).shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test-context" + } + return "" + } + + handler.(*configHandler).context = "test-context" + handler.(*configHandler).loaded = true + handler.(*configHandler).contextValues = map[string]any{ + "test_key": "test_value", + "number": 42, + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And values.yaml should be created with the context values + valuesPath := filepath.Join(tempDir, "contexts", "test-context", "values.yaml") + if _, err := os.Stat(valuesPath); os.IsNotExist(err) { + t.Fatalf("values.yaml was not created at %s", valuesPath) + } + + // And the content should match contextValues + content, err := os.ReadFile(valuesPath) + if err != nil { + t.Fatalf("Failed to read values.yaml: %v", err) + } + if !strings.Contains(string(content), "test_key") { + t.Errorf("values.yaml should contain 'test_key', got: %s", string(content)) + } + if !strings.Contains(string(content), "test_value") { + t.Errorf("values.yaml should contain 'test_value', got: %s", string(content)) + } + }) + + t.Run("SkipsSavingContextValuesWhenNotLoaded", func(t *testing.T) { + // Given a configHandler with contextValues but not loaded + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "test-context" + handler.(*configHandler).loaded = false + handler.(*configHandler).contextValues = map[string]any{ + "test_key": "test_value", + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And values.yaml should NOT be created + valuesPath := filepath.Join(tempDir, "contexts", "test-context", "values.yaml") + if _, err := os.Stat(valuesPath); !os.IsNotExist(err) { + t.Errorf("values.yaml should not have been created when not loaded") + } + }) + + t.Run("SkipsSavingContextValuesWhenNil", func(t *testing.T) { + // Given a configHandler with nil contextValues + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "test-context" + handler.(*configHandler).loaded = true + handler.(*configHandler).contextValues = nil + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And values.yaml should NOT be created + valuesPath := filepath.Join(tempDir, "contexts", "test-context", "values.yaml") + if _, err := os.Stat(valuesPath); !os.IsNotExist(err) { + t.Errorf("values.yaml should not have been created when contextValues is nil") + } + }) + + t.Run("SkipsSavingContextValuesWhenEmpty", func(t *testing.T) { + // Given a configHandler with empty contextValues + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.(*configHandler).context = "test-context" + handler.(*configHandler).loaded = true + handler.(*configHandler).contextValues = map[string]any{} + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And values.yaml should NOT be created + valuesPath := filepath.Join(tempDir, "contexts", "test-context", "values.yaml") + if _, err := os.Stat(valuesPath); !os.IsNotExist(err) { + t.Errorf("values.yaml should not have been created when contextValues is empty") + } + }) + + t.Run("SaveContextValuesError", func(t *testing.T) { + // Given a configHandler with contextValues and a write error + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + // Use real filesystem operations but mock WriteFile to fail for values.yaml + handler.(*configHandler).shims = NewShims() + handler.(*configHandler).shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test-context" + } + return "" + } + handler.(*configHandler).shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + if strings.Contains(filename, "values.yaml") { + return fmt.Errorf("write error") + } + return os.WriteFile(filename, data, perm) + } + + handler.(*configHandler).context = "test-context" + handler.(*configHandler).loaded = true + handler.(*configHandler).contextValues = map[string]any{ + "test_key": "test_value", + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error saving values.yaml") { + t.Errorf("Expected 'error saving values.yaml' in error, got %v", err) + } + }) +} + +func TestConfigHandler_GetString(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + return handler, mocks + } + + t.Run("WithNonExistentKey", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "default" + + // When getting a non-existent key + got := handler.GetString("nonExistentKey") + + // Then an empty string should be returned + expectedValue := "" + if got != expectedValue { + t.Errorf("GetString() = %v, expected %v", got, expectedValue) + } + }) + + t.Run("GetStringWithDefaultValue", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "default" + + // When getting a non-existent key with a default value + defaultValue := "defaultString" + value := handler.GetString("non.existent.key", defaultValue) + + // Then the default value should be returned + if value != defaultValue { + t.Errorf("Expected value '%v', got '%v'", defaultValue, value) + } + }) + + t.Run("WithExistingKey", func(t *testing.T) { + // Given a handler with a context and existing key-value pair + handler, _ := setup(t) + handler.(*configHandler).context = "default" + handler.(*configHandler).config = v1alpha1.Config{ + Contexts: map[string]*v1alpha1.Context{ + "default": { + Environment: map[string]string{ + "existingKey": "existingValue", + }, + }, + }, + } + + // When getting an existing key + got := handler.GetString("environment.existingKey") + + // Then the value should be returned as a string + expectedValue := "existingValue" + if got != expectedValue { + t.Errorf("GetString() = %v, expected %v", got, expectedValue) + } + }) +} + +func TestConfigHandler_GetInt(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + return handler, mocks + } + + t.Run("WithExistingNonIntegerKey", func(t *testing.T) { + // Given a handler with a context and non-integer value + handler, _ := setup(t) + handler.(*configHandler).context = "default" + handler.(*configHandler).config = v1alpha1.Config{ + Contexts: map[string]*v1alpha1.Context{ + "default": { + AWS: &aws.AWSConfig{ + AWSEndpointURL: ptrString("notAnInt"), + }, + }, + }, + } + + // When getting a key with non-integer value + value := handler.GetInt("aws.aws_endpoint_url") + + // Then the default integer value should be returned + expectedValue := 0 + if value != expectedValue { + t.Errorf("Expected value %v, got %v", expectedValue, value) + } + }) + + t.Run("WithNonExistentKey", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "default" + + // When getting a non-existent key + value := handler.GetInt("nonExistentKey") + + // Then the default integer value should be returned + expectedValue := 0 + if value != expectedValue { + t.Errorf("Expected value %v, got %v", expectedValue, value) + } + }) + + t.Run("WithNonExistentKeyAndDefaultValue", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "default" + + // When getting a non-existent key with a default value + got := handler.GetInt("nonExistentKey", 99) + + // Then the provided default value should be returned + expectedValue := 99 + if got != expectedValue { + t.Errorf("GetInt() = %v, expected %v", got, expectedValue) + } + }) + + t.Run("WithExistingIntegerKey", func(t *testing.T) { + // Given a handler with a context and integer value + handler, _ := setup(t) + handler.(*configHandler).context = "default" + handler.(*configHandler).config = v1alpha1.Config{ + Contexts: map[string]*v1alpha1.Context{ + "default": { + Cluster: &cluster.ClusterConfig{ + ControlPlanes: cluster.NodeGroupConfig{ + Count: ptrInt(3), + }, + }, + }, + }, + } + + // When getting an existing integer key + got := handler.GetInt("cluster.controlplanes.count") + + // Then the integer value should be returned + expectedValue := 3 + if got != expectedValue { + t.Errorf("GetInt() = %v, expected %v", got, expectedValue) + } + }) +} + +func TestConfigHandler_GetBool(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + return handler, mocks + } + + t.Run("WithExistingBooleanKey", func(t *testing.T) { + // Given a handler with a context and boolean value + handler, _ := setup(t) + handler.(*configHandler).context = "default" + handler.(*configHandler).config = v1alpha1.Config{ + Contexts: map[string]*v1alpha1.Context{ + "default": { + AWS: &aws.AWSConfig{ + Enabled: ptrBool(true), + }, + }, + }, + } + + // When getting an existing boolean key + got := handler.GetBool("aws.enabled") + + // Then the boolean value should be returned + expectedValue := true + if got != expectedValue { + t.Errorf("GetBool() = %v, expected %v", got, expectedValue) + } + }) + + t.Run("WithExistingNonBooleanKey", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "default" + + // When setting a non-boolean value for the key + handler.Set("contexts.default.aws.aws_endpoint_url", "notABool") + + // When getting an existing key with a non-boolean value + value := handler.GetBool("aws.aws_endpoint_url") + expectedValue := false + + // Then the default boolean value should be returned + if value != expectedValue { + t.Errorf("Expected value %v, got %v", expectedValue, value) + } + }) + + t.Run("WithNonExistentKey", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "default" + + // When getting a non-existent key + value := handler.GetBool("nonExistentKey") + expectedValue := false + + // Then the default boolean value should be returned + if value != expectedValue { + t.Errorf("Expected value %v, got %v", expectedValue, value) + } + }) + + t.Run("WithNonExistentKeyAndDefaultValue", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "default" + + // When getting a non-existent key with a default value + got := handler.GetBool("nonExistentKey", false) + + // Then the provided default value should be returned + expectedValue := false + if got != expectedValue { + t.Errorf("GetBool() = %v, expected %v", got, expectedValue) + } + }) +} + +func TestConfigHandler_GetStringSlice(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + return handler, mocks + } + + t.Run("Success", func(t *testing.T) { + // Given a handler with a context containing a slice value + handler, _ := setup(t) + handler.(*configHandler).context = "default" + handler.(*configHandler).config.Contexts = map[string]*v1alpha1.Context{ + "default": { + Cluster: &cluster.ClusterConfig{ + Workers: cluster.NodeGroupConfig{ + HostPorts: []string{"50000:50002/tcp", "30080:8080/tcp", "30443:8443/tcp"}, + }, + }, + }, + } + + // When retrieving the slice value using GetStringSlice + value := handler.GetStringSlice("cluster.workers.hostports") + + // Then the returned slice should match the expected slice + expectedSlice := []string{"50000:50002/tcp", "30080:8080/tcp", "30443:8443/tcp"} + if !reflect.DeepEqual(value, expectedSlice) { + t.Errorf("Expected GetStringSlice to return %v, got %v", expectedSlice, value) + } + }) + + t.Run("WithNonExistentKey", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "default" + + // When retrieving a non-existent key using GetStringSlice + value := handler.GetStringSlice("nonExistentKey") + + // Then the returned value should be an empty slice + if len(value) != 0 { + t.Errorf("Expected GetStringSlice with non-existent key to return an empty slice, got %v", value) + } + }) + + t.Run("WithNonExistentKeyAndDefaultValue", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "default" + defaultValue := []string{"default1", "default2"} + + // When retrieving a non-existent key with a default value + value := handler.GetStringSlice("nonExistentKey", defaultValue) + + // Then the returned value should match the default value + if !reflect.DeepEqual(value, defaultValue) { + t.Errorf("Expected GetStringSlice with default to return %v, got %v", defaultValue, value) + } + }) + + t.Run("TypeMismatch", func(t *testing.T) { + // Given a handler where the key exists but is not a slice + handler, _ := setup(t) + handler.(*configHandler).context = "default" + handler.Set("contexts.default.cluster.workers.hostports", 123) // Set an int instead of slice + + // When retrieving the value using GetStringSlice + value := handler.GetStringSlice("cluster.workers.hostports") + + // Then the returned slice should be empty + if len(value) != 0 { + t.Errorf("Expected empty slice due to type mismatch, got %v", value) + } + }) +} + +func TestConfigHandler_GetStringMap(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + handler.(*configHandler).shims = mocks.Shims + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + return handler, mocks + } + + t.Run("Success", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "default" + handler.(*configHandler).config.Contexts = map[string]*v1alpha1.Context{ + "default": { + Environment: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + }, + }, + } + + // When retrieving the map value using GetStringMap + value := handler.GetStringMap("environment") + + // Then the returned map should match the expected map + expectedMap := map[string]string{"KEY1": "value1", "KEY2": "value2"} + if !reflect.DeepEqual(value, expectedMap) { + t.Errorf("Expected GetStringMap to return %v, got %v", expectedMap, value) + } + }) + + t.Run("WithNonExistentKey", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "default" + + // When retrieving a non-existent key using GetStringMap + value := handler.GetStringMap("nonExistentKey") + + // Then the returned value should be an empty map + if !reflect.DeepEqual(value, map[string]string{}) { + t.Errorf("Expected GetStringMap with non-existent key to return an empty map, got %v", value) + } + }) + + t.Run("WithNonExistentKeyAndDefaultValue", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.(*configHandler).context = "default" + defaultValue := map[string]string{"defaultKey1": "defaultValue1", "defaultKey2": "defaultValue2"} + + // When retrieving a non-existent key with a default value + value := handler.GetStringMap("nonExistentKey", defaultValue) + + // Then the returned value should match the default value + if !reflect.DeepEqual(value, defaultValue) { + t.Errorf("Expected GetStringMap with default to return %v, got %v", defaultValue, value) + } + }) + + t.Run("TypeMismatch", func(t *testing.T) { + // Given a handler where the key exists but is not a map[string]string + handler, _ := setup(t) + handler.(*configHandler).context = "default" + handler.Set("contexts.default.environment", 123) // Set an int instead of map + + // When retrieving the value using GetStringMap + value := handler.GetStringMap("environment") + + // Then the returned map should be empty + if len(value) != 0 { + t.Errorf("Expected empty map due to type mismatch, got %v", value) + } + }) +} + +func TestConfigHandler_GetConfig(t *testing.T) { + setup := func(t *testing.T) (ConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewConfigHandler(mocks.Injector) + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + handler.(*configHandler).shims = mocks.Shims + return handler, mocks + } + + t.Run("EmptyContext", func(t *testing.T) { + // Given a handler with no context set + handler, _ := setup(t) + + // When getting the config + config := handler.GetConfig() + + // Then the default config should be returned + if config == nil { + t.Fatal("Expected default config, got nil") + } + }) + + t.Run("NonExistentContext", func(t *testing.T) { + // Given a handler with a non-existent context + handler, _ := setup(t) + handler.(*configHandler).context = "nonexistent" + + // When getting the config + config := handler.GetConfig() + + // Then the default config should be returned + if config == nil { + t.Fatal("Expected default config, got nil") + } + }) + + t.Run("ExistingContext", func(t *testing.T) { + // Given a handler with an existing context + handler, _ := setup(t) + handler.(*configHandler).context = "test" + + // And a context with environment variables + handler.(*configHandler).config.Contexts = map[string]*v1alpha1.Context{ + "test": { + Environment: map[string]string{ + "TEST_VAR": "test_value", + }, + }, + } + + // And default context with different environment variables + handler.(*configHandler).defaultContextConfig = v1alpha1.Context{ + Environment: map[string]string{ + "DEFAULT_VAR": "default_value", + }, + } + + // When getting the config + config := handler.GetConfig() + + // Then the merged config should be returned + if config == nil { + t.Fatal("Expected merged config, got nil") + } + + // And it should contain both environment variables + if config.Environment["TEST_VAR"] != "test_value" { + t.Errorf("Expected TEST_VAR to be 'test_value', got '%s'", config.Environment["TEST_VAR"]) + } + if config.Environment["DEFAULT_VAR"] != "default_value" { + t.Errorf("Expected DEFAULT_VAR to be 'default_value', got '%s'", config.Environment["DEFAULT_VAR"]) + } + }) + + t.Run("ContextOverridesDefault", func(t *testing.T) { + // Given a handler with an existing context + handler, _ := setup(t) + handler.(*configHandler).context = "test" + + // And a context with environment variables that override defaults + handler.(*configHandler).config.Contexts = map[string]*v1alpha1.Context{ + "test": { + Environment: map[string]string{ + "SHARED_VAR": "context_value", + }, + }, + } + + // And default context with the same environment variable + handler.(*configHandler).defaultContextConfig = v1alpha1.Context{ + Environment: map[string]string{ + "SHARED_VAR": "default_value", + }, + } + + // When getting the config + config := handler.GetConfig() + + // Then the context value should override the default + if config.Environment["SHARED_VAR"] != "context_value" { + t.Errorf("Expected SHARED_VAR to be 'context_value', got '%s'", config.Environment["SHARED_VAR"]) + } + }) +} diff --git a/pkg/config/config_handler_test.go b/pkg/config/config_handler_test.go deleted file mode 100644 index f628ead16..000000000 --- a/pkg/config/config_handler_test.go +++ /dev/null @@ -1,993 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/windsorcli/cli/api/v1alpha1" - "github.com/windsorcli/cli/api/v1alpha1/cluster" - "github.com/windsorcli/cli/pkg/di" - "github.com/windsorcli/cli/pkg/secrets" - "github.com/windsorcli/cli/pkg/shell" -) - -// ============================================================================= -// Test Setup -// ============================================================================= - -type Mocks struct { - Injector di.Injector - ConfigHandler *ConfigHandler - Shell *shell.MockShell - SecretsProvider *secrets.MockSecretsProvider - Shims *Shims -} - -type SetupOptions struct { - Injector di.Injector - ConfigHandler ConfigHandler - ConfigStr string -} - -func setupShims(t *testing.T) *Shims { - t.Helper() - shims := NewShims() - shims.Stat = func(name string) (os.FileInfo, error) { - return nil, nil - } - shims.ReadFile = func(filename string) ([]byte, error) { - return []byte("dummy: data"), nil - } - shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - return nil - } - shims.Getenv = func(key string) string { - if key == "WINDSOR_CONTEXT" { - return "test" - } - return "" - } - return shims -} - -// Global test setup helper that creates a temporary directory and mocks -// This is used by most test functions to establish a clean test environment -func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { - t.Helper() - - // Store original working directory - origDir, err := os.Getwd() - if err != nil { - t.Fatalf("Failed to get working directory: %v", err) - } - - // Create temp dir using testing.TempDir() - tmpDir := t.TempDir() - if err := os.Chdir(tmpDir); err != nil { - t.Fatalf("Failed to change to temp directory: %v", err) - } - - os.Setenv("WINDSOR_PROJECT_ROOT", tmpDir) - - var injector di.Injector - if len(opts) > 0 { - injector = opts[0].Injector - } else { - injector = di.NewInjector() - } - - mockShell := shell.NewMockShell(injector) - mockShell.GetProjectRootFunc = func() (string, error) { - return tmpDir, nil - } - injector.Register("shell", mockShell) - - mockSecretsProvider := secrets.NewMockSecretsProvider(injector) - injector.Register("secretsProvider", mockSecretsProvider) - - mockConfigHandler := NewMockConfigHandler() - mockConfigHandler.GetConfigFunc = func() *v1alpha1.Context { - return &v1alpha1.Context{} - } - injector.Register("configHandler", mockConfigHandler) - - mockShims := setupShims(t) - - t.Cleanup(func() { - os.Unsetenv("WINDSOR_PROJECT_ROOT") - - if err := os.Chdir(origDir); err != nil { - t.Logf("Warning: Failed to change back to original directory: %v", err) - } - }) - - return &Mocks{ - Shell: mockShell, - SecretsProvider: mockSecretsProvider, - Injector: injector, - Shims: mockShims, - } -} - -// ============================================================================= -// Test Public Methods -// ============================================================================= - -// TestBaseConfigHandler_Initialize tests the initialization of the BaseConfigHandler -func TestBaseConfigHandler_Initialize(t *testing.T) { - setup := func(t *testing.T) (*BaseConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewBaseConfigHandler(mocks.Injector) - handler.shims = mocks.Shims - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a properly configured BaseConfigHandler - handler, _ := setup(t) - - // When Initialize is called - err := handler.Initialize() - - // Then no error should be returned - if err != nil { - t.Errorf("Expected error = %v, got = %v", nil, err) - } - }) - - t.Run("ErrorResolvingShell", func(t *testing.T) { - // Given a BaseConfigHandler with a missing shell component - handler, mocks := setup(t) - mocks.Injector.Register("shell", nil) - - // When Initialize is called - err := handler.Initialize() - - // Then an error should be returned - if err == nil { - t.Errorf("Expected error when resolving shell, got nil") - } - }) -} - -// TestConfigHandler_IsLoaded tests the IsLoaded method of the ConfigHandler -func TestConfigHandler_IsLoaded(t *testing.T) { - setup := func(t *testing.T) (*BaseConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewBaseConfigHandler(mocks.Injector) - handler.shims = mocks.Shims - return handler, mocks - } - - t.Run("IsLoadedTrue", func(t *testing.T) { - // Given a ConfigHandler with loaded=true - handler, _ := setup(t) - handler.loaded = true - - // When IsLoaded is called - isLoaded := handler.IsLoaded() - - // Then it should return true - if !isLoaded { - t.Errorf("expected IsLoaded to return true, got false") - } - }) - - t.Run("IsLoadedFalse", func(t *testing.T) { - // Given a ConfigHandler with loaded=false - handler, _ := setup(t) - handler.loaded = false - - // When IsLoaded is called - isLoaded := handler.IsLoaded() - - // Then it should return false - if isLoaded { - t.Errorf("expected IsLoaded to return false, got true") - } - }) -} - -// TestConfigHandler_IsContextConfigLoaded tests the IsContextConfigLoaded method of the ConfigHandler -func TestConfigHandler_IsContextConfigLoaded(t *testing.T) { - setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewYamlConfigHandler(mocks.Injector) - handler.shims = mocks.Shims - return handler, mocks - } - - t.Run("ReturnsFalseWhenBaseConfigNotLoaded", func(t *testing.T) { - // Given a YamlConfigHandler with base config not loaded - handler, _ := setup(t) - handler.BaseConfigHandler.loaded = false - - // When IsContextConfigLoaded is called - isLoaded := handler.IsContextConfigLoaded() - - // Then it should return false - if isLoaded { - t.Errorf("expected IsContextConfigLoaded to return false when base config not loaded, got true") - } - }) - - t.Run("ReturnsFalseWhenContextNotSet", func(t *testing.T) { - // Given a YamlConfigHandler with base config loaded but no context set - handler, mocks := setup(t) - handler.BaseConfigHandler.loaded = true - - // Mock GetContext to return empty string - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.ReadFile = func(filename string) ([]byte, error) { - return []byte(""), nil // Empty context - } - mocks.Shims.Getenv = func(key string) string { - return "" // No environment variable - } - handler.Initialize() - - // When IsContextConfigLoaded is called - isLoaded := handler.IsContextConfigLoaded() - - // Then it should return false - if isLoaded { - t.Errorf("expected IsContextConfigLoaded to return false when context not set, got true") - } - }) - - t.Run("ReturnsFalseWhenContextsMapIsNil", func(t *testing.T) { - // Given a YamlConfigHandler with base config loaded and context set, but no contexts map - handler, mocks := setup(t) - handler.BaseConfigHandler.loaded = true - handler.config = v1alpha1.Config{ - Contexts: nil, // No contexts map - } - - // Mock GetContext to return a context name - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.ReadFile = func(filename string) ([]byte, error) { - return []byte("test-context"), nil - } - mocks.Shims.Getenv = func(key string) string { - return "" - } - handler.Initialize() - - // When IsContextConfigLoaded is called - isLoaded := handler.IsContextConfigLoaded() - - // Then it should return false - if isLoaded { - t.Errorf("expected IsContextConfigLoaded to return false when contexts map is nil, got true") - } - }) - - t.Run("ReturnsFalseWhenContextDoesNotExist", func(t *testing.T) { - // Given a YamlConfigHandler with base config loaded, context set, but context doesn't exist in map - handler, mocks := setup(t) - handler.BaseConfigHandler.loaded = true - handler.config = v1alpha1.Config{ - Contexts: map[string]*v1alpha1.Context{ - "other-context": { - // Empty context but valid - }, - }, - } - - // Mock GetContext to return a context name that doesn't exist - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.ReadFile = func(filename string) ([]byte, error) { - return []byte("test-context"), nil - } - mocks.Shims.Getenv = func(key string) string { - return "" - } - handler.Initialize() - - // When IsContextConfigLoaded is called - isLoaded := handler.IsContextConfigLoaded() - - // Then it should return false - if isLoaded { - t.Errorf("expected IsContextConfigLoaded to return false when context doesn't exist, got true") - } - }) - - t.Run("ReturnsFalseWhenContextExistsButIsNil", func(t *testing.T) { - // Given a YamlConfigHandler with base config loaded, context set, but context value is nil - handler, mocks := setup(t) - handler.BaseConfigHandler.loaded = true - handler.config = v1alpha1.Config{ - Contexts: map[string]*v1alpha1.Context{ - "test-context": nil, // Context exists but is nil - }, - } - - // Mock GetContext to return the context name - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.ReadFile = func(filename string) ([]byte, error) { - return []byte("test-context"), nil - } - mocks.Shims.Getenv = func(key string) string { - return "" - } - handler.Initialize() - - // When IsContextConfigLoaded is called - isLoaded := handler.IsContextConfigLoaded() - - // Then it should return false - if isLoaded { - t.Errorf("expected IsContextConfigLoaded to return false when context exists but is nil, got true") - } - }) - - t.Run("ReturnsTrueWhenContextExistsAndIsValid", func(t *testing.T) { - // Given a YamlConfigHandler with base config loaded, context set, and valid context config - handler, mocks := setup(t) - handler.BaseConfigHandler.loaded = true - handler.config = v1alpha1.Config{ - Contexts: map[string]*v1alpha1.Context{ - "test-context": { - Cluster: &cluster.ClusterConfig{ - Workers: cluster.NodeGroupConfig{ - Volumes: []string{"/var/blah"}, - }, - }, - }, - }, - } - - // Mock GetContext to return the context name - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.ReadFile = func(filename string) ([]byte, error) { - return []byte("test-context"), nil - } - mocks.Shims.Getenv = func(key string) string { - return "" - } - handler.Initialize() - - // Mark the context as loaded from existing files - handler.loadedContexts["test-context"] = true - - // When IsContextConfigLoaded is called - isLoaded := handler.IsContextConfigLoaded() - - // Then it should return true - if !isLoaded { - t.Errorf("expected IsContextConfigLoaded to return true when context exists and is valid, got false") - } - }) - - t.Run("ReturnsTrueWhenContextExistsAndHasEmptyConfig", func(t *testing.T) { - // Given a YamlConfigHandler with base config loaded, context set, and context config exists but is empty - handler, mocks := setup(t) - handler.BaseConfigHandler.loaded = true - handler.config = v1alpha1.Config{ - Contexts: map[string]*v1alpha1.Context{ - "test-context": { - // Empty config but still valid - }, - }, - } - - // Mock GetContext to return the context name - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.ReadFile = func(filename string) ([]byte, error) { - return []byte("test-context"), nil - } - mocks.Shims.Getenv = func(key string) string { - return "" - } - handler.Initialize() - - // Mark the context as loaded from existing files - handler.loadedContexts["test-context"] = true - - // When IsContextConfigLoaded is called - isLoaded := handler.IsContextConfigLoaded() - - // Then it should return true (empty config is still valid) - if !isLoaded { - t.Errorf("expected IsContextConfigLoaded to return true when context exists with empty config, got false") - } - }) - - t.Run("IsContextConfigLoadedReturnsTrueWhenContextLoadedFromAnySource", func(t *testing.T) { - // Given a YamlConfigHandler with base config loaded and context set - handler, mocks := setup(t) - handler.BaseConfigHandler.loaded = true - - // Mock GetContext to return a context name - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.ReadFile = func(filename string) ([]byte, error) { - return []byte("test-context"), nil - } - mocks.Shims.Getenv = func(key string) string { - return "" - } - handler.Initialize() - - // When a context is loaded from root config - handler.loadedContexts["test-context"] = true - - // Then IsContextConfigLoaded should return true - if !handler.IsContextConfigLoaded() { - t.Error("Expected IsContextConfigLoaded to return true when context is loaded from root config") - } - - // When a context is loaded from context-specific file - handler.loadedContexts["another-context"] = true - handler.config.Contexts = map[string]*v1alpha1.Context{ - "another-context": { - // Empty config but still valid - }, - } - - // Then IsContextConfigLoaded should return true for that context too - handler.SetContext("another-context") - if !handler.IsContextConfigLoaded() { - t.Error("Expected IsContextConfigLoaded to return true when context is loaded from context-specific file") - } - - // When a context is not loaded from any source - // We need to mock the file system to return a different context - mocks.Shims.ReadFile = func(filename string) ([]byte, error) { - return []byte("unloaded-context"), nil - } - // Re-initialize to pick up the new context - handler.Initialize() - - contextName := handler.GetContext() - if contextName != "unloaded-context" { - t.Errorf("Expected context to be 'unloaded-context', got '%s'", contextName) - } - if handler.IsContextConfigLoaded() { - t.Error("Expected IsContextConfigLoaded to return false when context is not loaded from any source") - } - }) -} - -// TestBaseConfigHandler_GetContext tests the GetContext method of the BaseConfigHandler -func TestBaseConfigHandler_GetContext(t *testing.T) { - setup := func(t *testing.T) (*BaseConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewBaseConfigHandler(mocks.Injector) - handler.shims = mocks.Shims - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a properly configured BaseConfigHandler with a context file - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.ReadFile = func(filename string) ([]byte, error) { - if filename == filepath.Join("/mock/project/root", ".windsor", "context") { - return []byte("test-context"), nil - } - return nil, fmt.Errorf("file not found") - } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - if path == filepath.Join("/mock/project/root", ".windsor") { - return nil - } - return fmt.Errorf("error creating directory") - } - mocks.Shims.Getenv = func(key string) string { - return "" - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When GetContext is called - contextValue := handler.GetContext() - - // Then it should return the context from the file - if contextValue != "test-context" { - t.Errorf("expected context 'test-context', got %s", contextValue) - } - }) - - t.Run("EmptyContextFile", func(t *testing.T) { - // Given a BaseConfigHandler with an empty context file - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.ReadFile = func(filename string) ([]byte, error) { - if filename == filepath.Join("/mock/project/root", ".windsor", "context") { - return []byte(""), nil - } - return nil, fmt.Errorf("file not found") - } - mocks.Shims.Getenv = func(key string) string { - return "" - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When GetContext is called - contextValue := handler.GetContext() - - // Then it should return an empty string - if contextValue != "" { - t.Errorf("expected empty context for empty file, got %s", contextValue) - } - }) - - t.Run("GetContextDefaultsToLocal", func(t *testing.T) { - // Given a BaseConfigHandler with no context file - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.ReadFile = func(_ string) ([]byte, error) { - return nil, fmt.Errorf("file not found") - } - mocks.Shims.Getenv = func(key string) string { - return "" - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When GetContext is called - actualContext := handler.GetContext() - - // Then it should default to "local" - expectedContext := "local" - if actualContext != expectedContext { - t.Errorf("Expected context %q, got %q", expectedContext, actualContext) - } - }) - - t.Run("GetProjectRootError", func(t *testing.T) { - // Given a BaseConfigHandler with a GetProjectRoot error - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "", fmt.Errorf("project root error") - } - mocks.Shims.Getenv = func(key string) string { - return "" - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When GetContext is called - contextValue := handler.GetContext() - - // Then it should default to "local" - if contextValue != "local" { - t.Errorf("expected context 'local' when GetProjectRoot fails, got %s", contextValue) - } - }) - - t.Run("ContextAlreadyDefined", func(t *testing.T) { - // Given a BaseConfigHandler with a predefined context in environment - handler, mocks := setup(t) - mocks.Shims.Getenv = func(key string) string { - if key == "WINDSOR_CONTEXT" { - return "predefined-context" - } - return "" - } - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When GetContext is called - actualContext := handler.GetContext() - - // Then it should return the predefined context - expectedContext := "predefined-context" - if actualContext != expectedContext { - t.Errorf("Expected context %q, got %q", expectedContext, actualContext) - } - }) -} - -// TestConfigHandler_SetContext tests the SetContext method of the ConfigHandler -func TestConfigHandler_SetContext(t *testing.T) { - setup := func(t *testing.T) (*BaseConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewBaseConfigHandler(mocks.Injector) - handler.shims = mocks.Shims - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a properly configured BaseConfigHandler - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - if path == filepath.Join("/mock/project/root", ".windsor") { - return nil - } - return fmt.Errorf("error creating directory") - } - mocks.Shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { - if filename == filepath.Join("/mock/project/root", ".windsor", "context") && string(data) == "new-context" { - return nil - } - return fmt.Errorf("error writing file") - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When SetContext is called with a new context - err = handler.SetContext("new-context") - - // Then no error should be returned - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - }) - - t.Run("GetProjectRootError", func(t *testing.T) { - // Given a BaseConfigHandler with a GetProjectRoot error - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "", fmt.Errorf("mocked error inside GetProjectRoot") - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When SetContext is called - err = handler.SetContext("new-context") - - // Then an error should be returned - if err == nil || err.Error() != "error getting project root: mocked error inside GetProjectRoot" { - t.Fatalf("expected error 'error getting project root: mocked error inside GetProjectRoot', got %v", err) - } - }) - - t.Run("MkdirAllError", func(t *testing.T) { - // Given a BaseConfigHandler with a MkdirAll error - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return fmt.Errorf("error creating directory") - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When SetContext is called - err = handler.SetContext("new-context") - - // Then an error should be returned - if err == nil || err.Error() != "error ensuring context directory exists: error creating directory" { - t.Fatalf("expected error 'error ensuring context directory exists: error creating directory', got %v", err) - } - }) - - t.Run("WriteFileError", func(t *testing.T) { - // Given a BaseConfigHandler with a WriteFile error - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil - } - mocks.Shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { - return fmt.Errorf("error writing file") - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When SetContext is called - err = handler.SetContext("new-context") - - // Then an error should be returned - if err == nil || err.Error() != "error writing context to file: error writing file" { - t.Fatalf("expected error 'error writing context to file: error writing file', got %v", err) - } - }) - - t.Run("SetenvError", func(t *testing.T) { - // Given a BaseConfigHandler with a Setenv error - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil - } - mocks.Shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { - return nil - } - mocks.Shims.Setenv = func(key, value string) error { - return fmt.Errorf("setenv error") - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When SetContext is called - err = handler.SetContext("test-context") - - // Then an error should be returned - if err == nil { - t.Fatal("expected error, got none") - } - expectedError := "error setting WINDSOR_CONTEXT environment variable: setenv error" - if err.Error() != expectedError { - t.Fatalf("expected error %q, got %q", expectedError, err.Error()) - } - }) -} - -// TestConfigHandler_GetConfigRoot tests the GetConfigRoot method of the ConfigHandler -func TestConfigHandler_GetConfigRoot(t *testing.T) { - setup := func(t *testing.T) (*BaseConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewBaseConfigHandler(mocks.Injector) - handler.shims = mocks.Shims - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a properly configured BaseConfigHandler with a context - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.ReadFile = func(filename string) ([]byte, error) { - if filename == filepath.Join("/mock/project/root", ".windsor", "context") { - return []byte("test-context"), nil - } - return nil, fmt.Errorf("error reading file") - } - mocks.Shims.Getenv = func(key string) string { - if key == "WINDSOR_CONTEXT" { - return "test-context" - } - return "" - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When GetConfigRoot is called - configRoot, err := handler.GetConfigRoot() - - // Then it should return the correct config root path - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - expectedConfigRoot := filepath.Join("/mock/project/root", "contexts", "test-context") - if configRoot != expectedConfigRoot { - t.Fatalf("expected config root %s, got %s", expectedConfigRoot, configRoot) - } - }) - - t.Run("GetProjectRootError", func(t *testing.T) { - // Given a BaseConfigHandler with a GetProjectRoot error - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "", fmt.Errorf("error getting project root") - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When GetConfigRoot is called - _, err = handler.GetConfigRoot() - - // Then an error should be returned - if err == nil { - t.Fatalf("expected error, got none") - } - expectedError := "error getting project root" - if err.Error() != expectedError { - t.Fatalf("expected error %s, got %s", expectedError, err.Error()) - } - }) -} - -// TestConfigHandler_Clean tests the Clean method of the ConfigHandler -func TestConfigHandler_Clean(t *testing.T) { - setup := func(t *testing.T) (*BaseConfigHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewBaseConfigHandler(mocks.Injector) - handler.shims = mocks.Shims - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a properly configured BaseConfigHandler - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.RemoveAll = func(path string) error { - return nil - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When Clean is called - err = handler.Clean() - - // Then no error should be returned - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - }) - - t.Run("ErrorGettingConfigRoot", func(t *testing.T) { - // Given a BaseConfigHandler with a GetProjectRoot error - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "", fmt.Errorf("error getting project root") - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When Clean is called - err = handler.Clean() - - // Then an error should be returned - if err == nil { - t.Fatalf("expected error, got none") - } - expectedError := "error getting config root: error getting project root" - if err.Error() != expectedError { - t.Fatalf("expected error %s, got %s", expectedError, err.Error()) - } - }) - - t.Run("ErrorDeletingDirectory", func(t *testing.T) { - // Given a BaseConfigHandler with a RemoveAll error - handler, mocks := setup(t) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/mock/project/root", nil - } - mocks.Shims.RemoveAll = func(path string) error { - return fmt.Errorf("error deleting %s", path) - } - err := handler.Initialize() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - // When Clean is called - err = handler.Clean() - - // Then an error should be returned - if err == nil { - t.Fatalf("expected error, got none") - } - if !strings.Contains(err.Error(), "error deleting") { - t.Fatalf("expected error containing 'error deleting', got %s", err.Error()) - } - }) -} - -func TestBaseConfigHandler_SetSecretsProvider(t *testing.T) { - t.Run("AddsProvider", func(t *testing.T) { - // Given a new config handler - mocks := setupMocks(t) - handler := NewBaseConfigHandler(mocks.Injector) - - // And a mock secrets provider - mockProvider := secrets.NewMockSecretsProvider(mocks.Injector) - - // When setting the secrets provider - handler.SetSecretsProvider(mockProvider) - - // Then the provider should be added to the list - if len(handler.secretsProviders) != 1 { - t.Errorf("Expected 1 secrets provider, got %d", len(handler.secretsProviders)) - } - if handler.secretsProviders[0] != mockProvider { - t.Errorf("Expected provider to be added, got %v", handler.secretsProviders[0]) - } - }) - - t.Run("AddsMultipleProviders", func(t *testing.T) { - // Given a new config handler - mocks := setupMocks(t) - handler := NewBaseConfigHandler(mocks.Injector) - - // And multiple mock secrets providers - mockProvider1 := secrets.NewMockSecretsProvider(mocks.Injector) - mockProvider2 := secrets.NewMockSecretsProvider(mocks.Injector) - - // When setting multiple secrets providers - handler.SetSecretsProvider(mockProvider1) - handler.SetSecretsProvider(mockProvider2) - - // Then all providers should be added to the list - if len(handler.secretsProviders) != 2 { - t.Errorf("Expected 2 secrets providers, got %d", len(handler.secretsProviders)) - } - if handler.secretsProviders[0] != mockProvider1 { - t.Errorf("Expected first provider to be added, got %v", handler.secretsProviders[0]) - } - if handler.secretsProviders[1] != mockProvider2 { - t.Errorf("Expected second provider to be added, got %v", handler.secretsProviders[1]) - } - }) -} - -// ============================================================================= -// LoadContextConfig Tests -// ============================================================================= - -func TestBaseConfigHandler_LoadContextConfig(t *testing.T) { - setup := func(t *testing.T) *BaseConfigHandler { - t.Helper() - injector := di.NewInjector() - handler := NewBaseConfigHandler(injector) - return handler - } - - t.Run("ReturnsNotImplementedError", func(t *testing.T) { - // Given a BaseConfigHandler - handler := setup(t) - - // When LoadContextConfig is called - err := handler.LoadContextConfig() - - // Then it should return the expected error - if err == nil { - t.Fatal("LoadContextConfig() expected error, got nil") - } - - expectedError := "LoadContextConfig not implemented in base config handler" - if err.Error() != expectedError { - t.Errorf("LoadContextConfig() error = %v, expected '%s'", err, expectedError) - } - }) -} diff --git a/pkg/config/mock_config_handler_test.go b/pkg/config/mock_config_handler_test.go index cc8f5505e..197638b20 100644 --- a/pkg/config/mock_config_handler_test.go +++ b/pkg/config/mock_config_handler_test.go @@ -796,3 +796,276 @@ func TestMockConfigHandler_LoadContextConfig(t *testing.T) { } }) } + +func TestMockConfigHandler_LoadSchema(t *testing.T) { + mockErr := fmt.Errorf("mock load schema error") + + t.Run("WithFuncSet", func(t *testing.T) { + // Given a mock config handler with LoadSchemaFunc set to return an error + handler := NewMockConfigHandler() + handler.LoadSchemaFunc = func(path string) error { + return mockErr + } + + // When LoadSchema is called + err := handler.LoadSchema("/path/to/schema.yaml") + + // Then the error should match the expected mock error + if err != mockErr { + t.Errorf("Expected error = %v, got = %v", mockErr, err) + } + }) + + t.Run("WithFuncSetSuccess", func(t *testing.T) { + // Given a mock config handler with LoadSchemaFunc set to return nil + handler := NewMockConfigHandler() + var capturedPath string + handler.LoadSchemaFunc = func(path string) error { + capturedPath = path + return nil + } + + // When LoadSchema is called with a specific path + testPath := "/test/schema.yaml" + err := handler.LoadSchema(testPath) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got = %v", err) + } + + // And the path should be captured correctly + if capturedPath != testPath { + t.Errorf("Expected path = %s, got = %s", testPath, capturedPath) + } + }) + + t.Run("WithNoFuncSet", func(t *testing.T) { + // Given a mock config handler without LoadSchemaFunc set + handler := NewMockConfigHandler() + + // When LoadSchema is called + err := handler.LoadSchema("/path/to/schema.yaml") + + // Then an error should be returned + if err == nil { + t.Error("Expected error when LoadSchemaFunc not set, got nil") + } + expectedErr := "LoadSchemaFunc not set" + if err.Error() != expectedErr { + t.Errorf("Expected error message = %s, got = %s", expectedErr, err.Error()) + } + }) +} + +func TestMockConfigHandler_LoadSchemaFromBytes(t *testing.T) { + mockErr := fmt.Errorf("mock load schema from bytes error") + + t.Run("WithFuncSet", func(t *testing.T) { + // Given a mock config handler with LoadSchemaFromBytesFunc set to return an error + handler := NewMockConfigHandler() + handler.LoadSchemaFromBytesFunc = func(content []byte) error { + return mockErr + } + + // When LoadSchemaFromBytes is called + err := handler.LoadSchemaFromBytes([]byte("schema content")) + + // Then the error should match the expected mock error + if err != mockErr { + t.Errorf("Expected error = %v, got = %v", mockErr, err) + } + }) + + t.Run("WithFuncSetSuccess", func(t *testing.T) { + // Given a mock config handler with LoadSchemaFromBytesFunc set to return nil + handler := NewMockConfigHandler() + var capturedContent []byte + handler.LoadSchemaFromBytesFunc = func(content []byte) error { + capturedContent = content + return nil + } + + // When LoadSchemaFromBytes is called with specific content + testContent := []byte("test schema content") + err := handler.LoadSchemaFromBytes(testContent) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got = %v", err) + } + + // And the content should be captured correctly + if string(capturedContent) != string(testContent) { + t.Errorf("Expected content = %s, got = %s", string(testContent), string(capturedContent)) + } + }) + + t.Run("WithNoFuncSet", func(t *testing.T) { + // Given a mock config handler without LoadSchemaFromBytesFunc set + handler := NewMockConfigHandler() + + // When LoadSchemaFromBytes is called + err := handler.LoadSchemaFromBytes([]byte("schema content")) + + // Then an error should be returned + if err == nil { + t.Error("Expected error when LoadSchemaFromBytesFunc not set, got nil") + } + expectedErr := "LoadSchemaFromBytesFunc not set" + if err.Error() != expectedErr { + t.Errorf("Expected error message = %s, got = %s", expectedErr, err.Error()) + } + }) +} + +func TestMockConfigHandler_GetSchemaDefaults(t *testing.T) { + mockErr := fmt.Errorf("mock get schema defaults error") + + t.Run("WithFuncSet", func(t *testing.T) { + // Given a mock config handler with GetSchemaDefaultsFunc set to return an error + handler := NewMockConfigHandler() + handler.GetSchemaDefaultsFunc = func() (map[string]any, error) { + return nil, mockErr + } + + // When GetSchemaDefaults is called + defaults, err := handler.GetSchemaDefaults() + + // Then the error should match the expected mock error + if err != mockErr { + t.Errorf("Expected error = %v, got = %v", mockErr, err) + } + + // And defaults should be nil + if defaults != nil { + t.Errorf("Expected defaults = nil, got = %v", defaults) + } + }) + + t.Run("WithFuncSetSuccess", func(t *testing.T) { + // Given a mock config handler with GetSchemaDefaultsFunc set to return defaults + handler := NewMockConfigHandler() + expectedDefaults := map[string]any{ + "key1": "value1", + "key2": 42, + "key3": true, + } + handler.GetSchemaDefaultsFunc = func() (map[string]any, error) { + return expectedDefaults, nil + } + + // When GetSchemaDefaults is called + defaults, err := handler.GetSchemaDefaults() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got = %v", err) + } + + // And the defaults should match the expected values + if !reflect.DeepEqual(defaults, expectedDefaults) { + t.Errorf("Expected defaults = %v, got = %v", expectedDefaults, defaults) + } + }) + + t.Run("WithNoFuncSet", func(t *testing.T) { + // Given a mock config handler without GetSchemaDefaultsFunc set + handler := NewMockConfigHandler() + + // When GetSchemaDefaults is called + defaults, err := handler.GetSchemaDefaults() + + // Then an error should be returned + if err == nil { + t.Error("Expected error when GetSchemaDefaultsFunc not set, got nil") + } + expectedErr := "GetSchemaDefaultsFunc not set" + if err.Error() != expectedErr { + t.Errorf("Expected error message = %s, got = %s", expectedErr, err.Error()) + } + + // And defaults should be nil + if defaults != nil { + t.Errorf("Expected defaults = nil, got = %v", defaults) + } + }) +} + +func TestMockConfigHandler_GetContextValues(t *testing.T) { + mockErr := fmt.Errorf("mock get context values error") + + t.Run("WithFuncSet", func(t *testing.T) { + // Given a mock config handler with GetContextValuesFunc set to return an error + handler := NewMockConfigHandler() + handler.GetContextValuesFunc = func() (map[string]any, error) { + return nil, mockErr + } + + // When GetContextValues is called + values, err := handler.GetContextValues() + + // Then the error should match the expected mock error + if err != mockErr { + t.Errorf("Expected error = %v, got = %v", mockErr, err) + } + + // And values should be nil + if values != nil { + t.Errorf("Expected values = nil, got = %v", values) + } + }) + + t.Run("WithFuncSetSuccess", func(t *testing.T) { + // Given a mock config handler with GetContextValuesFunc set to return values + handler := NewMockConfigHandler() + expectedValues := map[string]any{ + "environment": map[string]any{ + "VAR1": "value1", + "VAR2": "value2", + }, + "cluster": map[string]any{ + "enabled": true, + "nodes": 3, + }, + } + handler.GetContextValuesFunc = func() (map[string]any, error) { + return expectedValues, nil + } + + // When GetContextValues is called + values, err := handler.GetContextValues() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got = %v", err) + } + + // And the values should match the expected values + if !reflect.DeepEqual(values, expectedValues) { + t.Errorf("Expected values = %v, got = %v", expectedValues, values) + } + }) + + t.Run("WithNoFuncSet", func(t *testing.T) { + // Given a mock config handler without GetContextValuesFunc set + handler := NewMockConfigHandler() + + // When GetContextValues is called + values, err := handler.GetContextValues() + + // Then an error should be returned + if err == nil { + t.Error("Expected error when GetContextValuesFunc not set, got nil") + } + expectedErr := "GetContextValuesFunc not set" + if err.Error() != expectedErr { + t.Errorf("Expected error message = %s, got = %s", expectedErr, err.Error()) + } + + // And values should be nil + if values != nil { + t.Errorf("Expected values = nil, got = %v", values) + } + }) +} diff --git a/pkg/config/yaml_config_handler.go b/pkg/config/yaml_config_handler.go deleted file mode 100644 index 4b48b15f2..000000000 --- a/pkg/config/yaml_config_handler.go +++ /dev/null @@ -1,1142 +0,0 @@ -package config - -import ( - "fmt" - "math" - "os" - "path/filepath" - "reflect" - "strconv" - "strings" - - "github.com/windsorcli/cli/api/v1alpha1" - "github.com/windsorcli/cli/pkg/di" -) - -// YamlConfigHandler extends BaseConfigHandler to implement YAML-based configuration -// management. It handles serialization/deserialization of v1alpha1.Context objects -// to/from YAML files, with version validation and context-specific overrides. The -// handler maintains configuration state through file-based persistence, implementing -// atomic writes and proper error handling. Configuration values can be accessed through -// strongly-typed getters with support for default values. - -type YamlConfigHandler struct { - BaseConfigHandler - path string - defaultContextConfig v1alpha1.Context - loadedContexts map[string]bool // tracks contexts loaded from existing files -} - -// ============================================================================= -// Constructor -// ============================================================================= - -// NewYamlConfigHandler creates a new instance of YamlConfigHandler with default context configuration. -func NewYamlConfigHandler(injector di.Injector) *YamlConfigHandler { - handler := &YamlConfigHandler{ - BaseConfigHandler: *NewBaseConfigHandler(injector), - loadedContexts: make(map[string]bool), - } - - handler.config.Version = "v1alpha1" - - return handler -} - -// ============================================================================= -// Public Methods -// ============================================================================= - -// LoadConfigString loads configuration from a YAML string into the internal config structure. -// It unmarshals the YAML, records which contexts were present in the input, validates and sets -// the config version, and marks the configuration as loaded. Returns an error if unmarshalling -// fails or if the config version is unsupported. -func (y *YamlConfigHandler) LoadConfigString(content string) error { - if content == "" { - return nil - } - - var tempConfig v1alpha1.Config - if err := y.shims.YamlUnmarshal([]byte(content), &tempConfig); err != nil { - return fmt.Errorf("error unmarshalling yaml: %w", err) - } - - if tempConfig.Contexts != nil { - for contextName := range tempConfig.Contexts { - y.loadedContexts[contextName] = true - } - } - - if err := y.shims.YamlUnmarshal([]byte(content), &y.BaseConfigHandler.config); err != nil { - return fmt.Errorf("error unmarshalling yaml: %w", err) - } - - if y.BaseConfigHandler.config.Version == "" { - y.BaseConfigHandler.config.Version = "v1alpha1" - } else if y.BaseConfigHandler.config.Version != "v1alpha1" { - return fmt.Errorf("unsupported config version: %s", y.BaseConfigHandler.config.Version) - } - - return nil -} - -// LoadConfig loads the configuration from the specified path. If the file does not exist, it does nothing. -func (y *YamlConfigHandler) LoadConfig(path string) error { - y.path = path - if _, err := y.shims.Stat(path); os.IsNotExist(err) { - return nil - } - - data, err := y.shims.ReadFile(path) - if err != nil { - return fmt.Errorf("error reading config file: %w", err) - } - - return y.LoadConfigString(string(data)) -} - -// LoadContextConfig loads the context-specific windsor.yaml file for the current context. -// It is intended for pipelines that only require static context configuration, not dynamic values. -// The values.yaml file is loaded lazily upon first access via Get() or SetContextValue(). -// Returns nil if already loaded, or if loading succeeds; otherwise returns an error for shell or file-related issues. -func (y *YamlConfigHandler) LoadContextConfig() error { - if y.loaded { - return nil - } - if y.shell == nil { - return fmt.Errorf("shell not initialized") - } - - projectRoot, err := y.shell.GetProjectRoot() - if err != nil { - return fmt.Errorf("error retrieving project root: %w", err) - } - - contextName := y.GetContext() - contextConfigDir := filepath.Join(projectRoot, "contexts", contextName) - - yamlPath := filepath.Join(contextConfigDir, "windsor.yaml") - ymlPath := filepath.Join(contextConfigDir, "windsor.yml") - - var contextConfigPath string - if _, err := y.shims.Stat(yamlPath); err == nil { - contextConfigPath = yamlPath - } else if _, err := y.shims.Stat(ymlPath); err == nil { - contextConfigPath = ymlPath - } - - if contextConfigPath != "" { - data, err := y.shims.ReadFile(contextConfigPath) - if err != nil { - return fmt.Errorf("error reading context config file: %w", err) - } - - var contextConfig v1alpha1.Context - if err := y.shims.YamlUnmarshal(data, &contextConfig); err != nil { - return fmt.Errorf("error unmarshalling context yaml: %w", err) - } - - if y.config.Contexts == nil { - y.config.Contexts = make(map[string]*v1alpha1.Context) - } - - if y.config.Contexts[contextName] == nil { - y.config.Contexts[contextName] = &v1alpha1.Context{} - } - - y.config.Contexts[contextName].Merge(&contextConfig) - - y.loadedContexts[contextName] = true - } - - if len(y.config.Contexts) > 0 { - y.loaded = true - } - - return nil -} - -// IsContextConfigLoaded returns true if the base configuration is loaded, the current context name is set, -// and a context-specific configuration has been loaded for the current context. Returns false otherwise. -func (y *YamlConfigHandler) IsContextConfigLoaded() bool { - if !y.BaseConfigHandler.loaded { - return false - } - - contextName := y.GetContext() - if contextName == "" { - return false - } - - return y.loadedContexts[contextName] -} - -// SaveConfig writes the current configuration state to disk. It writes the root windsor.yaml file with only the version field, -// and creates or updates the current context's windsor.yaml file and values.yaml containing dynamic schema-based values. -// The root windsor.yaml is created if missing; the context windsor.yaml is created if missing and not tracked in the root config; -// values.yaml is created if context values are present. If the overwrite parameter is true, existing files are updated with the -// current in-memory state. All operations are performed for the current context. -func (y *YamlConfigHandler) SaveConfig(overwrite ...bool) error { - if y.shell == nil { - return fmt.Errorf("shell not initialized") - } - - shouldOverwrite := false - if len(overwrite) > 0 { - shouldOverwrite = overwrite[0] - } - - projectRoot, err := y.shell.GetProjectRoot() - if err != nil { - return fmt.Errorf("error retrieving project root: %w", err) - } - - rootConfigPath := filepath.Join(projectRoot, "windsor.yaml") - contextName := y.GetContext() - contextConfigPath := filepath.Join(projectRoot, "contexts", contextName, "windsor.yaml") - - rootExists := false - if _, err := y.shims.Stat(rootConfigPath); err == nil { - rootExists = true - } - - contextExists := false - if _, err := y.shims.Stat(contextConfigPath); err == nil { - contextExists = true - } - - contextExistsInRoot := y.loadedContexts[contextName] - - shouldCreateRootConfig := !rootExists - shouldCreateContextConfig := !contextExists && !contextExistsInRoot - shouldUpdateRootConfig := shouldOverwrite && rootExists - shouldUpdateContextConfig := shouldOverwrite && contextExists - - if shouldCreateRootConfig || shouldUpdateRootConfig { - rootConfig := struct { - Version string `yaml:"version"` - }{ - Version: y.config.Version, - } - - data, err := y.shims.YamlMarshal(rootConfig) - if err != nil { - return fmt.Errorf("error marshalling root config: %w", err) - } - - if err := y.shims.WriteFile(rootConfigPath, data, 0644); err != nil { - return fmt.Errorf("error writing root config: %w", err) - } - } - - if shouldCreateContextConfig || shouldUpdateContextConfig { - var contextConfig v1alpha1.Context - - if y.config.Contexts != nil && y.config.Contexts[contextName] != nil { - contextConfig = *y.config.Contexts[contextName] - } else { - contextConfig = y.defaultContextConfig - } - - contextDir := filepath.Join(projectRoot, "contexts", contextName) - if err := y.shims.MkdirAll(contextDir, 0755); err != nil { - return fmt.Errorf("error creating context directory: %w", err) - } - - data, err := y.shims.YamlMarshal(contextConfig) - if err != nil { - return fmt.Errorf("error marshalling context config: %w", err) - } - - if err := y.shims.WriteFile(contextConfigPath, data, 0644); err != nil { - return fmt.Errorf("error writing context config: %w", err) - } - } - - if y.loaded && y.contextValues != nil && len(y.contextValues) > 0 { - if err := y.saveContextValues(); err != nil { - return fmt.Errorf("error saving values.yaml: %w", err) - } - } - - return nil -} - -// SetDefault sets the given context configuration as the default and merges it with any -// existing context configuration. If no context exists, the default becomes the context. -// If a context exists, it merges the default with the existing context, with existing -// values taking precedence over defaults. -func (y *YamlConfigHandler) SetDefault(context v1alpha1.Context) error { - y.defaultContextConfig = context - currentContext := y.GetContext() - contextKey := fmt.Sprintf("contexts.%s", currentContext) - - if y.Get(contextKey) == nil { - return y.Set(contextKey, &context) - } - - if y.config.Contexts == nil { - y.config.Contexts = make(map[string]*v1alpha1.Context) - } - if y.config.Contexts[currentContext] == nil { - y.config.Contexts[currentContext] = &v1alpha1.Context{} - } - defaultCopy := context.DeepCopy() - existingCopy := y.config.Contexts[currentContext].DeepCopy() - defaultCopy.Merge(existingCopy) - y.config.Contexts[currentContext] = defaultCopy - - return nil -} - -// Get returns the value at the specified configuration path using the configured lookup precedence. -// Lookup order is: context configuration from windsor.yaml, then values.yaml, then schema defaults. -// Returns nil if the path is empty or if no value is found in any source. -func (y *YamlConfigHandler) Get(path string) any { - if path == "" { - return nil - } - pathKeys := parsePath(path) - - value := getValueByPath(y.config, pathKeys) - if value != nil { - return value - } - - if len(pathKeys) >= 2 && pathKeys[0] == "contexts" { - if len(pathKeys) >= 3 && y.loaded { - if err := y.ensureValuesYamlLoaded(); err != nil { - } - if y.contextValues != nil { - key := pathKeys[2] - if value, exists := y.contextValues[key]; exists { - return value - } - } - } - - value = getValueByPath(y.defaultContextConfig, pathKeys[2:]) - if value != nil { - return value - } - } - - if len(pathKeys) == 1 && y.schemaValidator != nil && y.schemaValidator.Schema != nil { - defaults, err := y.schemaValidator.GetSchemaDefaults() - if err == nil { - if value, exists := defaults[pathKeys[0]]; exists { - return value - } - } - } - - return nil -} - -// GetString retrieves a string value for the specified key from the configuration, with an optional default value. -// If the key is not found, it returns the provided default value or an empty string if no default is provided. -func (y *YamlConfigHandler) GetString(key string, defaultValue ...string) string { - contextKey := fmt.Sprintf("contexts.%s.%s", y.context, key) - value := y.Get(contextKey) - if value == nil { - if len(defaultValue) > 0 { - return defaultValue[0] - } - return "" - } - strValue := fmt.Sprintf("%v", value) - return strValue -} - -// GetInt retrieves an integer value for the specified key from the configuration, with an optional default value. -func (y *YamlConfigHandler) GetInt(key string, defaultValue ...int) int { - contextKey := fmt.Sprintf("contexts.%s.%s", y.context, key) - value := y.Get(contextKey) - if value == nil { - if len(defaultValue) > 0 { - return defaultValue[0] - } - return 0 - } - intValue, ok := value.(int) - if !ok { - return 0 - } - return intValue -} - -// GetBool retrieves a boolean value for the specified key from the configuration, with an optional default value. -func (y *YamlConfigHandler) GetBool(key string, defaultValue ...bool) bool { - contextKey := fmt.Sprintf("contexts.%s.%s", y.context, key) - value := y.Get(contextKey) - if value == nil { - if len(defaultValue) > 0 { - return defaultValue[0] - } - return false - } - if boolValue, ok := value.(bool); ok { - return boolValue - } - return false -} - -// GetStringSlice retrieves a slice of strings for the specified key from the configuration, with an optional default value. -// If the key is not found, it returns the provided default value or an empty slice if no default is provided. -func (y *YamlConfigHandler) GetStringSlice(key string, defaultValue ...[]string) []string { - contextKey := fmt.Sprintf("contexts.%s.%s", y.context, key) - value := y.Get(contextKey) - if value == nil { - if len(defaultValue) > 0 { - return defaultValue[0] - } - return []string{} - } - strSlice, ok := value.([]string) - if !ok { - return []string{} - } - return strSlice -} - -// GetStringMap retrieves a map of string key-value pairs for the specified key from the configuration. -// If the key is not found, it returns the provided default value or an empty map if no default is provided. -func (y *YamlConfigHandler) GetStringMap(key string, defaultValue ...map[string]string) map[string]string { - contextKey := fmt.Sprintf("contexts.%s.%s", y.context, key) - value := y.Get(contextKey) - if value == nil { - if len(defaultValue) > 0 { - return defaultValue[0] - } - return map[string]string{} - } - - strMap, ok := value.(map[string]string) - if !ok { - return map[string]string{} - } - - return strMap -} - -// Set updates the value at the specified path in the configuration using reflection. -// It parses the path, performs type conversion if necessary, and sets the value in the config structure. -// An error is returned if the path is invalid, conversion fails, or the update cannot be performed. -func (y *YamlConfigHandler) Set(path string, value any) error { - if path == "" { - return nil - } - - pathKeys := parsePath(path) - if len(pathKeys) == 0 { - return fmt.Errorf("invalid path: %s", path) - } - - if strValue, ok := value.(string); ok { - currentValue := y.Get(path) - if currentValue != nil { - targetType := reflect.TypeOf(currentValue) - convertedValue, err := convertValue(strValue, targetType) - if err != nil { - return fmt.Errorf("error converting value for %s: %w", path, err) - } - value = convertedValue - } - } - - configValue := reflect.ValueOf(&y.config) - return setValueByPath(configValue, pathKeys, value, path) -} - -// SetContextValue sets a value at the given path for the current context, updating config or values.yaml in memory. -// The key must not be empty or have invalid dot notation. Values are schema-validated if available. -// Changes require SaveConfig to persist. Returns error if path or value is invalid, or conversion fails. -func (y *YamlConfigHandler) SetContextValue(path string, value any) error { - if path == "" { - return fmt.Errorf("path cannot be empty") - } - if strings.Contains(path, "..") || strings.HasPrefix(path, ".") || strings.HasSuffix(path, ".") { - return fmt.Errorf("invalid path format: %s", path) - } - if y.isKeyInStaticSchema(path) { - if y.config.Contexts == nil { - y.config.Contexts = make(map[string]*v1alpha1.Context) - } - contextName := y.GetContext() - if y.config.Contexts[contextName] == nil { - y.config.Contexts[contextName] = &v1alpha1.Context{} - } - fullPath := fmt.Sprintf("contexts.%s.%s", contextName, path) - return y.Set(fullPath, value) - } - if err := y.ensureValuesYamlLoaded(); err != nil { - return fmt.Errorf("error loading values.yaml: %w", err) - } - convertedValue := y.convertStringValue(value) - y.contextValues[path] = convertedValue - if y.schemaValidator != nil && y.schemaValidator.Schema != nil { - if result, err := y.schemaValidator.Validate(y.contextValues); err != nil { - return fmt.Errorf("error validating context value: %w", err) - } else if !result.Valid { - return fmt.Errorf("context value validation failed: %v", result.Errors) - } - } - return nil -} - -// GetConfig returns the context config object for the current context, or the default if none is set. -func (y *YamlConfigHandler) GetConfig() *v1alpha1.Context { - defaultConfigCopy := y.defaultContextConfig.DeepCopy() - context := y.context - - if context == "" { - return defaultConfigCopy - } - - if ctx, ok := y.config.Contexts[context]; ok { - mergedConfig := defaultConfigCopy - mergedConfig.Merge(ctx) - return mergedConfig - } - - return defaultConfigCopy -} - -// GetContextValues returns merged context values from windsor.yaml (via GetConfig) and values.yaml -// The context config is converted to a map and deep merged with values.yaml, with values.yaml taking precedence -func (y *YamlConfigHandler) GetContextValues() (map[string]any, error) { - if err := y.ensureValuesYamlLoaded(); err != nil { - return nil, err - } - - contextConfig := y.GetConfig() - contextData, err := y.shims.YamlMarshal(contextConfig) - if err != nil { - return nil, fmt.Errorf("error marshalling context config: %w", err) - } - - var contextMap map[string]any - if err := y.shims.YamlUnmarshal(contextData, &contextMap); err != nil { - return nil, fmt.Errorf("error unmarshalling context config to map: %w", err) - } - - return y.deepMerge(contextMap, y.contextValues), nil -} - -// Ensure YamlConfigHandler implements ConfigHandler -var _ ConfigHandler = (*YamlConfigHandler)(nil) - -// ============================================================================= -// Private Methods -// ============================================================================= - -// ensureValuesYamlLoaded loads and validates the values.yaml file for the current context, and loads the schema if required. -// It initializes y.contextValues by reading values.yaml from the context directory, unless already loaded or not required. -// If values.yaml or the schema is missing, it initializes an empty map and performs schema validation if possible. -// Returns an error if any schema loading, reading, or unmarshaling fails. -func (y *YamlConfigHandler) ensureValuesYamlLoaded() error { - if y.contextValues != nil { - return nil - } - if y.shell == nil || !y.loaded { - y.contextValues = make(map[string]any) - return nil - } - projectRoot, err := y.shell.GetProjectRoot() - if err != nil { - return fmt.Errorf("error retrieving project root: %w", err) - } - contextName := y.GetContext() - contextConfigDir := filepath.Join(projectRoot, "contexts", contextName) - valuesPath := filepath.Join(contextConfigDir, "values.yaml") - if y.schemaValidator != nil && y.schemaValidator.Schema == nil { - schemaPath := filepath.Join(projectRoot, "contexts", "_template", "schema.yaml") - if _, err := y.shims.Stat(schemaPath); err == nil { - if err := y.schemaValidator.LoadSchema(schemaPath); err != nil { - return fmt.Errorf("error loading schema: %w", err) - } - } - } - if _, err := y.shims.Stat(valuesPath); err == nil { - data, err := y.shims.ReadFile(valuesPath) - if err != nil { - return fmt.Errorf("error reading values.yaml: %w", err) - } - var values map[string]any - if err := y.shims.YamlUnmarshal(data, &values); err != nil { - return fmt.Errorf("error unmarshalling values.yaml: %w", err) - } - if y.schemaValidator != nil && y.schemaValidator.Schema != nil { - if result, err := y.schemaValidator.Validate(values); err != nil { - return fmt.Errorf("error validating values.yaml: %w", err) - } else if !result.Valid { - return fmt.Errorf("values.yaml validation failed: %v", result.Errors) - } - } - y.contextValues = values - } else { - y.contextValues = make(map[string]any) - } - return nil -} - -// getValueByPath retrieves a value by navigating through a struct or map using YAML tags. -func getValueByPath(current any, pathKeys []string) any { - if len(pathKeys) == 0 { - return nil - } - - currValue := reflect.ValueOf(current) - if !currValue.IsValid() { - return nil - } - - for _, key := range pathKeys { - for currValue.Kind() == reflect.Ptr && !currValue.IsNil() { - currValue = currValue.Elem() - } - if currValue.Kind() == reflect.Ptr && currValue.IsNil() { - return nil - } - - switch currValue.Kind() { - case reflect.Struct: - fieldValue := getFieldByYamlTag(currValue, key) - currValue = fieldValue - - case reflect.Map: - mapKey := reflect.ValueOf(key) - if !mapKey.Type().AssignableTo(currValue.Type().Key()) { - return nil - } - mapValue := currValue.MapIndex(mapKey) - if !mapValue.IsValid() { - return nil - } - currValue = mapValue - - default: - return nil - } - } - - if currValue.Kind() == reflect.Ptr { - if currValue.IsNil() { - return nil - } - currValue = currValue.Elem() - } - - if currValue.IsValid() && currValue.CanInterface() { - return currValue.Interface() - } - - return nil -} - -// getFieldByYamlTag retrieves a field from a struct by its YAML tag. -func getFieldByYamlTag(v reflect.Value, tag string) reflect.Value { - t := v.Type() - for i := range make([]struct{}, v.NumField()) { - field := t.Field(i) - yamlTag := strings.Split(field.Tag.Get("yaml"), ",")[0] - if yamlTag == tag { - return v.Field(i) - } - } - return reflect.Value{} -} - -// setValueByPath sets a value in a struct or map by navigating through it using YAML tags. -func setValueByPath(currValue reflect.Value, pathKeys []string, value any, fullPath string) error { - if len(pathKeys) == 0 { - return fmt.Errorf("pathKeys cannot be empty") - } - - key := pathKeys[0] - isLast := len(pathKeys) == 1 - - if currValue.Kind() == reflect.Ptr { - if currValue.IsNil() { - currValue.Set(reflect.New(currValue.Type().Elem())) - } - currValue = currValue.Elem() - } - - switch currValue.Kind() { - case reflect.Struct: - fieldValue := getFieldByYamlTag(currValue, key) - if !fieldValue.IsValid() { - return fmt.Errorf("field not found: %s", key) - } - - if fieldValue.Kind() == reflect.Ptr && fieldValue.IsNil() { - fieldValue.Set(reflect.New(fieldValue.Type().Elem())) - } - - if fieldValue.Kind() == reflect.Map && fieldValue.IsNil() { - fieldValue.Set(reflect.MakeMap(fieldValue.Type())) - } - - if isLast { - newFieldValue, err := assignValue(fieldValue, value) - if err != nil { - return err - } - fieldValue.Set(newFieldValue) - } else { - err := setValueByPath(fieldValue, pathKeys[1:], value, fullPath) - if err != nil { - return err - } - } - - case reflect.Map: - if currValue.IsNil() { - currValue.Set(reflect.MakeMap(currValue.Type())) - } - - mapKey := reflect.ValueOf(key) - if !mapKey.Type().AssignableTo(currValue.Type().Key()) { - return fmt.Errorf("key type mismatch: expected %s, got %s", currValue.Type().Key(), mapKey.Type()) - } - - var nextValue reflect.Value - - if isLast { - val := reflect.ValueOf(value) - if !val.Type().AssignableTo(currValue.Type().Elem()) { - if val.Type().ConvertibleTo(currValue.Type().Elem()) { - val = val.Convert(currValue.Type().Elem()) - } else { - return fmt.Errorf("value type mismatch for key %s: expected %s, got %s", key, currValue.Type().Elem(), val.Type()) - } - } - currValue.SetMapIndex(mapKey, val) - } else { - nextValue = currValue.MapIndex(mapKey) - if !nextValue.IsValid() { - nextValue = reflect.New(currValue.Type().Elem()).Elem() - } else { - nextValue = makeAddressable(nextValue) - } - - err := setValueByPath(nextValue, pathKeys[1:], value, fullPath) - if err != nil { - return err - } - - currValue.SetMapIndex(mapKey, nextValue) - } - - default: - return fmt.Errorf("Invalid path: %s", fullPath) - } - - return nil -} - -// assignValue assigns a value to a struct field, performing type conversion if necessary. -// It supports string-to-type conversion, pointer assignment, and type compatibility checks. -// Returns a reflect.Value suitable for assignment or an error if conversion is not possible. -func assignValue(fieldValue reflect.Value, value any) (reflect.Value, error) { - if !fieldValue.CanSet() { - return reflect.Value{}, fmt.Errorf("cannot set field") - } - - fieldType := fieldValue.Type() - valueType := reflect.TypeOf(value) - - if strValue, ok := value.(string); ok { - convertedValue, err := convertValue(strValue, fieldType) - if err == nil { - return reflect.ValueOf(convertedValue), nil - } - } - - if fieldType.Kind() == reflect.Ptr { - elemType := fieldType.Elem() - newValue := reflect.New(elemType) - val := reflect.ValueOf(value) - - if valueType.AssignableTo(fieldType) { - return val, nil - } - - if val.Type().ConvertibleTo(elemType) { - val = val.Convert(elemType) - newValue.Elem().Set(val) - return newValue, nil - } - - return reflect.Value{}, fmt.Errorf("cannot assign value of type %s to field of type %s", valueType, fieldType) - } - - val := reflect.ValueOf(value) - if valueType.AssignableTo(fieldType) { - return val, nil - } - - if valueType.ConvertibleTo(fieldType) { - return val.Convert(fieldType), nil - } - - return reflect.Value{}, fmt.Errorf("cannot assign value of type %s to field of type %s", valueType, fieldType) -} - -// makeAddressable ensures a value is addressable by creating a new pointer if necessary. -func makeAddressable(v reflect.Value) reflect.Value { - if !v.IsValid() { - return v - } - if v.CanAddr() { - return v - } - addr := reflect.New(v.Type()) - addr.Elem().Set(v) - return addr.Elem() -} - -// parsePath parses a path string into a slice of keys, supporting both dot and bracket notation. -func parsePath(path string) []string { - var keys []string - var currentKey strings.Builder - inBracket := false - - for _, char := range path { - switch char { - case '.': - if !inBracket { - if currentKey.Len() > 0 { - keys = append(keys, currentKey.String()) - currentKey.Reset() - } - } else { - currentKey.WriteRune(char) - } - case '[': - inBracket = true - if currentKey.Len() > 0 { - keys = append(keys, currentKey.String()) - currentKey.Reset() - } - case ']': - inBracket = false - default: - currentKey.WriteRune(char) - } - } - - if currentKey.Len() > 0 { - keys = append(keys, currentKey.String()) - } - - return keys -} - -// convertValue attempts to convert a string value to the appropriate type based on the target field's type -func convertValue(value string, targetType reflect.Type) (any, error) { - isPointer := targetType.Kind() == reflect.Ptr - if isPointer { - targetType = targetType.Elem() - } - - var convertedValue any - var err error - - switch targetType.Kind() { - case reflect.Bool: - convertedValue, err = strconv.ParseBool(value) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - var v int64 - v, err = strconv.ParseInt(value, 10, 64) - if err == nil { - switch targetType.Kind() { - case reflect.Int: - if v < math.MinInt || v > math.MaxInt { - return nil, fmt.Errorf("integer overflow: %d is outside the range of int", v) - } - convertedValue = int(v) - case reflect.Int8: - if v < math.MinInt8 || v > math.MaxInt8 { - return nil, fmt.Errorf("integer overflow: %d is outside the range of int8", v) - } - convertedValue = int8(v) - case reflect.Int16: - if v < math.MinInt16 || v > math.MaxInt16 { - return nil, fmt.Errorf("integer overflow: %d is outside the range of int16", v) - } - convertedValue = int16(v) - case reflect.Int32: - if v < math.MinInt32 || v > math.MaxInt32 { - return nil, fmt.Errorf("integer overflow: %d is outside the range of int32", v) - } - convertedValue = int32(v) - case reflect.Int64: - convertedValue = v - } - } - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - var v uint64 - v, err = strconv.ParseUint(value, 10, 64) - if err == nil { - switch targetType.Kind() { - case reflect.Uint: - if v > math.MaxUint { - return nil, fmt.Errorf("integer overflow: %d is outside the range of uint", v) - } - convertedValue = uint(v) - case reflect.Uint8: - if v > math.MaxUint8 { - return nil, fmt.Errorf("integer overflow: %d is outside the range of uint8", v) - } - convertedValue = uint8(v) - case reflect.Uint16: - if v > math.MaxUint16 { - return nil, fmt.Errorf("integer overflow: %d is outside the range of uint16", v) - } - convertedValue = uint16(v) - case reflect.Uint32: - if v > math.MaxUint32 { - return nil, fmt.Errorf("integer overflow: %d is outside the range of uint32", v) - } - convertedValue = uint32(v) - case reflect.Uint64: - convertedValue = v - } - } - case reflect.Float32, reflect.Float64: - var v float64 - v, err = strconv.ParseFloat(value, 64) - if err == nil { - if targetType.Kind() == reflect.Float32 { - if v < -math.MaxFloat32 || v > math.MaxFloat32 { - return nil, fmt.Errorf("float overflow: %f is outside the range of float32", v) - } - convertedValue = float32(v) - } else { - convertedValue = v - } - } - case reflect.String: - convertedValue = value - default: - return nil, fmt.Errorf("unsupported type conversion from string to %v", targetType) - } - - if err != nil { - return nil, err - } - - if isPointer { - ptr := reflect.New(targetType) - ptr.Elem().Set(reflect.ValueOf(convertedValue)) - return ptr.Interface(), nil - } - - return convertedValue, nil -} - -// GenerateContextID generates a random context ID if one doesn't exist -func (y *YamlConfigHandler) GenerateContextID() error { - if y.GetString("id") != "" { - return nil - } - - const charset = "abcdefghijklmnopqrstuvwxyz0123456789" - b := make([]byte, 7) - if _, err := y.shims.CryptoRandRead(b); err != nil { - return fmt.Errorf("failed to generate random context ID: %w", err) - } - - for i := range b { - b[i] = charset[int(b[i])%len(charset)] - } - - id := "w" + string(b) - return y.SetContextValue("id", id) -} - -// saveContextValues writes the current contextValues map to a values.yaml file in the context directory for the current context. -// This function ensures that the target context directory exists. If a schema validator is configured, it validates the contextValues -// against the schema before saving. The function marshals the values to YAML, writes them to values.yaml, and returns an error if -// validation, marshalling, or file writing fails. -func (y *YamlConfigHandler) saveContextValues() error { - if y.schemaValidator != nil && y.schemaValidator.Schema != nil { - if result, err := y.schemaValidator.Validate(y.contextValues); err != nil { - return fmt.Errorf("error validating values.yaml: %w", err) - } else if !result.Valid { - return fmt.Errorf("values.yaml validation failed: %v", result.Errors) - } - } - - configRoot, err := y.GetConfigRoot() - if err != nil { - return fmt.Errorf("error getting config root: %w", err) - } - - if err := y.shims.MkdirAll(configRoot, 0755); err != nil { - return fmt.Errorf("error creating context directory: %w", err) - } - - valuesPath := filepath.Join(configRoot, "values.yaml") - data, err := y.shims.YamlMarshal(y.contextValues) - if err != nil { - return fmt.Errorf("error marshalling values.yaml: %w", err) - } - - if err := y.shims.WriteFile(valuesPath, data, 0644); err != nil { - return fmt.Errorf("error writing values.yaml: %w", err) - } - - return nil -} - -// isKeyInStaticSchema determines whether the provided key exists as a top-level field -// in the static windsor.yaml schema, represented by v1alpha1.Context. It checks both -// direct keys and those nested with dot notation (e.g., "environment.TEST_VAR" -> "environment"). -// Returns true if the top-level key matches a YAML field tag in v1alpha1.Context, false otherwise. -func (y *YamlConfigHandler) isKeyInStaticSchema(key string) bool { - topLevelKey := key - if dotIndex := strings.Index(key, "."); dotIndex != -1 { - topLevelKey = key[:dotIndex] - } - contextType := reflect.TypeOf(v1alpha1.Context{}) - for i := 0; i < contextType.NumField(); i++ { - field := contextType.Field(i) - yamlTag := strings.Split(field.Tag.Get("yaml"), ",")[0] - if yamlTag == topLevelKey { - return true - } - } - return false -} - -// convertStringValue infers and converts a string value to the appropriate type based on schema type information. -// It is used to correctly coerce command-line --set flags (which arrive as strings) to their target types. -// The function uses the configured schema validator, if present, to determine the expected type for the value. -// If type information cannot be found in the schema, it applies pattern-based type conversion heuristics. -// The returned value is properly typed if conversion is possible; otherwise, the original value is returned. -func (y *YamlConfigHandler) convertStringValue(value any) any { - str, ok := value.(string) - if !ok { - return value - } - if y.schemaValidator != nil && y.schemaValidator.Schema != nil { - if expectedType := y.getExpectedTypeFromSchema(str); expectedType != "" { - if convertedValue := y.convertStringToType(str, expectedType); convertedValue != nil { - return convertedValue - } - } - } - return y.convertStringByPattern(str) -} - -// getExpectedTypeFromSchema attempts to find the expected type for a key in the schema -func (y *YamlConfigHandler) getExpectedTypeFromSchema(key string) string { - if y.schemaValidator == nil || y.schemaValidator.Schema == nil { - return "" - } - - properties, ok := y.schemaValidator.Schema["properties"] - if !ok { - return "" - } - - propertiesMap, ok := properties.(map[string]any) - if !ok { - return "" - } - - propSchema, exists := propertiesMap[key] - if !exists { - return "" - } - - propSchemaMap, ok := propSchema.(map[string]any) - if !ok { - return "" - } - - expectedType, ok := propSchemaMap["type"] - if !ok { - return "" - } - - expectedTypeStr, ok := expectedType.(string) - if !ok { - return "" - } - - return expectedTypeStr -} - -// convertStringToType converts a string value to the corresponding Go type based on the provided JSON schema type. -// It supports boolean, integer, number, and string schema types. Returns the converted value, or nil if conversion fails. -// The conversion follows JSON schema type expectations: booleans are case-insensitive, integers use strconv.Atoi, -// numbers use strconv.ParseFloat (64-bit), and unrecognized types or conversion failures return nil. -func (y *YamlConfigHandler) convertStringToType(str, expectedType string) any { - switch expectedType { - case "boolean": - switch strings.ToLower(str) { - case "true": - return true - case "false": - return false - } - case "integer": - if intVal, err := strconv.Atoi(str); err == nil { - return intVal - } - case "number": - if floatVal, err := strconv.ParseFloat(str, 64); err == nil { - return floatVal - } - case "string": - return str - } - return nil -} - -// convertStringByPattern attempts to infer and convert a string value to its most likely Go type. -// It recognizes "true"/"false" as booleans, parses numeric strings as integer or float as appropriate, -// and returns the original string if no conversion pattern matches. -func (y *YamlConfigHandler) convertStringByPattern(str string) any { - switch strings.ToLower(str) { - case "true": - return true - case "false": - return false - } - - if intVal, err := strconv.Atoi(str); err == nil { - return intVal - } - - if floatVal, err := strconv.ParseFloat(str, 64); err == nil { - return floatVal - } - - return str -} - -// deepMerge recursively merges two maps with overlay values taking precedence. -// Nested maps are merged rather than replaced. Non-map values in overlay replace base values. -func (y *YamlConfigHandler) deepMerge(base, overlay map[string]any) map[string]any { - result := make(map[string]any) - for k, v := range base { - result[k] = v - } - for k, overlayValue := range overlay { - if baseValue, exists := result[k]; exists { - if baseMap, baseIsMap := baseValue.(map[string]any); baseIsMap { - if overlayMap, overlayIsMap := overlayValue.(map[string]any); overlayIsMap { - result[k] = y.deepMerge(baseMap, overlayMap) - continue - } - } - } - result[k] = overlayValue - } - return result -} diff --git a/pkg/env/env_test.go b/pkg/env/env_test.go index 75c37a577..b99a0f32c 100644 --- a/pkg/env/env_test.go +++ b/pkg/env/env_test.go @@ -86,7 +86,7 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { // Create config handler var configHandler config.ConfigHandler if options.ConfigHandler == nil { - configHandler = config.NewYamlConfigHandler(injector) + configHandler = config.NewConfigHandler(injector) } else { configHandler = options.ConfigHandler } diff --git a/pkg/env/windsor_env_test.go b/pkg/env/windsor_env_test.go index 369bd88a2..1204480b4 100644 --- a/pkg/env/windsor_env_test.go +++ b/pkg/env/windsor_env_test.go @@ -120,7 +120,7 @@ func TestWindsorEnv_GetEnvVars(t *testing.T) { t.Errorf("Expected WINDSOR_SESSION_TOKEN to be %q, got %q", expectedSessionToken, envVars["WINDSOR_SESSION_TOKEN"]) } - // And context ID should be set but empty (YamlConfigHandler returns empty for non-existent keys) + // And context ID should be set but empty (ConfigHandler returns empty for non-existent keys) expectedContextID := "" if envVars["WINDSOR_CONTEXT_ID"] != expectedContextID { t.Errorf("Expected WINDSOR_CONTEXT_ID to be %q, got %q", expectedContextID, envVars["WINDSOR_CONTEXT_ID"]) diff --git a/pkg/network/network_test.go b/pkg/network/network_test.go index c2efe78da..279d415e5 100644 --- a/pkg/network/network_test.go +++ b/pkg/network/network_test.go @@ -85,7 +85,7 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { if len(opts) > 0 && opts[0].ConfigHandler != nil { configHandler = opts[0].ConfigHandler } else { - configHandler = config.NewYamlConfigHandler(injector) + configHandler = config.NewConfigHandler(injector) } injector.Register("configHandler", configHandler) diff --git a/pkg/pipelines/pipeline.go b/pkg/pipelines/pipeline.go index ac3897c4d..f883d9be5 100644 --- a/pkg/pipelines/pipeline.go +++ b/pkg/pipelines/pipeline.go @@ -203,7 +203,7 @@ func (p *BasePipeline) withConfigHandler() config.ConfigHandler { } } - p.configHandler = config.NewYamlConfigHandler(p.injector) + p.configHandler = config.NewConfigHandler(p.injector) p.injector.Register("configHandler", p.configHandler) return p.configHandler } diff --git a/pkg/pipelines/pipeline_test.go b/pkg/pipelines/pipeline_test.go index 805359f9e..aeb27c118 100644 --- a/pkg/pipelines/pipeline_test.go +++ b/pkg/pipelines/pipeline_test.go @@ -128,7 +128,7 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { if len(opts) > 0 && opts[0].ConfigHandler != nil { configHandler = opts[0].ConfigHandler } else { - configHandler = config.NewYamlConfigHandler(injector) + configHandler = config.NewConfigHandler(injector) } injector.Register("configHandler", configHandler) diff --git a/pkg/services/service_test.go b/pkg/services/service_test.go index 393e7e9a8..98b304485 100644 --- a/pkg/services/service_test.go +++ b/pkg/services/service_test.go @@ -134,7 +134,7 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { if len(opts) > 0 && opts[0].ConfigHandler != nil { configHandler = opts[0].ConfigHandler } else { - configHandler = config.NewYamlConfigHandler(injector) + configHandler = config.NewConfigHandler(injector) } injector.Register("configHandler", configHandler) diff --git a/pkg/stack/stack_test.go b/pkg/stack/stack_test.go index 34f61c5e1..9a8f8984a 100644 --- a/pkg/stack/stack_test.go +++ b/pkg/stack/stack_test.go @@ -101,7 +101,7 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { // Create config handler var configHandler config.ConfigHandler if options.ConfigHandler == nil { - configHandler = config.NewYamlConfigHandler(injector) + configHandler = config.NewConfigHandler(injector) } else { configHandler = options.ConfigHandler } diff --git a/pkg/terraform/module_resolver_test.go b/pkg/terraform/module_resolver_test.go index 920a21696..2c70dde75 100644 --- a/pkg/terraform/module_resolver_test.go +++ b/pkg/terraform/module_resolver_test.go @@ -77,7 +77,7 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { if len(opts) > 0 && opts[0].ConfigHandler != nil { configHandler = opts[0].ConfigHandler } else { - configHandler = config.NewYamlConfigHandler(injector) + configHandler = config.NewConfigHandler(injector) } injector.Register("configHandler", configHandler) diff --git a/pkg/tools/tools_manager_test.go b/pkg/tools/tools_manager_test.go index 0e48516c6..c341f8601 100644 --- a/pkg/tools/tools_manager_test.go +++ b/pkg/tools/tools_manager_test.go @@ -71,7 +71,7 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { var configHandler config.ConfigHandler if options.ConfigHandler == nil { - configHandler = config.NewYamlConfigHandler(injector) + configHandler = config.NewConfigHandler(injector) } else { configHandler = options.ConfigHandler } diff --git a/pkg/virt/virt_test.go b/pkg/virt/virt_test.go index 4e9290cb6..d1afe8ad6 100644 --- a/pkg/virt/virt_test.go +++ b/pkg/virt/virt_test.go @@ -139,7 +139,7 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { // Create config handler var configHandler config.ConfigHandler if options.ConfigHandler == nil { - configHandler = config.NewYamlConfigHandler(injector) + configHandler = config.NewConfigHandler(injector) } else { configHandler = options.ConfigHandler }