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)