feat: rename go-config to core/config
Module path changed from forge.lthn.ai/core/go-config to forge.lthn.ai/core/config. Package name remains "config" — only the module path changed. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
commit
cd93ad8081
12 changed files with 1285 additions and 0 deletions
24
.core/build.yaml
Normal file
24
.core/build.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
version: 1
|
||||
|
||||
project:
|
||||
name: config
|
||||
description: Configuration management
|
||||
binary: ""
|
||||
|
||||
build:
|
||||
cgo: false
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s
|
||||
- -w
|
||||
|
||||
targets:
|
||||
- os: linux
|
||||
arch: amd64
|
||||
- os: linux
|
||||
arch: arm64
|
||||
- os: darwin
|
||||
arch: arm64
|
||||
- os: windows
|
||||
arch: amd64
|
||||
20
.core/release.yaml
Normal file
20
.core/release.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
version: 1
|
||||
|
||||
project:
|
||||
name: config
|
||||
repository: core/config
|
||||
|
||||
publishers: []
|
||||
|
||||
changelog:
|
||||
include:
|
||||
- feat
|
||||
- fix
|
||||
- perf
|
||||
- refactor
|
||||
exclude:
|
||||
- chore
|
||||
- docs
|
||||
- style
|
||||
- test
|
||||
- ci
|
||||
54
CLAUDE.md
Normal file
54
CLAUDE.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
This project uses the Core CLI (`core` binary), not `go` directly.
|
||||
|
||||
```bash
|
||||
core go test # run all tests
|
||||
core go test --run TestConfig_Get_Good # run a single test
|
||||
core go cov # test with coverage
|
||||
core go cov --open # coverage + open HTML report
|
||||
|
||||
core go qa # format, vet, lint, test
|
||||
core go qa full # adds race detector, vuln scan, security audit
|
||||
|
||||
core go fmt # format
|
||||
core go vet # vet
|
||||
core go lint # lint
|
||||
```
|
||||
|
||||
This is a library package — there is no binary to build or run.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Dual-Viper pattern**: `Config` holds two `*viper.Viper` instances:
|
||||
- `v` (full) — file + env + defaults; used for all reads (`Get`, `All`)
|
||||
- `f` (file-only) — file + explicit `Set()` calls; used for persistence (`Commit`)
|
||||
|
||||
This prevents environment variables from leaking into saved config files. When implementing new features, maintain this invariant: writes go to both `v` and `f`; reads come from `v`; persistence comes from `f`.
|
||||
|
||||
**Resolution priority** (ascending): defaults → file → env vars (`CORE_CONFIG_*`) → `Set()`
|
||||
|
||||
**Service wrapper**: `Service` in `service.go` wraps `Config` with framework lifecycle (`core.Startable`). Both `Config` and `Service` satisfy `core.Config`, enforced by compile-time assertions.
|
||||
|
||||
**Storage abstraction**: All file I/O goes through `io.Medium` (from `go-io`). Tests use `io.NewMockMedium()` with an in-memory `Files` map — never touch the real filesystem.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **UK English** in comments and documentation (colour, organisation, centre)
|
||||
- **Error wrapping**: `coreerr.E(caller, message, underlying)` from `go-log`
|
||||
- **Test naming**: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases)
|
||||
- **Functional options**: `New()` takes `...Option` (e.g. `WithMedium`, `WithPath`, `WithEnvPrefix`)
|
||||
- **Conventional commits**: `type(scope): description`
|
||||
- **Go workspace**: module is part of `~/Code/go.work`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `forge.lthn.ai/core/go-io` — `Medium` interface for storage
|
||||
- `forge.lthn.ai/core/go-log` — `coreerr.E()` error helper
|
||||
- `forge.lthn.ai/core/go/pkg/core` — `core.Config`, `core.Startable`, `core.ServiceRuntime` interfaces
|
||||
- `github.com/spf13/viper` — configuration engine
|
||||
- `github.com/stretchr/testify` — test assertions
|
||||
237
config.go
Normal file
237
config.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
// 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"
|
||||
"iter"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
core "forge.lthn.ai/core/go/pkg/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 // Full configuration (file + env + defaults)
|
||||
f *viper.Viper // File-backed configuration only (for persistence)
|
||||
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(),
|
||||
f: 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", fmt.Sprintf("failed to read config file: %s", path), err)
|
||||
}
|
||||
|
||||
ext := filepath.Ext(path)
|
||||
configType := "yaml"
|
||||
if ext == "" && filepath.Base(path) == ".env" {
|
||||
configType = "env"
|
||||
} else if ext != "" {
|
||||
configType = strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
|
||||
// Load into file-backed viper
|
||||
c.f.SetConfigType(configType)
|
||||
if err := c.f.MergeConfig(strings.NewReader(content)); err != nil {
|
||||
return coreerr.E("config.LoadFile", fmt.Sprintf("failed to parse config file (f): %s", path), err)
|
||||
}
|
||||
|
||||
// Load into full viper
|
||||
c.v.SetConfigType(configType)
|
||||
if err := c.v.MergeConfig(strings.NewReader(content)); err != nil {
|
||||
return coreerr.E("config.LoadFile", fmt.Sprintf("failed to parse config file (v): %s", 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 == "" {
|
||||
if err := c.v.Unmarshal(out); err != nil {
|
||||
return coreerr.E("config.Get", "failed to unmarshal full config", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !c.v.IsSet(key) {
|
||||
return coreerr.E("config.Get", fmt.Sprintf("key not found: %s", key), nil)
|
||||
}
|
||||
|
||||
if err := c.v.UnmarshalKey(key, out); err != nil {
|
||||
return coreerr.E("config.Get", fmt.Sprintf("failed to unmarshal key: %s", key), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set stores a configuration value in memory.
|
||||
// Call Commit() to persist changes to disk.
|
||||
func (c *Config) Set(key string, v any) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.f.Set(key, v)
|
||||
c.v.Set(key, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commit persists any changes made via Set() to the configuration file on disk.
|
||||
// This will only save the configuration that was loaded from the file or explicitly Set(),
|
||||
// preventing environment variable leakage.
|
||||
func (c *Config) Commit() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if err := Save(c.medium, c.path, c.f.AllSettings()); err != nil {
|
||||
return coreerr.E("config.Commit", "failed to save config", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// All returns an iterator over all configuration values (including environment variables).
|
||||
func (c *Config) All() iter.Seq2[string, any] {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return maps.All(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)
|
||||
281
config_test.go
Normal file
281
config_test.go
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"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)
|
||||
|
||||
err = cfg.Commit()
|
||||
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 := maps.Collect(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)
|
||||
}
|
||||
188
docs/architecture.md
Normal file
188
docs/architecture.md
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
---
|
||||
title: Architecture
|
||||
description: Internal design of config -- dual-viper layering, the Medium abstraction, and the framework service wrapper.
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
## Design Goals
|
||||
|
||||
1. **Layered resolution** -- a single `Get()` call checks environment, file, and defaults without the caller needing to know which source won.
|
||||
2. **Safe persistence** -- environment variables must never bleed into the saved config file.
|
||||
3. **Pluggable storage** -- the file system is abstracted behind `io.Medium`, making tests deterministic and enabling future remote backends.
|
||||
4. **Framework integration** -- the package satisfies the `core.Config` interface, so any Core service can consume configuration without importing this package directly.
|
||||
|
||||
## Key Types
|
||||
|
||||
### Config
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
mu sync.RWMutex
|
||||
v *viper.Viper // full configuration (file + env + defaults)
|
||||
f *viper.Viper // file-backed configuration only (for persistence)
|
||||
medium coreio.Medium
|
||||
path string
|
||||
}
|
||||
```
|
||||
|
||||
`Config` is the central type. It holds **two** Viper instances:
|
||||
|
||||
- **`v`** (full) -- contains everything: file values, environment bindings, and explicit `Set()` calls. All reads go through `v`.
|
||||
- **`f`** (file) -- contains only values that originated from the config file or were explicitly set via `Set()`. All writes (`Commit()`) go through `f`.
|
||||
|
||||
This dual-instance design is the key architectural decision. It solves the environment leakage problem: Viper merges environment variables into its settings map, which means a naive `SaveConfig()` would serialise env vars into the YAML file. By maintaining `f` as a clean copy, `Commit()` only persists what should be persisted.
|
||||
|
||||
### Option
|
||||
|
||||
```go
|
||||
type Option func(*Config)
|
||||
```
|
||||
|
||||
Functional options configure the `Config` at creation time:
|
||||
|
||||
| Option | Effect |
|
||||
|-------------------|----------------------------------------------------|
|
||||
| `WithMedium(m)` | Sets the storage backend (defaults to `io.Local`) |
|
||||
| `WithPath(path)` | Sets the config file path (defaults to `~/.core/config.yaml`) |
|
||||
| `WithEnvPrefix(p)`| Changes the environment variable prefix (defaults to `CORE_CONFIG`) |
|
||||
|
||||
### Service
|
||||
|
||||
```go
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[ServiceOptions]
|
||||
config *Config
|
||||
}
|
||||
```
|
||||
|
||||
`Service` wraps `Config` as a framework-managed service. It embeds `ServiceRuntime` for typed options and implements two interfaces:
|
||||
|
||||
- **`core.Config`** -- `Get(key, out)` and `Set(key, v)`
|
||||
- **`core.Startable`** -- `OnStartup(ctx)` triggers config file loading during the application lifecycle
|
||||
|
||||
The service is registered as a factory function:
|
||||
|
||||
```go
|
||||
core.New(core.WithService(config.NewConfigService))
|
||||
```
|
||||
|
||||
The factory receives the `*core.Core` instance, constructs the service, and returns it. The framework calls `OnStartup` at the appropriate lifecycle phase, at which point the config file is loaded.
|
||||
|
||||
### Env / LoadEnv
|
||||
|
||||
```go
|
||||
func Env(prefix string) iter.Seq2[string, any]
|
||||
func LoadEnv(prefix string) map[string]any // deprecated
|
||||
```
|
||||
|
||||
`Env` returns a Go 1.23+ iterator over environment variables matching a given prefix, yielding `(dotKey, value)` pairs. The conversion logic:
|
||||
|
||||
1. Filter `os.Environ()` for entries starting with `prefix`
|
||||
2. Strip the prefix
|
||||
3. Lowercase and replace `_` with `.`
|
||||
|
||||
`LoadEnv` is the older materialising variant and is deprecated in favour of the iterator.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Initialisation (`New`)
|
||||
|
||||
```
|
||||
New(opts...)
|
||||
|
|
||||
+-- create two viper instances (v, f)
|
||||
+-- set env prefix on v ("CORE_CONFIG_" default)
|
||||
+-- set env key replacer ("." <-> "_")
|
||||
+-- apply functional options
|
||||
+-- default medium to io.Local if nil
|
||||
+-- default path to ~/.core/config.yaml if empty
|
||||
+-- enable v.AutomaticEnv()
|
||||
+-- if config file exists:
|
||||
LoadFile(medium, path)
|
||||
+-- medium.Read(path)
|
||||
+-- detect config type from extension
|
||||
+-- f.MergeConfig(content)
|
||||
+-- v.MergeConfig(content)
|
||||
```
|
||||
|
||||
### Read (`Get`)
|
||||
|
||||
```
|
||||
Get(key, &out)
|
||||
|
|
||||
+-- RLock
|
||||
+-- if key == "":
|
||||
| v.Unmarshal(out) // full config into a struct
|
||||
+-- else:
|
||||
| v.IsSet(key)?
|
||||
| yes -> v.UnmarshalKey(key, out)
|
||||
| no -> error "key not found"
|
||||
+-- RUnlock
|
||||
```
|
||||
|
||||
Because `v` has `AutomaticEnv()` enabled, `v.IsSet(key)` returns true if the key exists in the file **or** as a `CORE_CONFIG_*` environment variable.
|
||||
|
||||
### Write (`Set` + `Commit`)
|
||||
|
||||
```
|
||||
Set(key, value)
|
||||
|
|
||||
+-- Lock
|
||||
+-- f.Set(key, value) // track for persistence
|
||||
+-- v.Set(key, value) // make visible to Get()
|
||||
+-- Unlock
|
||||
|
||||
Commit()
|
||||
|
|
||||
+-- Lock
|
||||
+-- Save(medium, path, f.AllSettings())
|
||||
| +-- yaml.Marshal(data)
|
||||
| +-- medium.EnsureDir(dir)
|
||||
| +-- medium.Write(path, content)
|
||||
+-- Unlock
|
||||
```
|
||||
|
||||
Note that `Commit()` serialises `f.AllSettings()`, not `v.AllSettings()`. This is intentional -- it prevents environment variable values from being written to the config file.
|
||||
|
||||
### File Loading (`LoadFile`)
|
||||
|
||||
`LoadFile` supports both YAML and `.env` files. The config type is inferred from the file extension:
|
||||
|
||||
- `.yaml`, `.yml` -- YAML
|
||||
- `.json` -- JSON
|
||||
- `.toml` -- TOML
|
||||
- `.env` (no extension, basename `.env`) -- dotenv format
|
||||
|
||||
Content is merged (not replaced) into both `v` and `f` via `MergeConfig`, so multiple files can be layered.
|
||||
|
||||
## Concurrency
|
||||
|
||||
All public methods on `Config` and `Service` are safe for concurrent use. A `sync.RWMutex` protects the internal state:
|
||||
|
||||
- `Get`, `All` take a read lock
|
||||
- `Set`, `Commit`, `LoadFile` take a write lock
|
||||
|
||||
## The Medium Abstraction
|
||||
|
||||
File operations go through `io.Medium` (from `forge.lthn.ai/core/go-io`), not `os` directly. This means:
|
||||
|
||||
- **Tests** use `io.NewMockMedium()` -- an in-memory filesystem with a `Files` map
|
||||
- **Production** uses `io.Local` -- the real local filesystem
|
||||
- **Future** backends (S3, embedded assets) can implement `Medium` without changing config code
|
||||
|
||||
## Compile-Time Interface Checks
|
||||
|
||||
The package includes two compile-time assertions at the bottom of the respective files:
|
||||
|
||||
```go
|
||||
var _ core.Config = (*Config)(nil) // config.go
|
||||
var _ core.Config = (*Service)(nil) // service.go
|
||||
var _ core.Startable = (*Service)(nil) // service.go
|
||||
```
|
||||
|
||||
These ensure that if the `core.Config` or `core.Startable` interfaces ever change, this package will fail to compile rather than fail at runtime.
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
121
docs/development.md
Normal file
121
docs/development.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
---
|
||||
title: Development
|
||||
description: How to build, test, and contribute to config.
|
||||
---
|
||||
|
||||
# Development
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.26+**
|
||||
- **Core CLI** (`core` binary) for running tests and quality checks
|
||||
- The Go workspace at `~/Code/go.work` should include this module
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd /path/to/config
|
||||
|
||||
# All tests
|
||||
core go test
|
||||
|
||||
# Single test
|
||||
core go test --run TestConfig_Get_Good
|
||||
|
||||
# With coverage
|
||||
core go cov
|
||||
core go cov --open # opens HTML report in browser
|
||||
```
|
||||
|
||||
### Test Naming Convention
|
||||
|
||||
Tests follow the `_Good` / `_Bad` / `_Ugly` suffix pattern:
|
||||
|
||||
| Suffix | Meaning |
|
||||
|---------|---------------------------------|
|
||||
| `_Good` | Happy path -- expected success |
|
||||
| `_Bad` | Expected error conditions |
|
||||
| `_Ugly` | Panics, edge cases, corruption |
|
||||
|
||||
### Mock Medium
|
||||
|
||||
Tests use `io.NewMockMedium()` to avoid touching the real filesystem. Pre-populate it by writing directly to the `Files` map:
|
||||
|
||||
```go
|
||||
m := io.NewMockMedium()
|
||||
m.Files["/tmp/test/config.yaml"] = "app:\n name: existing\n"
|
||||
|
||||
cfg, err := config.New(config.WithMedium(m), config.WithPath("/tmp/test/config.yaml"))
|
||||
```
|
||||
|
||||
This pattern keeps tests fast, deterministic, and parallelisable.
|
||||
|
||||
## Quality Checks
|
||||
|
||||
```bash
|
||||
# Format, vet, lint, test in one pass
|
||||
core go qa
|
||||
|
||||
# Full suite (adds race detector, vulnerability scan, security audit)
|
||||
core go qa full
|
||||
|
||||
# Individual commands
|
||||
core go fmt
|
||||
core go vet
|
||||
core go lint
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- **UK English** in comments and documentation (colour, organisation, centre)
|
||||
- **`declare(strict_types=1)`** equivalent: all functions have explicit parameter and return types
|
||||
- **Error wrapping**: use `coreerr.E(caller, message, underlying)` from `go-log`
|
||||
- **Formatting**: standard `gofmt` / `goimports`
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
config/
|
||||
.core/
|
||||
build.yaml # Build configuration (targets, flags)
|
||||
release.yaml # Release configuration (changelog rules)
|
||||
config.go # Config struct, New(), Get/Set/Commit, Load/Save
|
||||
config_test.go # Tests
|
||||
env.go # Env() iterator, LoadEnv() (deprecated)
|
||||
service.go # Framework service wrapper (Startable)
|
||||
go.mod
|
||||
go.sum
|
||||
docs/
|
||||
index.md # This documentation
|
||||
architecture.md # Internal design
|
||||
development.md # Build and contribution guide
|
||||
```
|
||||
|
||||
## Adding a New Feature
|
||||
|
||||
1. **Write the test first** -- add a `TestFeatureName_Good` (and `_Bad` if error paths exist) to `config_test.go`.
|
||||
2. **Implement** -- keep the dual-viper invariant: writes go to both `v` and `f`; reads come from `v`; persistence comes from `f`.
|
||||
3. **Run QA** -- `core go qa` must pass before committing.
|
||||
4. **Update docs** -- if the change affects public API, update `docs/index.md` and `docs/architecture.md`.
|
||||
|
||||
## Interface Compliance
|
||||
|
||||
`Config` and `Service` both satisfy `core.Config`. `Service` additionally satisfies `core.Startable`. These are enforced at compile time:
|
||||
|
||||
```go
|
||||
var _ core.Config = (*Config)(nil)
|
||||
var _ core.Config = (*Service)(nil)
|
||||
var _ core.Startable = (*Service)(nil)
|
||||
```
|
||||
|
||||
If you add a new interface method upstream in `core/go`, the compiler will tell you what to implement here.
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
- Use conventional commits: `type(scope): description`
|
||||
- Include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>` when pair-programming with Claude
|
||||
- Push via SSH: `ssh://git@forge.lthn.ai:2223/core/config.git`
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
141
docs/index.md
Normal file
141
docs/index.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
---
|
||||
title: config
|
||||
description: Layered configuration management for the Core framework with file, environment, and in-memory resolution.
|
||||
---
|
||||
|
||||
# config
|
||||
|
||||
`forge.lthn.ai/core/config` provides layered configuration management for applications built on the Core framework. It resolves values through a priority chain -- defaults, file, environment variables, flags -- so that the same codebase works identically across local development, CI, and production without code changes.
|
||||
|
||||
## Module Path
|
||||
|
||||
```
|
||||
forge.lthn.ai/core/config
|
||||
```
|
||||
|
||||
Requires **Go 1.26+**.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Standalone usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
config "forge.lthn.ai/core/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.New() // loads ~/.core/config.yaml if it exists
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Write a value and persist it
|
||||
_ = cfg.Set("dev.editor", "vim")
|
||||
_ = cfg.Commit()
|
||||
|
||||
// Read it back
|
||||
var editor string
|
||||
_ = cfg.Get("dev.editor", &editor)
|
||||
fmt.Println(editor) // "vim"
|
||||
}
|
||||
```
|
||||
|
||||
### As a Core framework service
|
||||
|
||||
```go
|
||||
import (
|
||||
config "forge.lthn.ai/core/config"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
app, _ := core.New(
|
||||
core.WithService(config.NewConfigService),
|
||||
)
|
||||
// The config service loads automatically during OnStartup.
|
||||
// Retrieve it later via core.ServiceFor[*config.Service](app).
|
||||
```
|
||||
|
||||
## Package Layout
|
||||
|
||||
| File | Purpose |
|
||||
|-----------------|----------------------------------------------------------------|
|
||||
| `config.go` | Core `Config` struct -- layered Get/Set, file load, commit |
|
||||
| `env.go` | Environment variable iteration and prefix-based loading |
|
||||
| `service.go` | Framework service wrapper with lifecycle (`Startable`) support |
|
||||
| `config_test.go`| Tests following the `_Good` / `_Bad` / `_Ugly` convention |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Module | Role |
|
||||
|-----------------------------------|-----------------------------------------|
|
||||
| `forge.lthn.ai/core/go` | Core framework (`core.Config` interface, `ServiceRuntime`) |
|
||||
| `forge.lthn.ai/core/go-io` | Storage abstraction (`Medium` for reading/writing files) |
|
||||
| `forge.lthn.ai/core/go-log` | Contextual error helper (`E()`) |
|
||||
| `github.com/spf13/viper` | Underlying configuration engine |
|
||||
| `gopkg.in/yaml.v3` | YAML serialisation for `Commit()` |
|
||||
|
||||
## Configuration Priority
|
||||
|
||||
Values are resolved in ascending priority order:
|
||||
|
||||
1. **Defaults** -- hardcoded fallbacks (via `Set()` before any file load)
|
||||
2. **File** -- YAML loaded from `~/.core/config.yaml` (or a custom path)
|
||||
3. **Environment variables** -- prefixed with `CORE_CONFIG_` by default
|
||||
4. **Explicit Set()** -- in-memory overrides applied at runtime
|
||||
|
||||
Environment variables always override file values. An explicit `Set()` call overrides everything.
|
||||
|
||||
## Key Access
|
||||
|
||||
All keys use **dot notation** for nested values:
|
||||
|
||||
```go
|
||||
cfg.Set("a.b.c", "deep")
|
||||
|
||||
var val string
|
||||
cfg.Get("a.b.c", &val) // "deep"
|
||||
```
|
||||
|
||||
This maps to YAML structure:
|
||||
|
||||
```yaml
|
||||
a:
|
||||
b:
|
||||
c: deep
|
||||
```
|
||||
|
||||
## Environment Variable Mapping
|
||||
|
||||
Environment variables are mapped to dot-notation keys by:
|
||||
|
||||
1. Stripping the prefix (default `CORE_CONFIG_`)
|
||||
2. Lowercasing
|
||||
3. Replacing `_` with `.`
|
||||
|
||||
For example, `CORE_CONFIG_DEV_EDITOR=nano` resolves to key `dev.editor` with value `"nano"`.
|
||||
|
||||
You can change the prefix with `WithEnvPrefix`:
|
||||
|
||||
```go
|
||||
cfg, _ := config.New(config.WithEnvPrefix("MYAPP"))
|
||||
// MYAPP_SETTING=secret -> key "setting"
|
||||
```
|
||||
|
||||
## Persisting Changes
|
||||
|
||||
`Set()` only writes to memory. Call `Commit()` to flush changes to disk:
|
||||
|
||||
```go
|
||||
cfg.Set("dev.editor", "vim")
|
||||
cfg.Commit() // writes to ~/.core/config.yaml
|
||||
```
|
||||
|
||||
`Commit()` only persists values that were loaded from the file or explicitly set via `Set()`. Environment variable values are never leaked into the config file.
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
52
env.go
Normal file
52
env.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Env returns an iterator over environment variables with the given prefix,
|
||||
// providing them as dot-notation keys and values.
|
||||
//
|
||||
// For example, with prefix "CORE_CONFIG_":
|
||||
//
|
||||
// CORE_CONFIG_FOO_BAR=baz -> yields ("foo.bar", "baz")
|
||||
func Env(prefix string) iter.Seq2[string, any] {
|
||||
return func(yield func(string, any) bool) {
|
||||
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, "_", ".")
|
||||
|
||||
if !yield(key, value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LoadEnv parses environment variables with the given prefix and returns
|
||||
// them as a flat map with dot-notation keys.
|
||||
//
|
||||
// Deprecated: Use Env for iterative access or collect into a map manually.
|
||||
func LoadEnv(prefix string) map[string]any {
|
||||
result := make(map[string]any)
|
||||
for k, v := range Env(prefix) {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
28
go.mod
Normal file
28
go.mod
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
module forge.lthn.ai/core/config
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
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/google/go-cmp v0.7.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
|
||||
)
|
||||
48
go.sum
Normal file
48
go.sum
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
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=
|
||||
91
service.go
Normal file
91
service.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
core "forge.lthn.ai/core/go/pkg/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)
|
||||
}
|
||||
|
||||
// Commit persists any configuration changes to disk.
|
||||
func (s *Service) Commit() error {
|
||||
if s.config == nil {
|
||||
return coreerr.E("config.Service.Commit", "config not loaded", nil)
|
||||
}
|
||||
return s.config.Commit()
|
||||
}
|
||||
|
||||
// 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)
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue