// FILE: lixenwraith/config/register.go package config import ( "fmt" "os" "reflect" "strings" ) // Register makes a configuration path known to the Config instance. // The path should be dot-separated (e.g., "server.port", "debug"). // Each segment of the path must be a valid TOML key identifier. // defaultValue is the value returned by Get if no specific value has been set. func (c *Config) Register(path string, defaultValue any) error { if path == "" { return wrapError(ErrInvalidPath, fmt.Errorf("registration path cannot be empty")) } // Validate path segments segments := strings.Split(path, ".") for _, segment := range segments { if !isValidKeySegment(segment) { return wrapError(ErrInvalidPath, fmt.Errorf("invalid path segment %q in path %q", segment, path)) } } c.mutex.Lock() defer c.mutex.Unlock() c.items[path] = configItem{ defaultValue: defaultValue, currentValue: defaultValue, // Initially set to default values: make(map[Source]any), } return nil } // RegisterWithEnv registers a path with an explicit environment variable mapping func (c *Config) RegisterWithEnv(path string, defaultValue any, envVar string) error { if err := c.Register(path, defaultValue); err != nil { return err } // Check if the environment variable exists and load it if value, exists := os.LookupEnv(envVar); exists { parsed := parseValue(value) return c.SetSource(SourceEnv, path, parsed) // Already wrapped with error category in SetSource } return nil } // RegisterRequired registers a path and marks it as required // The configuration will fail validation if this value is not provided func (c *Config) RegisterRequired(path string, defaultValue any) error { // For now, just register normally // The required paths will be tracked separately in a future enhancement return c.Register(path, defaultValue) } // Unregister removes a configuration path and all its children. func (c *Config) Unregister(path string) error { c.mutex.Lock() defer c.mutex.Unlock() // Check if the exact path exists before proceeding if _, exists := c.items[path]; !exists { // Check if it's a prefix for other registered paths hasChildren := false prefix := path + "." for childPath := range c.items { if strings.HasPrefix(childPath, prefix) { hasChildren = true break } } // If neither the path nor any children exist, return error if !hasChildren { return wrapError(ErrPathNotRegistered, fmt.Errorf("path not registered: %s", path)) } } // Remove the path itself if it exists delete(c.items, path) // Remove any child paths prefix := path + "." for childPath := range c.items { if strings.HasPrefix(childPath, prefix) { delete(c.items, childPath) } } return nil } // RegisterStruct registers configuration values derived from a struct. // It uses struct tags (`toml:"..."`) to determine the configuration paths. // The prefix is prepended to all paths (e.g., "log."). An empty prefix is allowed. func (c *Config) RegisterStruct(prefix string, structWithDefaults any) error { return c.RegisterStructWithTags(prefix, structWithDefaults, FormatTOML) } // RegisterStructWithTags is like RegisterStruct but allows custom tag names func (c *Config) RegisterStructWithTags(prefix string, structWithDefaults any, tagName string) error { v := reflect.ValueOf(structWithDefaults) // Handle pointer or direct struct value if v.Kind() == reflect.Ptr { if v.IsNil() { return wrapError(ErrTypeMismatch, fmt.Errorf("RegisterStructWithTags requires a non-nil struct pointer or value")) } v = v.Elem() } if v.Kind() != reflect.Struct { return wrapError(ErrTypeMismatch, fmt.Errorf("RegisterStructWithTags requires a struct or struct pointer, got %T", structWithDefaults)) } // Validate tag name switch tagName { case FormatTOML, FormatJSON, FormatYAML: // Supported tags default: return wrapError(ErrTypeMismatch, fmt.Errorf("unsupported tag name %q, must be one of: toml, json, yaml", tagName)) } var errors []string // Use helper function for recursive registration with specified tag c.registerFields(v, prefix, "", &errors, tagName) if len(errors) > 0 { return wrapError(ErrTypeMismatch, fmt.Errorf("failed to register %d field(s): %s", len(errors), strings.Join(errors, "; "))) } return nil } // registerFields is a helper function that handles the recursive field registration. func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, errors *[]string, tagName string) { t := v.Type() for i := 0; i < v.NumField(); i++ { field := t.Field(i) fieldValue := v.Field(i) if !field.IsExported() { continue } // Get tag value based on tagName parameter tag := field.Tag.Get(tagName) if tag == "-" { continue } // Fall back to field name if no tag key := field.Name if tag != "" { parts := strings.Split(tag, ",") if parts[0] != "" { key = parts[0] } } // Check for additional tags envTag := field.Tag.Get("env") // Explicit env var name required := field.Tag.Get("required") == "true" // Build full path currentPath := key if pathPrefix != "" { if !strings.HasSuffix(pathPrefix, ".") { pathPrefix += "." } currentPath = pathPrefix + key } // Handle nested structs recursively fieldType := fieldValue.Type() isStruct := fieldValue.Kind() == reflect.Struct isPtrToStruct := fieldValue.Kind() == reflect.Ptr && fieldType.Elem().Kind() == reflect.Struct if isStruct || isPtrToStruct { // Check if the field's TYPE is one that should be treated as a single value, // even though it's a struct. These types have custom decode hooks. fieldType := fieldValue.Type() isAtomicStruct := false switch fieldType.String() { case "time.Time", "*net.IPNet", "*url.URL", "net.IP": // Match the exact type names isAtomicStruct = true } // Only recurse if it's a "normal" struct, not an atomic one. if !isAtomicStruct { nestedValue := fieldValue if isPtrToStruct { if fieldValue.IsNil() { continue // Skip nil pointers in the default struct } nestedValue = fieldValue.Elem() } nestedPrefix := currentPath + "." c.registerFields(nestedValue, nestedPrefix, fieldPath+field.Name+".", errors, tagName) continue } // If it is an atomic struct, we fall through and register it as a single value. } // Register non-struct fields defaultValue := fieldValue.Interface() var err error if required { err = c.RegisterRequired(currentPath, defaultValue) } else { err = c.Register(currentPath, defaultValue) } if err != nil { *errors = append(*errors, fmt.Sprintf("field %s%s (path %s): %v", fieldPath, field.Name, currentPath, err)) } // Handle explicit env tag if envTag != "" && err == nil { if value, exists := os.LookupEnv(envTag); exists { parsed := parseValue(value) if setErr := c.SetSource(SourceEnv, currentPath, parsed); setErr != nil { *errors = append(*errors, fmt.Sprintf("field %s%s env %s: %v", fieldPath, field.Name, envTag, setErr)) } } } } } // GetRegisteredPaths returns all registered configuration paths with the specified prefix. func (c *Config) GetRegisteredPaths(prefix ...string) map[string]bool { p := "" if len(prefix) > 0 { p = prefix[0] } c.mutex.RLock() defer c.mutex.RUnlock() result := make(map[string]bool) for path := range c.items { if strings.HasPrefix(path, p) { result[path] = true } } return result } // GetRegisteredPathsWithDefaults returns paths with their default values func (c *Config) GetRegisteredPathsWithDefaults(prefix ...string) map[string]any { p := "" if len(prefix) > 0 { p = prefix[0] } c.mutex.RLock() defer c.mutex.RUnlock() result := make(map[string]any) for path, item := range c.items { if strings.HasPrefix(path, p) { result[path] = item.defaultValue } } return result } // Scan decodes configuration into target using the unified unmarshal function func (c *Config) Scan(target any, basePath ...string) error { return c.unmarshal("", target, basePath...) } // ScanSource decodes configuration from specific source using unified unmarshal func (c *Config) ScanSource(source Source, target any, basePath ...string) error { return c.unmarshal(source, target, basePath...) }