397 lines
12 KiB
Go
397 lines
12 KiB
Go
// Package config provides a configuration management service that handles
|
|
// loading, saving, and accessing application settings. It supports both a
|
|
// main JSON configuration file and auxiliary data stored in various formats
|
|
// like YAML, INI, and XML. The service is designed to be extensible and
|
|
// can be used with static or dynamic dependency injection.
|
|
//
|
|
// The Service struct is the core of the package, providing methods to
|
|
// interact with the configuration. It manages file paths, default values,
|
|
// and the serialization/deserialization of data.
|
|
//
|
|
// Basic usage involves creating a new Service instance and then using its
|
|
// methods to get, set, and manage configuration data.
|
|
//
|
|
// Example:
|
|
//
|
|
// // Create a new config service.
|
|
// cfg, err := config.New()
|
|
// if err != nil {
|
|
// log.Fatalf("failed to create config service: %v", err)
|
|
// }
|
|
//
|
|
// // Set a new value.
|
|
// err = cfg.Set("language", "fr")
|
|
// if err != nil {
|
|
// log.Fatalf("failed to set config value: %v", err)
|
|
// }
|
|
//
|
|
// // Retrieve a value.
|
|
// var lang string
|
|
// err = cfg.Get("language", &lang)
|
|
// if err != nil {
|
|
// log.Fatalf("failed to get config value: %v", err)
|
|
// }
|
|
// fmt.Printf("Language: %s\n", lang)
|
|
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"forge.lthn.ai/core/gui/pkg/core"
|
|
"github.com/adrg/xdg"
|
|
)
|
|
|
|
// HandleIPCEvents processes IPC messages for the config service.
|
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
|
switch msg.(type) {
|
|
case core.ActionServiceStartup:
|
|
// Config initializes during Register(), no additional startup needed.
|
|
return nil
|
|
default:
|
|
if c.App != nil && c.App.Logger != nil {
|
|
c.App.Logger.Debug("Config: Unhandled message type", "type", fmt.Sprintf("%T", msg))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const appName = "lethean"
|
|
const configFileName = "config.json"
|
|
|
|
// Options holds configuration for the config service. This struct is provided
|
|
// for future extensibility and currently has no fields.
|
|
type Options struct{}
|
|
|
|
// Service provides access to the application's configuration.
|
|
// It handles loading, saving, and providing access to configuration values,
|
|
// abstracting away the details of file I/O and data serialization.
|
|
// The Service is designed to be a central point for all configuration-related
|
|
// operations within the application.
|
|
//
|
|
// The fields of the Service struct are automatically saved to and loaded from
|
|
// a JSON configuration file. The `json:"-"` tag on ServiceRuntime prevents
|
|
// it from being serialized.
|
|
type Service struct {
|
|
*core.ServiceRuntime[Options] `json:"-"`
|
|
|
|
// Persistent fields, saved to config.json.
|
|
ConfigPath string `json:"configPath,omitempty"`
|
|
UserHomeDir string `json:"userHomeDir,omitempty"`
|
|
RootDir string `json:"rootDir,omitempty"`
|
|
CacheDir string `json:"cacheDir,omitempty"`
|
|
ConfigDir string `json:"configDir,omitempty"`
|
|
DataDir string `json:"dataDir,omitempty"`
|
|
WorkspaceDir string `json:"workspaceDir,omitempty"`
|
|
DefaultRoute string `json:"default_route"`
|
|
Features []string `json:"features"`
|
|
Language string `json:"language"`
|
|
}
|
|
|
|
// createServiceInstance handles the setup of the configuration service. It
|
|
// resolves necessary paths, creates directories, and loads the configuration
|
|
// file if it exists. If the configuration file is not found, it creates a new
|
|
// one with default values. This function is not exported and is used internally
|
|
// by the New and Register constructors.
|
|
func createServiceInstance() (*Service, error) {
|
|
// --- Path and Directory Setup ---
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not resolve user home directory: %w", err)
|
|
}
|
|
userHomeDir := filepath.Join(homeDir, appName)
|
|
|
|
rootDir, err := xdg.DataFile(appName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not resolve data directory: %w", err)
|
|
}
|
|
|
|
cacheDir, err := xdg.CacheFile(appName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not resolve cache directory: %w", err)
|
|
}
|
|
|
|
s := &Service{
|
|
UserHomeDir: userHomeDir,
|
|
RootDir: rootDir,
|
|
CacheDir: cacheDir,
|
|
ConfigDir: filepath.Join(userHomeDir, "config"),
|
|
DataDir: filepath.Join(userHomeDir, "data"),
|
|
WorkspaceDir: filepath.Join(userHomeDir, "workspace"),
|
|
DefaultRoute: "/",
|
|
Features: []string{},
|
|
Language: "en",
|
|
}
|
|
s.ConfigPath = filepath.Join(s.ConfigDir, configFileName)
|
|
|
|
dirs := []string{s.RootDir, s.ConfigDir, s.DataDir, s.CacheDir, s.WorkspaceDir, s.UserHomeDir}
|
|
for _, dir := range dirs {
|
|
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
|
return nil, fmt.Errorf("could not create directory %s: %w", dir, err)
|
|
}
|
|
}
|
|
|
|
// --- Load or Create Configuration ---
|
|
if data, err := os.ReadFile(s.ConfigPath); err == nil {
|
|
// Config file exists, load it.
|
|
if err := json.Unmarshal(data, s); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
|
}
|
|
} else if os.IsNotExist(err) {
|
|
// Config file does not exist, create it with default values.
|
|
if err := s.Save(); err != nil {
|
|
return nil, fmt.Errorf("failed to create default config file: %w", err)
|
|
}
|
|
} else {
|
|
// Another error occurred reading the file.
|
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// New creates a new instance of the configuration service. This constructor is
|
|
// intended for static dependency injection, where the service is created and
|
|
// managed manually. It initializes the service with default paths and values,
|
|
// and loads any existing configuration from disk.
|
|
//
|
|
// Example:
|
|
//
|
|
// cfg, err := config.New()
|
|
// if err != nil {
|
|
// log.Fatalf("Failed to initialize config: %v", err)
|
|
// }
|
|
// // Use cfg to access configuration settings.
|
|
func New() (*Service, error) {
|
|
return createServiceInstance()
|
|
}
|
|
|
|
// Register creates a new instance of the configuration service and registers it
|
|
// with the application's core. This constructor is intended for dynamic
|
|
// dependency injection, where services are managed by a central core component.
|
|
// It performs the same initialization as New, but also integrates the service
|
|
// with the provided core instance.
|
|
func Register(c *core.Core) (any, error) {
|
|
s, err := createServiceInstance()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Defensive check: createServiceInstance should not return nil service with nil error
|
|
if s == nil {
|
|
return nil, errors.New("config: createServiceInstance returned a nil service instance with no error")
|
|
}
|
|
s.ServiceRuntime = core.NewServiceRuntime(c, Options{})
|
|
return s, nil
|
|
}
|
|
|
|
// Save writes the current configuration to a JSON file. The location of the file
|
|
// is determined by the ConfigPath field of the Service struct. This method is
|
|
// typically called automatically by Set, but can be used to explicitly save
|
|
// changes.
|
|
//
|
|
// Example:
|
|
//
|
|
// err := cfg.Save()
|
|
// if err != nil {
|
|
// log.Printf("Error saving configuration: %v", err)
|
|
// }
|
|
func (s *Service) Save() error {
|
|
data, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal config: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(s.ConfigPath, data, 0644); err != nil {
|
|
return fmt.Errorf("failed to write config file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Get retrieves a configuration value by its key. The key corresponds to the
|
|
// JSON tag of a field in the Service struct. The retrieved value is stored in
|
|
// the `out` parameter, which must be a non-nil pointer to a variable of the
|
|
// correct type.
|
|
//
|
|
// Example:
|
|
//
|
|
// var currentLanguage string
|
|
// err := cfg.Get("language", ¤tLanguage)
|
|
// if err != nil {
|
|
// log.Printf("Could not retrieve language setting: %v", err)
|
|
// }
|
|
// fmt.Println("Current language is:", currentLanguage)
|
|
func (s *Service) Get(key string, out any) error {
|
|
val := reflect.ValueOf(s).Elem()
|
|
typ := val.Type()
|
|
|
|
for i := 0; i < val.NumField(); i++ {
|
|
field := typ.Field(i)
|
|
jsonTag := field.Tag.Get("json")
|
|
if jsonTag != "" && jsonTag != "-" {
|
|
jsonName := strings.Split(jsonTag, ",")[0]
|
|
if strings.EqualFold(jsonName, key) {
|
|
outVal := reflect.ValueOf(out)
|
|
if outVal.Kind() != reflect.Ptr || outVal.IsNil() {
|
|
return errors.New("output argument must be a non-nil pointer")
|
|
}
|
|
targetVal := outVal.Elem()
|
|
srcVal := val.Field(i)
|
|
|
|
if !srcVal.Type().AssignableTo(targetVal.Type()) {
|
|
return fmt.Errorf("cannot assign config value of type %s to output of type %s", srcVal.Type(), targetVal.Type())
|
|
}
|
|
targetVal.Set(srcVal)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("key '%s' not found in config", key)
|
|
}
|
|
|
|
// SaveStruct saves an arbitrary struct to a JSON file in the config directory.
|
|
// This is useful for storing complex data that is not part of the main
|
|
// configuration. The `key` parameter is used as the filename (with a .json
|
|
// extension).
|
|
//
|
|
// Example:
|
|
//
|
|
// type UserPreferences struct {
|
|
// Theme string `json:"theme"`
|
|
// Notifications bool `json:"notifications"`
|
|
// }
|
|
// prefs := UserPreferences{Theme: "dark", Notifications: true}
|
|
// err := cfg.SaveStruct("user_prefs", prefs)
|
|
// if err != nil {
|
|
// log.Printf("Error saving user preferences: %v", err)
|
|
// }
|
|
func (s *Service) SaveStruct(key string, data interface{}) error {
|
|
filePath := filepath.Join(s.ConfigDir, key+".json")
|
|
jsonData, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal struct for key '%s': %w", key, err)
|
|
}
|
|
return os.WriteFile(filePath, jsonData, 0644)
|
|
}
|
|
|
|
// LoadStruct loads an arbitrary struct from a JSON file in the config directory.
|
|
// The `key` parameter specifies the filename (without the .json extension). The
|
|
// loaded data is unmarshaled into the `data` parameter, which must be a
|
|
// non-nil pointer to a struct.
|
|
//
|
|
// Example:
|
|
//
|
|
// var prefs UserPreferences
|
|
// err := cfg.LoadStruct("user_prefs", &prefs)
|
|
// if err != nil {
|
|
// log.Printf("Error loading user preferences: %v", err)
|
|
// }
|
|
// fmt.Printf("User theme is: %s", prefs.Theme)
|
|
func (s *Service) LoadStruct(key string, data interface{}) error {
|
|
filePath := filepath.Join(s.ConfigDir, key+".json")
|
|
jsonData, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil // Return nil if the file doesn't exist
|
|
}
|
|
return fmt.Errorf("failed to read struct file for key '%s': %w", key, err)
|
|
}
|
|
return json.Unmarshal(jsonData, data)
|
|
}
|
|
|
|
// Set updates a configuration value and saves the change to the configuration
|
|
// file. The key corresponds to the JSON tag of a field in the Service struct.
|
|
// The provided value `v` must be of a type that is assignable to the field.
|
|
//
|
|
// Example:
|
|
//
|
|
// err := cfg.Set("default_route", "/home")
|
|
// if err != nil {
|
|
// log.Printf("Failed to set default route: %v", err)
|
|
// }
|
|
func (s *Service) Set(key string, v any) error {
|
|
val := reflect.ValueOf(s).Elem()
|
|
typ := val.Type()
|
|
|
|
for i := 0; i < val.NumField(); i++ {
|
|
field := typ.Field(i)
|
|
jsonTag := field.Tag.Get("json")
|
|
if jsonTag != "" && jsonTag != "-" {
|
|
jsonName := strings.Split(jsonTag, ",")[0]
|
|
if strings.EqualFold(jsonName, key) {
|
|
fieldVal := val.Field(i)
|
|
if !fieldVal.CanSet() {
|
|
return fmt.Errorf("cannot set config field for key '%s'", key)
|
|
}
|
|
newVal := reflect.ValueOf(v)
|
|
if !newVal.Type().AssignableTo(fieldVal.Type()) {
|
|
return fmt.Errorf("type mismatch for key '%s': expected %s, got %s", key, fieldVal.Type(), newVal.Type())
|
|
}
|
|
fieldVal.Set(newVal)
|
|
return s.Save()
|
|
}
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("key '%s' not found in config", key)
|
|
}
|
|
|
|
// EnableFeature enables a feature by adding it to the features list.
|
|
// If the feature is already enabled, this is a no-op.
|
|
//
|
|
// Example:
|
|
//
|
|
// err := cfg.EnableFeature("dark_mode")
|
|
// if err != nil {
|
|
// log.Printf("Failed to enable feature: %v", err)
|
|
// }
|
|
func (s *Service) EnableFeature(feature string) error {
|
|
// Check if feature is already enabled
|
|
for _, f := range s.Features {
|
|
if f == feature {
|
|
return nil // Already enabled
|
|
}
|
|
}
|
|
s.Features = append(s.Features, feature)
|
|
return s.Save()
|
|
}
|
|
|
|
// DisableFeature disables a feature by removing it from the features list.
|
|
// If the feature is not enabled, this is a no-op.
|
|
//
|
|
// Example:
|
|
//
|
|
// err := cfg.DisableFeature("dark_mode")
|
|
// if err != nil {
|
|
// log.Printf("Failed to disable feature: %v", err)
|
|
// }
|
|
func (s *Service) DisableFeature(feature string) error {
|
|
for i, f := range s.Features {
|
|
if f == feature {
|
|
s.Features = append(s.Features[:i], s.Features[i+1:]...)
|
|
return s.Save()
|
|
}
|
|
}
|
|
return nil // Feature wasn't enabled, no-op
|
|
}
|
|
|
|
// IsFeatureEnabled checks if a feature is enabled.
|
|
//
|
|
// Example:
|
|
//
|
|
// if cfg.IsFeatureEnabled("dark_mode") {
|
|
// // Apply dark mode styles
|
|
// }
|
|
func (s *Service) IsFeatureEnabled(feature string) bool {
|
|
for _, f := range s.Features {
|
|
if f == feature {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|