package config import ( "fmt" "os" "path/filepath" "reflect" "strconv" "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) } // int (значение по умолчанию, напр. port) if fv.Kind() == reflect.Int { if n, err := strconv.Atoi(meta.defaultVal); err == nil { fv.SetInt(int64(n)) } } // bool (по умолчанию false/true) if fv.Kind() == reflect.Bool { if b, err := strconv.ParseBool(meta.defaultVal); err == nil { fv.SetBool(b) } } }) } // 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) } if fv.Kind() == reflect.Int { if n, err := strconv.Atoi(envVal); err == nil { fv.SetInt(int64(n)) } } if fv.Kind() == reflect.Bool { if b, err := strconv.ParseBool(envVal); err == nil { fv.SetBool(b) } } }) } // 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.Int { fn(fv, collectMeta(f)) } else if fv.Kind() == reflect.Bool { fn(fv, collectMeta(f)) } else if fv.Kind() == reflect.Struct { walk(fv, fn) } } }