From 130dc59840321f25a6213adb75bb30963e6e72c6 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 16 Aug 2026 20:31:08 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BF=D1=83=D1=82=D0=B8=20(db,=20worktr?= =?UTF-8?q?ee)=20=D0=B8=20config.yaml=20=D1=80=D0=B5=D0=B7=D0=BE=D0=BB?= =?UTF-8?q?=D0=B2=D1=8F=D1=82=D1=81=D1=8F=20=D1=80=D1=8F=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=20=D1=81=20.exe,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BE=D1=82=20CWD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/app.go | 3 ++ internal/config/config_test.go | 53 ++++++++++++++++++++++++++++++++++ internal/config/load.go | 41 ++++++++++++++++++++++++-- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 32cadb6..94b1dc3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -55,6 +55,9 @@ func New(configPath, version, updateToken string) (*App, error) { return nil, fmt.Errorf("%w: %v", ErrConfig, err) } + // Относительные пути (db, worktree) — рядом с .exe, а не от CWD запуска. + cfg.ResolveExePaths() + // SQLite для задач и трасс ctx := context.Background() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b1d6d71..7447014 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "os" + "path/filepath" "strings" "testing" "time" @@ -155,6 +156,58 @@ func TestLoad_FileNotFound(t *testing.T) { } } +func TestResolveExePaths_RelativeToExe(t *testing.T) { + t.Setenv("TG_TOKEN", "tok") + t.Setenv("TG_CHAT_ID", "42") + + yaml := `telegram: + token: "${TG_TOKEN}" + chat_id: "${TG_CHAT_ID}" +paths: + db: "./ratatoskr.db" + worktree: "./worktrees" +` + cfg, err := Load(writeCfg(t, yaml)) + if err != nil { + t.Fatalf("Load: %v", err) + } + cfg.ResolveExePaths() + + base := ExeDir() + if cfg.Paths.DB != filepath.Join(base, "ratatoskr.db") { + t.Errorf("db = %q, want %q", cfg.Paths.DB, filepath.Join(base, "ratatoskr.db")) + } + if cfg.Paths.Worktree != filepath.Join(base, "worktrees") { + t.Errorf("worktree = %q, want %q", cfg.Paths.Worktree, filepath.Join(base, "worktrees")) + } +} + +func TestResolveExePaths_AbsoluteKept(t *testing.T) { + t.Setenv("TG_TOKEN", "tok") + t.Setenv("TG_CHAT_ID", "42") + + absDB := filepath.Join(string(filepath.Separator), "data", "ratatoskr.db") // абсолютный для текущей ОС + absWt := filepath.Join(string(filepath.Separator), "worktrees") + yaml := `telegram: + username: "${TG_TOKEN}" + chat_id: "${TG_CHAT_ID}" +paths: + db: "` + absDB + `" + worktree: "` + absWt + `" +` + cfg, err := Load(writeCfg(t, yaml)) + if err != nil { + t.Fatalf("Load: %v", err) + } + cfg.ResolveExePaths() + if cfg.Paths.DB != absDB { + t.Errorf("db = %q, want абсолютный путь сохранён %q", cfg.Paths.DB, absDB) + } + if cfg.Paths.Worktree != absWt { + t.Errorf("worktree = %q, want абсолютный путь сохранён %q", cfg.Paths.Worktree, absWt) + } +} + func TestLoad_MissingRequired(t *testing.T) { // пустой конфиг — все обязательные поля отсутствуют t.Setenv("TG_TOKEN", "") diff --git a/internal/config/load.go b/internal/config/load.go index 00136f7..1671d4b 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "path/filepath" "reflect" "strings" @@ -11,11 +12,13 @@ import ( // Load читает конфиг из yaml-файла, подставляет ${VAR:-default}, накладывает // env-переменные (тег env) и валидирует. Если path пустой — ищет config.yaml -// в CWD. Ошибка C4 если файл не существует (но не fatal при path=""). +// сначала в каталоге бинаря (exeDir), затем в CWD (fallback). Ошибка C4 если +// файл не существует (но не fatal при path=""). func Load(path string) (*Config, error) { noFile := false if path == "" { - path = "config.yaml" + // приоритет — рядом с .exe, фоллбэк на CWD + path = firstExistingPath(filepath.Join(ExeDir(), "config.yaml"), "config.yaml") } raw, err := os.ReadFile(path) if err != nil { @@ -47,6 +50,40 @@ func Load(path string) (*Config, error) { 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)