summaryrefslogtreecommitdiff
path: root/config
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-05 14:17:59 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-05 14:17:59 +0200
commit71c39ed9f8ce3563130cdfc5099e9d7df60845a9 (patch)
treee610b8360f6364e3ab7c5e817ac94bd51cc0dfd1 /config
parentbef31e1d35fe88f2698cf0e3191f26a79623f66f (diff)
refactor: Replace env config with atomic YAML
Diffstat (limited to 'config')
-rw-r--r--config/config.go120
-rw-r--r--config/config_test.go38
-rw-r--r--config/env.go52
3 files changed, 158 insertions, 52 deletions
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..98ccebc
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,120 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "gopkg.in/yaml.v3"
+)
+
+type Config struct {
+ Database DatabaseConfig `yaml:"database"`
+ Server ServerConfig `yaml:"server"`
+ Storage StorageConfig `yaml:"storage"`
+ Strava StravaConfig `yaml:"strava"`
+}
+
+type DatabaseConfig struct {
+ Path string `yaml:"path"`
+}
+
+type ServerConfig struct {
+ Address string `yaml:"address"`
+ PublicURL string `yaml:"public_url"`
+}
+
+type StorageConfig struct {
+ GPXDir string `yaml:"gpx_dir"`
+}
+
+type StravaConfig struct {
+ ClientID string `yaml:"client_id"`
+ ClientSecret string `yaml:"client_secret"`
+ AccessToken string `yaml:"access_token"`
+ RefreshToken string `yaml:"refresh_token"`
+ ExpiresAt int64 `yaml:"expires_at"`
+}
+
+func Default() Config {
+ return Config{
+ Database: DatabaseConfig{Path: "biking_home.db"},
+ Server: ServerConfig{
+ Address: "127.0.0.1:8080",
+ PublicURL: "http://localhost:8080",
+ },
+ Storage: StorageConfig{GPXDir: "rides"},
+ }
+}
+
+func Load(filename string) (Config, error) {
+ data, err := os.ReadFile(filename)
+ if err != nil {
+ return Config{}, fmt.Errorf("read config %q: %w", filename, err)
+ }
+ config := Default()
+ if err := yaml.Unmarshal(data, &config); err != nil {
+ return Config{}, fmt.Errorf("parse config %q: %w", filename, err)
+ }
+ if config.Database.Path == "" {
+ return Config{}, fmt.Errorf("config %q: database.path is required", filename)
+ }
+ if config.Server.Address == "" || config.Server.PublicURL == "" {
+ return Config{}, fmt.Errorf("config %q: server.address and server.public_url are required", filename)
+ }
+ if config.Storage.GPXDir == "" {
+ return Config{}, fmt.Errorf("config %q: storage.gpx_dir is required", filename)
+ }
+ return config, nil
+}
+
+func Save(filename string, config Config) error {
+ data, err := yaml.Marshal(config)
+ if err != nil {
+ return fmt.Errorf("marshal config: %w", err)
+ }
+ directory := filepath.Dir(filename)
+ temporary, err := os.CreateTemp(directory, "."+filepath.Base(filename)+".tmp-*")
+ if err != nil {
+ return fmt.Errorf("create temporary config: %w", err)
+ }
+ temporaryName := temporary.Name()
+ removeTemporary := true
+ defer func() {
+ if removeTemporary {
+ _ = os.Remove(temporaryName)
+ }
+ }()
+ if err := temporary.Chmod(0o600); err != nil {
+ _ = temporary.Close()
+ return fmt.Errorf("set config permissions: %w", err)
+ }
+ if _, err := temporary.Write(data); err != nil {
+ _ = temporary.Close()
+ return fmt.Errorf("write temporary config: %w", err)
+ }
+ if err := temporary.Sync(); err != nil {
+ _ = temporary.Close()
+ return fmt.Errorf("sync temporary config: %w", err)
+ }
+ if err := temporary.Close(); err != nil {
+ return fmt.Errorf("close temporary config: %w", err)
+ }
+ if err := os.Rename(temporaryName, filename); err != nil {
+ return fmt.Errorf("replace config: %w", err)
+ }
+ removeTemporary = false
+ return syncDirectory(directory)
+}
+
+func syncDirectory(directory string) error {
+ dir, err := os.Open(directory)
+ if err != nil {
+ return fmt.Errorf("open config directory: %w", err)
+ }
+ defer dir.Close()
+ if err := dir.Sync(); err != nil {
+ return fmt.Errorf("sync config directory: %w", err)
+ }
+ return nil
+}
diff --git a/config/config_test.go b/config/config_test.go
new file mode 100644
index 0000000..6b99030
--- /dev/null
+++ b/config/config_test.go
@@ -0,0 +1,38 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSaveAndLoad(t *testing.T) {
+ filename := filepath.Join(t.TempDir(), "config.yaml")
+ want := Default()
+ want.Database.Path = "test.db"
+ want.Strava.ClientID = "123"
+ want.Strava.ExpiresAt = 1_700_000_000
+
+ require.NoError(t, Save(filename, want))
+ got, err := Load(filename)
+ require.NoError(t, err)
+ assert.Equal(t, want, got)
+ info, err := os.Stat(filename)
+ require.NoError(t, err)
+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+}
+
+func TestSaveReplacesExistingConfigAtomically(t *testing.T) {
+ filename := filepath.Join(t.TempDir(), "config.yaml")
+ require.NoError(t, Save(filename, Default()))
+ updated := Default()
+ updated.Server.Address = "127.0.0.1:9090"
+ require.NoError(t, Save(filename, updated))
+
+ got, err := Load(filename)
+ require.NoError(t, err)
+ assert.Equal(t, "127.0.0.1:9090", got.Server.Address)
+}
diff --git a/config/env.go b/config/env.go
deleted file mode 100644
index b9d9f76..0000000
--- a/config/env.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package config
-
-import (
- "fmt"
- "os"
- "strings"
-)
-
-func LoadEnv(filename string) (map[string]string, error) {
- data, err := os.ReadFile(filename)
- if err != nil {
- return nil, err
- }
- values := map[string]string{}
- for _, line := range strings.Split(string(data), "\n") {
- line = strings.TrimSpace(line)
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- key, value, found := strings.Cut(line, "=")
- if found {
- values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"`)
- }
- }
- return values, nil
-}
-
-func UpdateEnv(filename string, updates map[string]string) error {
- data, err := os.ReadFile(filename)
- if err != nil {
- return err
- }
- lines := strings.Split(string(data), "\n")
- for key, value := range updates {
- found := false
- for i, line := range lines {
- existingKey, _, cut := strings.Cut(strings.TrimSpace(line), "=")
- if cut && strings.TrimSpace(existingKey) == key {
- lines[i] = key + "=" + value
- found = true
- break
- }
- }
- if !found {
- lines = append(lines, key+"="+value)
- }
- }
- if err := os.WriteFile(filename, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
- return fmt.Errorf("write env file: %w", err)
- }
- return nil
-}