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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-06 12:59:44 +00:00
commit 2705ca5bf3
6 changed files with 691 additions and 0 deletions

212
config.go Normal file
View file

@ -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)

277
config_test.go Normal file
View file

@ -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)
}

40
env.go Normal file
View file

@ -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
}

28
go.mod Normal file
View file

@ -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
)

51
go.sum Normal file
View file

@ -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=

83
service.go Normal file
View file

@ -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)
)