diff --git a/go.mod b/go.mod index ac660d9..6f02588 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/kamelion/ratatoskr-go go 1.25.0 -require modernc.org/sqlite v1.56.0 +require ( + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.56.0 +) require ( github.com/dustin/go-humanize v1.0.1 // indirect diff --git a/go.sum b/go.sum index 1932692..a6bc08b 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,10 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..90c4e7c --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,211 @@ +package config + +import ( + "os" + "strings" + "testing" + "time" +) + +func writeCfg(t *testing.T, content string) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "cfg*.yaml") + if err != nil { + t.Fatalf("temp: %v", err) + } + if _, err := f.WriteString(content); err != nil { + t.Fatalf("write: %v", err) + } + f.Close() + return f.Name() +} + +func TestLoad_FullConfig(t *testing.T) { + t.Setenv("TG_TOKEN", "tok123") + t.Setenv("GITEA_URL", "http://gitea.home") + t.Setenv("GITEA_TOKEN", "gtok") + t.Setenv("GITEA_USER", "kamelion") + + yaml := ` +telegram: + token: "${TG_TOKEN}" + chat_id: "123" + +gitea: + url: "${GITEA_URL}" + token: "${GITEA_TOKEN}" + user: "${GITEA_USER}" + +opencode: + bin: "my-opencode" + hard_timeout: "10m" +` + path := writeCfg(t, yaml) + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Telegram.Token != "tok123" { + t.Errorf("tg token = %q", cfg.Telegram.Token) + } + if cfg.Telegram.ChatID != "123" { + t.Errorf("tg chat_id = %q", cfg.Telegram.ChatID) + } + if cfg.Gitea.URL != "http://gitea.home" { + t.Errorf("gitea url = %q", cfg.Gitea.URL) + } + if cfg.OpenCode.Bin != "my-opencode" { + t.Errorf("opencode bin = %q", cfg.OpenCode.Bin) + } + if cfg.OpenCode.HardTimeout.Duration() != 10*time.Minute { + t.Errorf("hard timeout = %v", cfg.OpenCode.HardTimeout) + } +} + +func TestLoad_Defaults(t *testing.T) { + t.Setenv("TG_TOKEN", "tok") + t.Setenv("TG_CHAT_ID", "42") + t.Setenv("GITEA_URL", "http://x") + t.Setenv("GITEA_TOKEN", "secret") + t.Setenv("GITEA_USER", "u") + + yaml := ` +telegram: + token: "${TG_TOKEN}" + chat_id: "${TG_CHAT_ID}" + +gitea: + url: "${GITEA_URL}" + token: "${GITEA_TOKEN}" + user: "${GITEA_USER}" +` + path := writeCfg(t, yaml) + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + // default opencode.bin + if cfg.OpenCode.Bin != "opencode" { + t.Errorf("bin = %q, want opencode", cfg.OpenCode.Bin) + } + if cfg.OpenCode.HardTimeout.Duration() != 20*time.Minute { + t.Errorf("hard timeout = %v", cfg.OpenCode.HardTimeout) + } + if cfg.OpenCode.IdleTimeout.Duration() != 2*time.Minute { + t.Errorf("idle timeout = %v", cfg.OpenCode.IdleTimeout) + } + if cfg.Paths.Worktree != "/opt/data/ratatoskr/worktrees" { + t.Errorf("worktree = %q", cfg.Paths.Worktree) + } +} + +func TestLoad_EnvOverride(t *testing.T) { + t.Setenv("TG_TOKEN", "tok") + t.Setenv("TG_CHAT_ID", "42") + t.Setenv("GITEA_URL", "http://x") + t.Setenv("GITEA_TOKEN", "secret") + t.Setenv("GITEA_USER", "u") + // env override для opencode.timeout + t.Setenv("OPENCODE_CONFIG", "/custom/opencode.json") + + yaml := `telegram: + token: "${TG_TOKEN}" + chat_id: "${TG_CHAT_ID}" +gitea: + url: "${GITEA_URL}" + token: "${GITEA_TOKEN}" + user: "${GITEA_USER}" +` + path := writeCfg(t, yaml) + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OpenCode.Config != "/custom/opencode.json" { + t.Errorf("opencode config = %q", cfg.OpenCode.Config) + } +} + +func TestLoad_MissingEnvVarUsesDefault(t *testing.T) { + t.Setenv("TG_TOKEN", "tok") + t.Setenv("TG_CHAT_ID", "42") + t.Setenv("GITEA_URL", "http://x") + t.Setenv("GITEA_TOKEN", "secret") + t.Setenv("GITEA_USER", "u") + + yaml := `telegram: + token: "${TG_TOKEN}" + chat_id: "${TG_CHAT_ID}" +gitea: + url: "${GITEA_URL}" + token: "${GITEA_TOKEN}" + user: "${GITEA_USER}" +opencode: + db_path: "${OPENCODE_DB:-/default/db.sqlite}" +` + path := writeCfg(t, yaml) + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OpenCode.DBPath != "/default/db.sqlite" { + t.Errorf("db_path = %q, want /default/db.sqlite", cfg.OpenCode.DBPath) + } +} + +func TestLoad_FileNotFound(t *testing.T) { + _, err := Load("/nonexistent/config.yaml") + if err == nil || !strings.Contains(err.Error(), "file not found") { + t.Fatalf("ожидалась ошибка file not found, got %v", err) + } +} + +func TestLoad_MissingRequired(t *testing.T) { + // пустой конфиг — все обязательные поля отсутствуют + t.Setenv("GITEA_URL", "") // не ставим + t.Setenv("GITEA_TOKEN", "") + t.Setenv("TG_TOKEN", "") + t.Setenv("TG_CHAT_ID", "") + t.Setenv("GITEA_USER", "") + + yaml := `telegram: + token: "" + chat_id: "" +gitea: + url: "" + token: "" + user: "" +` + path := writeCfg(t, yaml) + _, err := Load(path) + if err == nil { + t.Fatal("ожидалась ошибка валидации") + } + if !strings.Contains(err.Error(), "telegram.token") { + t.Errorf("ожидалось telegram.token в ошибке, got %v", err) + } +} + +func TestLoad_InvalidDuration(t *testing.T) { + t.Setenv("TG_TOKEN", "x") + t.Setenv("TG_CHAT_ID", "x") + t.Setenv("GITEA_URL", "x") + t.Setenv("GITEA_TOKEN", "x") + t.Setenv("GITEA_USER", "x") + + yaml := `telegram: + token: "x" + chat_id: "x" +gitea: + url: "x" + token: "x" + user: "x" +opencode: + hard_timeout: "не-длительность" +` + path := writeCfg(t, yaml) + _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "parse duration") { + t.Fatalf("ожидалась ошибка парсинга duration, got %v", err) + } +} \ No newline at end of file diff --git a/internal/config/errors.go b/internal/config/errors.go new file mode 100644 index 0000000..582527d --- /dev/null +++ b/internal/config/errors.go @@ -0,0 +1,15 @@ +package config + +import "errors" + +// Классы ошибок конфига (C1..C4). +var ( + // C1 — обязательное поле пусто (токен, url и т.п.) + ErrMissingField = errors.New("config: missing required field") + + // C2 — невалидный формат (YAML-синтаксис, Duration, число) + ErrInvalidFormat = errors.New("config: invalid format") + + // C4 — файл конфига не найден (не fatal: Load ищет ./config.yaml, флаг --config) + ErrFileNotFound = errors.New("config: file not found") +) \ No newline at end of file diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..3e7371b --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,135 @@ +package config + +import ( + "fmt" + "os" + "reflect" + "strings" + + "gopkg.in/yaml.v3" +) + +// Load читает конфиг из yaml-файла, подставляет ${VAR:-default}, накладывает +// env-переменные (тег env) и валидирует. Если path пустой — ищет config.yaml +// в CWD. Ошибка C4 если файл не существует (но не fatal при path=""). +func Load(path string) (*Config, error) { + if path == "" { + path = "config.yaml" + } + raw, err := os.ReadFile(path) + if err != nil { + if strings.Contains(err.Error(), "no such file") || strings.Contains(err.Error(), "cannot find") { + return nil, fmt.Errorf("%w: %s", ErrFileNotFound, path) + } + return nil, fmt.Errorf("%w: read %s: %v", ErrInvalidFormat, path, err) + } + // подстановка ${VAR:-default} + expanded := os.Expand(string(raw), envLookup) + cfg := &Config{} + 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 +} + +// 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) + } + } +} \ No newline at end of file diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..3378830 --- /dev/null +++ b/internal/config/types.go @@ -0,0 +1,89 @@ +// Package config — загрузка конфигурации Ratatoskr-go. +// +// Источники (каждый следующий имеет приоритет над предыдущим): +// 1. Файл config.yaml (с подстановкой ${VAR:-default}) +// 2. Переменные окружения (env-теги в структуре) +// +// Порядок: Load("config.yaml") → YAML → os.Expand → env override → Validate. +package config + +import ( + "errors" + "fmt" + "time" +) + +// Duration — time.Duration с YAML-сериализацией (строка "5s", "10m"). +type Duration time.Duration + +func (d Duration) Duration() time.Duration { return time.Duration(d) } +func (d Duration) String() string { return time.Duration(d).String() } + +func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error { + var s string + if err := unmarshal(&s); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidFormat, err) + } + td, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("%w: parse duration %q: %v", ErrInvalidFormat, s, err) + } + *d = Duration(td) + return nil +} + +// Config — полная конфигурация бинаря. +type Config struct { + Telegram TelegramCfg `yaml:"telegram"` + Gitea GiteaCfg `yaml:"gitea"` + OpenCode OpenCodeCfg `yaml:"opencode"` + Chat ChatCfg `yaml:"chat"` + Paths PathsCfg `yaml:"paths"` +} + +type TelegramCfg struct { + Token string `yaml:"token" env:"TG_TOKEN"` + ChatID string `yaml:"chat_id" env:"TG_CHAT_ID"` +} + +type GiteaCfg struct { + URL string `yaml:"url" env:"GITEA_URL"` + Token string `yaml:"token" env:"GITEA_TOKEN"` + User string `yaml:"user" env:"GITEA_USER"` +} + +type OpenCodeCfg struct { + Bin string `yaml:"bin" default:"opencode"` + DBPath string `yaml:"db_path" default:""` + Config string `yaml:"config" env:"OPENCODE_CONFIG"` + HardTimeout Duration `yaml:"hard_timeout" default:"20m"` + IdleTimeout Duration `yaml:"idle_timeout" default:"2m"` + PollMs Duration `yaml:"poll_ms" default:"2s"` +} + +type ChatCfg struct { + PollInterval Duration `yaml:"poll_interval" default:"30s"` +} + +type PathsCfg struct { + Worktree string `yaml:"worktree" default:"/opt/data/ratatoskr/worktrees"` +} + +// Validate проверяет обязательные поля. Возвращает C1 MissingField +// (склеенную, все ошибки сразу) или C2 InvalidFormat. +func (c *Config) Validate() error { + var errs []error + if c.Telegram.Token == "" { + errs = append(errs, fmt.Errorf("%w: telegram.token", ErrMissingField)) + } + if c.Telegram.ChatID == "" { + errs = append(errs, fmt.Errorf("%w: telegram.chat_id", ErrMissingField)) + } + if c.Gitea.URL == "" { + errs = append(errs, fmt.Errorf("%w: gitea.url", ErrMissingField)) + } + if c.Gitea.Token == "" { + errs = append(errs, fmt.Errorf("%w: gitea.token", ErrMissingField)) + } + return errors.Join(errs...) +} \ No newline at end of file