commit 2705ca5bf3412c2fa3f8864e0e71fe1d70b86ed6 Author: Snider Date: Fri Mar 6 12:59:44 2026 +0000 feat: extract config package from core/go pkg/config Layered configuration management (defaults → file → env → flags) with viper backend, YAML persistence via go-io Medium, and framework service. Moved from forge.lthn.ai/core/go/pkg/config to standalone module. Co-Authored-By: Virgil diff --git a/config.go b/config.go new file mode 100644 index 0000000..4a6458e --- /dev/null +++ b/config.go @@ -0,0 +1,212 @@ +// Package config provides layered configuration management for the Core framework. +// +// Configuration values are resolved in priority order: defaults -> file -> env -> flags. +// Values are stored in a YAML file at ~/.core/config.yaml by default. +// +// Keys use dot notation for nested access: +// +// cfg.Set("dev.editor", "vim") +// var editor string +// cfg.Get("dev.editor", &editor) +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + coreerr "forge.lthn.ai/core/go-log" + coreio "forge.lthn.ai/core/go-io" + core "forge.lthn.ai/core/go/pkg/framework/core" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +// Config implements the core.Config interface with layered resolution. +// It uses viper as the underlying configuration engine. +type Config struct { + mu sync.RWMutex + v *viper.Viper + medium coreio.Medium + path string +} + +// Option is a functional option for configuring a Config instance. +type Option func(*Config) + +// WithMedium sets the storage medium for configuration file operations. +func WithMedium(m coreio.Medium) Option { + return func(c *Config) { + c.medium = m + } +} + +// WithPath sets the path to the configuration file. +func WithPath(path string) Option { + return func(c *Config) { + c.path = path + } +} + +// WithEnvPrefix sets the prefix for environment variables. +func WithEnvPrefix(prefix string) Option { + return func(c *Config) { + c.v.SetEnvPrefix(prefix) + } +} + +// New creates a new Config instance with the given options. +// If no medium is provided, it defaults to io.Local. +// If no path is provided, it defaults to ~/.core/config.yaml. +func New(opts ...Option) (*Config, error) { + c := &Config{ + v: viper.New(), + } + + // Configure viper defaults + c.v.SetEnvPrefix("CORE_CONFIG") + c.v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + + for _, opt := range opts { + opt(c) + } + + if c.medium == nil { + c.medium = coreio.Local + } + + if c.path == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, coreerr.E("config.New", "failed to determine home directory", err) + } + c.path = filepath.Join(home, ".core", "config.yaml") + } + + c.v.AutomaticEnv() + + // Load existing config file if it exists + if c.medium.Exists(c.path) { + if err := c.LoadFile(c.medium, c.path); err != nil { + return nil, coreerr.E("config.New", "failed to load config file", err) + } + } + + return c, nil +} + +// LoadFile reads a configuration file from the given medium and path and merges it into the current config. +// It supports YAML and environment files (.env). +func (c *Config) LoadFile(m coreio.Medium, path string) error { + c.mu.Lock() + defer c.mu.Unlock() + + content, err := m.Read(path) + if err != nil { + return coreerr.E("config.LoadFile", "failed to read config file: "+path, err) + } + + ext := filepath.Ext(path) + if ext == "" && filepath.Base(path) == ".env" { + c.v.SetConfigType("env") + } else if ext != "" { + c.v.SetConfigType(strings.TrimPrefix(ext, ".")) + } else { + c.v.SetConfigType("yaml") + } + + if err := c.v.MergeConfig(strings.NewReader(content)); err != nil { + return coreerr.E("config.LoadFile", "failed to parse config file: "+path, err) + } + + return nil +} + +// Get retrieves a configuration value by dot-notation key and stores it in out. +// If key is empty, it unmarshals the entire configuration into out. +// The out parameter must be a pointer to the target type. +func (c *Config) Get(key string, out any) error { + c.mu.RLock() + defer c.mu.RUnlock() + + if key == "" { + return c.v.Unmarshal(out) + } + + if !c.v.IsSet(key) { + return coreerr.E("config.Get", fmt.Sprintf("key not found: %s", key), nil) + } + + return c.v.UnmarshalKey(key, out) +} + +// Set stores a configuration value by dot-notation key and persists to disk. +func (c *Config) Set(key string, v any) error { + c.mu.Lock() + defer c.mu.Unlock() + + c.v.Set(key, v) + + // Persist to disk + if err := Save(c.medium, c.path, c.v.AllSettings()); err != nil { + return coreerr.E("config.Set", "failed to save config", err) + } + + return nil +} + +// All returns a deep copy of all configuration values. +func (c *Config) All() map[string]any { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.v.AllSettings() +} + +// Path returns the path to the configuration file. +func (c *Config) Path() string { + return c.path +} + +// Load reads a YAML configuration file from the given medium and path. +// Returns the parsed data as a map, or an error if the file cannot be read or parsed. +// Deprecated: Use Config.LoadFile instead. +func Load(m coreio.Medium, path string) (map[string]any, error) { + content, err := m.Read(path) + if err != nil { + return nil, coreerr.E("config.Load", "failed to read config file: "+path, err) + } + + v := viper.New() + v.SetConfigType("yaml") + if err := v.ReadConfig(strings.NewReader(content)); err != nil { + return nil, coreerr.E("config.Load", "failed to parse config file: "+path, err) + } + + return v.AllSettings(), nil +} + +// Save writes configuration data to a YAML file at the given path. +// It ensures the parent directory exists before writing. +func Save(m coreio.Medium, path string, data map[string]any) error { + out, err := yaml.Marshal(data) + if err != nil { + return coreerr.E("config.Save", "failed to marshal config", err) + } + + dir := filepath.Dir(path) + if err := m.EnsureDir(dir); err != nil { + return coreerr.E("config.Save", "failed to create config directory: "+dir, err) + } + + if err := m.Write(path, string(out)); err != nil { + return coreerr.E("config.Save", "failed to write config file: "+path, err) + } + + return nil +} + +// Ensure Config implements core.Config at compile time. +var _ core.Config = (*Config)(nil) diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..f899b72 --- /dev/null +++ b/config_test.go @@ -0,0 +1,277 @@ +package config + +import ( + "os" + "testing" + + "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" +) + +func TestConfig_Get_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + err = cfg.Set("app.name", "core") + assert.NoError(t, err) + + var name string + err = cfg.Get("app.name", &name) + assert.NoError(t, err) + assert.Equal(t, "core", name) +} + +func TestConfig_Get_Bad(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + var value string + err = cfg.Get("nonexistent.key", &value) + assert.Error(t, err) + assert.Contains(t, err.Error(), "key not found") +} + +func TestConfig_Set_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + err = cfg.Set("dev.editor", "vim") + assert.NoError(t, err) + + // Verify the value was saved to the medium + content, readErr := m.Read("/tmp/test/config.yaml") + assert.NoError(t, readErr) + assert.Contains(t, content, "editor: vim") + + // Verify we can read it back + var editor string + err = cfg.Get("dev.editor", &editor) + assert.NoError(t, err) + assert.Equal(t, "vim", editor) +} + +func TestConfig_Set_Nested_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + err = cfg.Set("a.b.c", "deep") + assert.NoError(t, err) + + var val string + err = cfg.Get("a.b.c", &val) + assert.NoError(t, err) + assert.Equal(t, "deep", val) +} + +func TestConfig_All_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + _ = cfg.Set("key1", "val1") + _ = cfg.Set("key2", "val2") + + all := cfg.All() + assert.Equal(t, "val1", all["key1"]) + assert.Equal(t, "val2", all["key2"]) +} + +func TestConfig_Path_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/custom/path/config.yaml")) + assert.NoError(t, err) + + assert.Equal(t, "/custom/path/config.yaml", cfg.Path()) +} + +func TestConfig_Load_Existing_Good(t *testing.T) { + m := io.NewMockMedium() + m.Files["/tmp/test/config.yaml"] = "app:\n name: existing\n" + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + var name string + err = cfg.Get("app.name", &name) + assert.NoError(t, err) + assert.Equal(t, "existing", name) +} + +func TestConfig_Env_Good(t *testing.T) { + // Set environment variable + t.Setenv("CORE_CONFIG_DEV_EDITOR", "nano") + + m := io.NewMockMedium() + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + var editor string + err = cfg.Get("dev.editor", &editor) + assert.NoError(t, err) + assert.Equal(t, "nano", editor) +} + +func TestConfig_Env_Overrides_File_Good(t *testing.T) { + // Set file config + m := io.NewMockMedium() + m.Files["/tmp/test/config.yaml"] = "dev:\n editor: vim\n" + + // Set environment override + t.Setenv("CORE_CONFIG_DEV_EDITOR", "nano") + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + var editor string + err = cfg.Get("dev.editor", &editor) + assert.NoError(t, err) + assert.Equal(t, "nano", editor) +} + +func TestConfig_Assign_Types_Good(t *testing.T) { + m := io.NewMockMedium() + m.Files["/tmp/test/config.yaml"] = "count: 42\nenabled: true\nratio: 3.14\n" + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + var count int + err = cfg.Get("count", &count) + assert.NoError(t, err) + assert.Equal(t, 42, count) + + var enabled bool + err = cfg.Get("enabled", &enabled) + assert.NoError(t, err) + assert.True(t, enabled) + + var ratio float64 + err = cfg.Get("ratio", &ratio) + assert.NoError(t, err) + assert.InDelta(t, 3.14, ratio, 0.001) +} + +func TestConfig_Assign_Any_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + _ = cfg.Set("key", "value") + + var val any + err = cfg.Get("key", &val) + assert.NoError(t, err) + assert.Equal(t, "value", val) +} + +func TestConfig_DefaultPath_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m)) + assert.NoError(t, err) + + home, _ := os.UserHomeDir() + assert.Equal(t, home+"/.core/config.yaml", cfg.Path()) +} + +func TestLoadEnv_Good(t *testing.T) { + t.Setenv("CORE_CONFIG_FOO_BAR", "baz") + t.Setenv("CORE_CONFIG_SIMPLE", "value") + + result := LoadEnv("CORE_CONFIG_") + assert.Equal(t, "baz", result["foo.bar"]) + assert.Equal(t, "value", result["simple"]) +} + +func TestLoad_Bad(t *testing.T) { + m := io.NewMockMedium() + + _, err := Load(m, "/nonexistent/file.yaml") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read config file") +} + +func TestLoad_InvalidYAML_Bad(t *testing.T) { + m := io.NewMockMedium() + m.Files["/tmp/test/config.yaml"] = "invalid: yaml: content: [[[[" + + _, err := Load(m, "/tmp/test/config.yaml") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse config file") +} + +func TestSave_Good(t *testing.T) { + m := io.NewMockMedium() + + data := map[string]any{ + "key": "value", + } + + err := Save(m, "/tmp/test/config.yaml", data) + assert.NoError(t, err) + + content, readErr := m.Read("/tmp/test/config.yaml") + assert.NoError(t, readErr) + assert.Contains(t, content, "key: value") +} + +func TestConfig_LoadFile_Env(t *testing.T) { + m := io.NewMockMedium() + m.Files["/.env"] = "FOO=bar\nBAZ=qux" + + cfg, err := New(WithMedium(m), WithPath("/config.yaml")) + assert.NoError(t, err) + + err = cfg.LoadFile(m, "/.env") + assert.NoError(t, err) + + var foo string + err = cfg.Get("foo", &foo) + assert.NoError(t, err) + assert.Equal(t, "bar", foo) +} + +func TestConfig_WithEnvPrefix(t *testing.T) { + t.Setenv("MYAPP_SETTING", "secret") + + m := io.NewMockMedium() + cfg, err := New(WithMedium(m), WithEnvPrefix("MYAPP")) + assert.NoError(t, err) + + var setting string + err = cfg.Get("setting", &setting) + assert.NoError(t, err) + assert.Equal(t, "secret", setting) +} + +func TestConfig_Get_EmptyKey(t *testing.T) { + m := io.NewMockMedium() + m.Files["/config.yaml"] = "app:\n name: test\nversion: 1" + + cfg, err := New(WithMedium(m), WithPath("/config.yaml")) + assert.NoError(t, err) + + type AppConfig struct { + App struct { + Name string `mapstructure:"name"` + } `mapstructure:"app"` + Version int `mapstructure:"version"` + } + + var full AppConfig + err = cfg.Get("", &full) + assert.NoError(t, err) + assert.Equal(t, "test", full.App.Name) + assert.Equal(t, 1, full.Version) +} diff --git a/env.go b/env.go new file mode 100644 index 0000000..711e3ec --- /dev/null +++ b/env.go @@ -0,0 +1,40 @@ +package config + +import ( + "os" + "strings" +) + +// LoadEnv parses environment variables with the given prefix and returns +// them as a flat map with dot-notation keys. +// +// For example, with prefix "CORE_CONFIG_": +// +// CORE_CONFIG_FOO_BAR=baz -> {"foo.bar": "baz"} +// CORE_CONFIG_EDITOR=vim -> {"editor": "vim"} +func LoadEnv(prefix string) map[string]any { + result := make(map[string]any) + + for _, env := range os.Environ() { + if !strings.HasPrefix(env, prefix) { + continue + } + + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 { + continue + } + + name := parts[0] + value := parts[1] + + // Strip prefix and convert to dot notation + key := strings.TrimPrefix(name, prefix) + key = strings.ToLower(key) + key = strings.ReplaceAll(key, "_", ".") + + result[key] = value + } + + return result +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..28b0c1b --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module forge.lthn.ai/core/go-config + +go 1.26.0 + +require ( + forge.lthn.ai/core/go v0.1.0 + forge.lthn.ai/core/go-io v0.0.3 + forge.lthn.ai/core/go-log v0.0.1 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..be60cd5 --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI= +forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ= +forge.lthn.ai/core/go-io v0.0.3 h1:TlhYpGTyjPgAlbEHyYrVSeUChZPhJXcLZ7D/8IbFqfI= +forge.lthn.ai/core/go-io v0.0.3/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0= +forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg= +forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/service.go b/service.go new file mode 100644 index 0000000..1192d06 --- /dev/null +++ b/service.go @@ -0,0 +1,83 @@ +package config + +import ( + "context" + + coreerr "forge.lthn.ai/core/go-log" + "forge.lthn.ai/core/go-io" + core "forge.lthn.ai/core/go/pkg/framework/core" +) + +// Service wraps Config as a framework service with lifecycle support. +type Service struct { + *core.ServiceRuntime[ServiceOptions] + config *Config +} + +// ServiceOptions holds configuration for the config service. +type ServiceOptions struct { + // Path overrides the default config file path. + Path string + // Medium overrides the default storage medium. + Medium io.Medium +} + +// NewConfigService creates a new config service factory for the Core framework. +// Register it with core.WithService(config.NewConfigService). +func NewConfigService(c *core.Core) (any, error) { + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + return svc, nil +} + +// OnStartup loads the configuration file during application startup. +func (s *Service) OnStartup(_ context.Context) error { + opts := s.Opts() + + var configOpts []Option + if opts.Path != "" { + configOpts = append(configOpts, WithPath(opts.Path)) + } + if opts.Medium != nil { + configOpts = append(configOpts, WithMedium(opts.Medium)) + } + + cfg, err := New(configOpts...) + if err != nil { + return err + } + + s.config = cfg + return nil +} + +// Get retrieves a configuration value by key. +func (s *Service) Get(key string, out any) error { + if s.config == nil { + return coreerr.E("config.Service.Get", "config not loaded", nil) + } + return s.config.Get(key, out) +} + +// Set stores a configuration value by key. +func (s *Service) Set(key string, v any) error { + if s.config == nil { + return coreerr.E("config.Service.Set", "config not loaded", nil) + } + return s.config.Set(key, v) +} + +// LoadFile merges a configuration file into the central configuration. +func (s *Service) LoadFile(m io.Medium, path string) error { + if s.config == nil { + return coreerr.E("config.Service.LoadFile", "config not loaded", nil) + } + return s.config.LoadFile(m, path) +} + +// Ensure Service implements core.Config and Startable at compile time. +var ( + _ core.Config = (*Service)(nil) + _ core.Startable = (*Service)(nil) +)