Files
ratatoskr-go/internal/config/load.go
Hermes 130dc59840
All checks were successful
CI / test (push) Successful in 1m5s
CI / build-and-package (amd64, linux) (push) Successful in 44s
CI / build-and-package (amd64, windows) (push) Successful in 43s
fix: пути (db, worktree) и config.yaml резолвятся рядом с .exe, а не от CWD
2026-08-16 20:31:08 +05:00

180 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}