180 lines
5.3 KiB
Go
180 lines
5.3 KiB
Go
package config
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"reflect"
|
||
"strings"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
// Load читает конфиг из yaml-файла, подставляет ${VAR:-default}, накладывает
|
||
// env-переменные (тег env) и валидирует. Если path пустой — ищет config.yaml
|
||
// сначала в каталоге бинаря (exeDir), затем в CWD (fallback). Ошибка C4 если
|
||
// файл не существует (но не fatal при path="").
|
||
func Load(path string) (*Config, error) {
|
||
noFile := false
|
||
if path == "" {
|
||
// приоритет — рядом с .exe, фоллбэк на CWD
|
||
path = firstExistingPath(filepath.Join(ExeDir(), "config.yaml"), "config.yaml")
|
||
}
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "no such file") || strings.Contains(err.Error(), "cannot find") {
|
||
if path == "config.yaml" {
|
||
// Файл не указан и не найден — не фатально, соберём дефолты+env
|
||
noFile = true
|
||
} else {
|
||
return nil, fmt.Errorf("%w: %s", ErrFileNotFound, path)
|
||
}
|
||
} else {
|
||
return nil, fmt.Errorf("%w: read %s: %v", ErrInvalidFormat, path, err)
|
||
}
|
||
}
|
||
cfg := &Config{}
|
||
if !noFile {
|
||
expanded := os.Expand(string(raw), envLookup)
|
||
if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil {
|
||
return nil, fmt.Errorf("%w: parse %s: %v", ErrInvalidFormat, path, err)
|
||
}
|
||
}
|
||
// дефолты для zero-значений
|
||
applyDefaults(cfg)
|
||
// env override (только если env-переменная непустая)
|
||
applyEnvOverrides(cfg)
|
||
if err := cfg.Validate(); err != nil {
|
||
return nil, err
|
||
}
|
||
return cfg, nil
|
||
}
|
||
|
||
// ExeDir возвращает каталог исполняемого файла (бинаря). Используется для
|
||
// поиска config.yaml и резолва относительных путей "рядом с .exe".
|
||
func ExeDir() string {
|
||
exe, err := os.Executable()
|
||
if err != nil {
|
||
return "."
|
||
}
|
||
return filepath.Dir(exe)
|
||
}
|
||
|
||
// firstExistingPath возвращает первый существующий путь из списка.
|
||
func firstExistingPath(paths ...string) string {
|
||
for _, p := range paths {
|
||
if _, err := os.Stat(p); err == nil {
|
||
return p
|
||
}
|
||
}
|
||
return paths[len(paths)-1] // фоллбэк на последний
|
||
}
|
||
|
||
// ResolveExePaths подставляет относительные пути ПУТЕЙ (db, worktree) к каталогу
|
||
// бинаря, чтобы они были стабильны независимо от CWD запуска. Абсолютные пути
|
||
// не трогает. Вызывается после Load (в app.New), а не внутри Load, чтобы
|
||
// персист-тесты дефолтов оставались независимыми от окружения.
|
||
func (c *Config) ResolveExePaths() {
|
||
base := ExeDir()
|
||
if c.Paths.DB != "" && !filepath.IsAbs(c.Paths.DB) {
|
||
c.Paths.DB = filepath.Join(base, c.Paths.DB)
|
||
}
|
||
if c.Paths.Worktree != "" && !filepath.IsAbs(c.Paths.Worktree) {
|
||
c.Paths.Worktree = filepath.Join(base, c.Paths.Worktree)
|
||
}
|
||
}
|
||
|
||
// envLookup обрабатывает ${VAR:-default} в YAML.
|
||
func envLookup(key string) string {
|
||
parts := strings.SplitN(key, ":-", 2)
|
||
v := os.Getenv(parts[0])
|
||
if v != "" {
|
||
return v
|
||
}
|
||
if len(parts) > 1 {
|
||
return parts[1]
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ---- reflect-based defaults и env override ----
|
||
|
||
type fieldMeta struct {
|
||
defaultVal string
|
||
envKey string
|
||
}
|
||
|
||
func collectMeta(f reflect.StructField) fieldMeta {
|
||
return fieldMeta{
|
||
envKey: f.Tag.Get("env"),
|
||
defaultVal: f.Tag.Get("default"),
|
||
}
|
||
}
|
||
|
||
// applyDefaults проставляет default-значения для zero-полей.
|
||
func applyDefaults(cfg *Config) {
|
||
walk(reflect.ValueOf(cfg).Elem(), func(fv reflect.Value, meta fieldMeta) {
|
||
if meta.defaultVal == "" {
|
||
return
|
||
}
|
||
// только zero-поля
|
||
if !fv.IsZero() {
|
||
return
|
||
}
|
||
// Duration требует ручного Unmarshal
|
||
if dur, ok := fv.Addr().Interface().(*Duration); ok {
|
||
_ = dur.UnmarshalYAML(func(v any) error {
|
||
*(v.(*string)) = meta.defaultVal
|
||
return nil
|
||
})
|
||
return
|
||
}
|
||
// строка
|
||
if fv.Kind() == reflect.String {
|
||
fv.SetString(meta.defaultVal)
|
||
}
|
||
})
|
||
}
|
||
|
||
// applyEnvOverrides перезаписывает поля из env-переменных, если они непустые.
|
||
func applyEnvOverrides(cfg *Config) {
|
||
walk(reflect.ValueOf(cfg).Elem(), func(fv reflect.Value, meta fieldMeta) {
|
||
if meta.envKey == "" {
|
||
return
|
||
}
|
||
envVal := os.Getenv(meta.envKey)
|
||
if envVal == "" {
|
||
return
|
||
}
|
||
if dur, ok := fv.Addr().Interface().(*Duration); ok {
|
||
_ = dur.UnmarshalYAML(func(v any) error {
|
||
*(v.(*string)) = envVal
|
||
return nil
|
||
})
|
||
return
|
||
}
|
||
if fv.Kind() == reflect.String {
|
||
fv.SetString(envVal)
|
||
}
|
||
})
|
||
}
|
||
|
||
// walk рекурсивно обходит все leaf-поля структуры (string, Duration) и зовёт fn.
|
||
func walk(v reflect.Value, fn func(reflect.Value, fieldMeta)) {
|
||
t := v.Type()
|
||
durType := reflect.TypeOf(Duration(0))
|
||
for i := range t.NumField() {
|
||
f := t.Field(i)
|
||
fv := v.Field(i)
|
||
if !fv.CanSet() {
|
||
continue
|
||
}
|
||
if fv.Kind() == reflect.String {
|
||
fn(fv, collectMeta(f))
|
||
} else if fv.Type() == durType {
|
||
fn(fv, collectMeta(f))
|
||
} else if fv.Kind() == reflect.Struct {
|
||
walk(fv, fn)
|
||
}
|
||
}
|
||
} |