fix: пути (db, worktree) и config.yaml резолвятся рядом с .exe, а не от CWD
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

This commit is contained in:
Hermes
2026-08-16 20:31:08 +05:00
parent 91e91ac3ce
commit 130dc59840
3 changed files with 95 additions and 2 deletions

View File

@@ -55,6 +55,9 @@ func New(configPath, version, updateToken string) (*App, error) {
return nil, fmt.Errorf("%w: %v", ErrConfig, err) return nil, fmt.Errorf("%w: %v", ErrConfig, err)
} }
// Относительные пути (db, worktree) — рядом с .exe, а не от CWD запуска.
cfg.ResolveExePaths()
// SQLite для задач и трасс // SQLite для задач и трасс
ctx := context.Background() ctx := context.Background()

View File

@@ -2,6 +2,7 @@ package config
import ( import (
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "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) { func TestLoad_MissingRequired(t *testing.T) {
// пустой конфиг — все обязательные поля отсутствуют // пустой конфиг — все обязательные поля отсутствуют
t.Setenv("TG_TOKEN", "") t.Setenv("TG_TOKEN", "")

View File

@@ -3,6 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"reflect" "reflect"
"strings" "strings"
@@ -11,11 +12,13 @@ import (
// Load читает конфиг из yaml-файла, подставляет ${VAR:-default}, накладывает // Load читает конфиг из yaml-файла, подставляет ${VAR:-default}, накладывает
// env-переменные (тег env) и валидирует. Если path пустой — ищет config.yaml // env-переменные (тег env) и валидирует. Если path пустой — ищет config.yaml
// в CWD. Ошибка C4 если файл не существует (но не fatal при path=""). // сначала в каталоге бинаря (exeDir), затем в CWD (fallback). Ошибка C4 если
// файл не существует (но не fatal при path="").
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
noFile := false noFile := false
if path == "" { if path == "" {
path = "config.yaml" // приоритет — рядом с .exe, фоллбэк на CWD
path = firstExistingPath(filepath.Join(ExeDir(), "config.yaml"), "config.yaml")
} }
raw, err := os.ReadFile(path) raw, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -47,6 +50,40 @@ func Load(path string) (*Config, error) {
return cfg, nil 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. // envLookup обрабатывает ${VAR:-default} в YAML.
func envLookup(key string) string { func envLookup(key string) string {
parts := strings.SplitN(key, ":-", 2) parts := strings.SplitN(key, ":-", 2)