diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-22 09:34:08 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-22 09:34:08 +0200 |
| commit | ef8c0385ca64918f5a52be1f0780cc5e6261cc70 (patch) | |
| tree | bd6b8b4ae5b70861f6a8da6c260ea8be65d9cb68 | |
| parent | 0a877f7dfb975c51c9d57f095ab6f313682315cd (diff) | |
Implement version and config packages (task 353)
- internal/version: bump Version constant to v0.4.0
- internal/config: full Config struct with JSON snake_case fields matching
Ruby Config::DEFAULTS; Load() merges ~/.config/geheim.json over defaults,
tilde-expands path fields, and warns on stderr for parse errors
- internal/config: table-driven tests covering defaults, overrides,
tilde expansion, invalid JSON warning, and silent missing-file behaviour
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | internal/config/config.go | 101 | ||||
| -rw-r--r-- | internal/config/config_test.go | 228 | ||||
| -rw-r--r-- | internal/version/version.go | 2 |
3 files changed, 328 insertions, 3 deletions
diff --git a/internal/config/config.go b/internal/config/config.go index 3474b73..2b45333 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,5 +1,102 @@ // Package config handles loading and storing geheim configuration. +// Defaults mirror the Ruby reference (geheim.rb Config::DEFAULTS). +// A JSON file at ~/.config/geheim.json overrides individual fields; +// missing fields keep their default values because Go's json.Unmarshal +// only touches fields that are present in the JSON document. package config -// Config holds application-wide configuration values. -type Config struct{} +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// configPath is the location of the optional user config file. +const configPath = "~/.config/geheim.json" + +// Config holds all application-wide configuration values. +// JSON field names use snake_case to match geheim.rb Config::DEFAULTS keys. +type Config struct { + DataDir string `json:"data_dir"` + ExportDir string `json:"export_dir"` + KeyFile string `json:"key_file"` + KeyLength int `json:"key_length"` + EncAlg string `json:"enc_alg"` + AddToIV string `json:"add_to_iv"` + EditCmd string `json:"edit_cmd"` + GnomeClipboardCmd string `json:"gnome_clipboard_cmd"` + MacOSClipboardCmd string `json:"macos_clipboard_cmd"` + SyncRepos []string `json:"sync_repos"` +} + +// defaultConfig returns a Config populated with the same defaults as the +// Ruby reference implementation's Config::DEFAULTS. It calls +// os.UserHomeDir() so that path fields expand correctly at runtime. +func defaultConfig() Config { + home, _ := os.UserHomeDir() + return Config{ + DataDir: filepath.Join(home, "git", "geheimlager"), + ExportDir: filepath.Join(home, ".geheimlagerexport"), + KeyFile: filepath.Join(home, ".geheimlager.key"), + KeyLength: 32, + EncAlg: "AES-256-CBC", + AddToIV: "Hello world", + EditCmd: "hx", + GnomeClipboardCmd: "gpaste-client", + MacOSClipboardCmd: "pbcopy", + SyncRepos: []string{"git1", "git2"}, + } +} + +// expandTilde replaces a leading "~" in path with the user's home directory. +// Non-tilde paths and empty strings are returned unchanged. +func expandTilde(path string) string { + if path == "" || !strings.HasPrefix(path, "~") { + return path + } + home, _ := os.UserHomeDir() + // Replace only the leading "~"; preserve any subdirectory suffix. + return home + path[1:] +} + +// expandPathFields tilde-expands every path-typed field in cfg in place. +func expandPathFields(cfg *Config) { + cfg.DataDir = expandTilde(cfg.DataDir) + cfg.ExportDir = expandTilde(cfg.ExportDir) + cfg.KeyFile = expandTilde(cfg.KeyFile) +} + +// Load reads ~/.config/geheim.json and merges it over the built-in defaults. +// Any field present in the JSON file overrides the corresponding default; +// fields absent from the file keep their default values. +// If the file is missing or contains invalid JSON a warning is printed to +// stderr and the pure defaults are returned — matching the Ruby behaviour. +func Load() Config { + cfg := defaultConfig() + path := expandTilde(configPath) + + data, err := os.ReadFile(path) + if err != nil { + // File missing or unreadable — use defaults silently only when the + // error is "not found"; otherwise warn the caller. + if !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Unable to read %s, using defaults! %v\n", path, err) + } + return cfg + } + + // Unmarshal into the defaults struct so that only fields present in the + // JSON document are overwritten; all others retain their default values. + if err := json.Unmarshal(data, &cfg); err != nil { + fmt.Fprintf(os.Stderr, "Unable to read %s, using defaults! %v\n", path, err) + return defaultConfig() + } + + // Tilde-expand path fields that may have been supplied as "~/…" strings + // in the JSON file (defaultConfig() already returns absolute paths, but + // user-supplied values might use "~"). + expandPathFields(&cfg) + return cfg +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..a443ce0 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,228 @@ +package config + +import ( + "bytes" + "io" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// homeDir is a test helper that returns the current user's home directory, +// panicking if it cannot be determined (should never happen in tests). +func homeDir(t *testing.T) string { + t.Helper() + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("os.UserHomeDir: %v", err) + } + return home +} + +// writeConfigFile creates a temporary directory, writes content to +// geheim.json inside it, and returns the file path. +func writeConfigFile(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "geheim.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return path +} + +// loadFromPath replicates the Load() merge logic against an arbitrary file +// path instead of the real ~/.config/geheim.json, so tests don't need to +// redirect HOME or touch the real user config. +func loadFromPath(path string) Config { + cfg := defaultConfig() + data, err := os.ReadFile(path) + if err != nil { + return cfg + } + if err := json.Unmarshal(data, &cfg); err != nil { + return defaultConfig() + } + expandPathFields(&cfg) + return cfg +} + +// ---- tests ------------------------------------------------------------------ + +// TestExpandTilde verifies all three cases: tilde prefix, no tilde, empty string. +func TestExpandTilde(t *testing.T) { + home := homeDir(t) + + cases := []struct { + name string + input string + want string + }{ + {"tilde only", "~", home}, + {"tilde with subpath", "~/foo/bar", home + "/foo/bar"}, + {"absolute path unchanged", "/etc/passwd", "/etc/passwd"}, + {"relative path unchanged", "relative/path", "relative/path"}, + {"empty string unchanged", "", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := expandTilde(tc.input) + if got != tc.want { + t.Errorf("expandTilde(%q) = %q; want %q", tc.input, got, tc.want) + } + }) + } +} + +// TestLoad_defaults verifies that Load() returns fully-expanded default values +// when the config file does not exist. HOME is redirected to a temp dir so +// Load() looks for a config file that is guaranteed not to exist. +func TestLoad_defaults(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + cfg := Load() + home := dir // HOME was redirected, defaultConfig() uses the new value + + if cfg.DataDir != filepath.Join(home, "git", "geheimlager") { + t.Errorf("DataDir = %q; want %q", cfg.DataDir, filepath.Join(home, "git", "geheimlager")) + } + if cfg.ExportDir != filepath.Join(home, ".geheimlagerexport") { + t.Errorf("ExportDir = %q; want %q", cfg.ExportDir, filepath.Join(home, ".geheimlagerexport")) + } + if cfg.KeyFile != filepath.Join(home, ".geheimlager.key") { + t.Errorf("KeyFile = %q; want %q", cfg.KeyFile, filepath.Join(home, ".geheimlager.key")) + } + if cfg.KeyLength != 32 { + t.Errorf("KeyLength = %d; want 32", cfg.KeyLength) + } + if cfg.EncAlg != "AES-256-CBC" { + t.Errorf("EncAlg = %q; want AES-256-CBC", cfg.EncAlg) + } + if cfg.AddToIV != "Hello world" { + t.Errorf("AddToIV = %q; want 'Hello world'", cfg.AddToIV) + } + if cfg.EditCmd != "hx" { + t.Errorf("EditCmd = %q; want hx", cfg.EditCmd) + } + if cfg.GnomeClipboardCmd != "gpaste-client" { + t.Errorf("GnomeClipboardCmd = %q; want gpaste-client", cfg.GnomeClipboardCmd) + } + if cfg.MacOSClipboardCmd != "pbcopy" { + t.Errorf("MacOSClipboardCmd = %q; want pbcopy", cfg.MacOSClipboardCmd) + } + if len(cfg.SyncRepos) != 2 || cfg.SyncRepos[0] != "git1" || cfg.SyncRepos[1] != "git2" { + t.Errorf("SyncRepos = %v; want [git1 git2]", cfg.SyncRepos) + } +} + +// TestLoad_override verifies that fields present in the JSON file override +// defaults while absent fields retain their default values. +func TestLoad_override(t *testing.T) { + jsonContent := `{"edit_cmd":"nvim","key_length":64,"sync_repos":["github","gitlab"]}` + path := writeConfigFile(t, jsonContent) + + cfg := loadFromPath(path) + + // Overridden fields. + if cfg.EditCmd != "nvim" { + t.Errorf("EditCmd = %q; want nvim", cfg.EditCmd) + } + if cfg.KeyLength != 64 { + t.Errorf("KeyLength = %d; want 64", cfg.KeyLength) + } + if len(cfg.SyncRepos) != 2 || cfg.SyncRepos[0] != "github" || cfg.SyncRepos[1] != "gitlab" { + t.Errorf("SyncRepos = %v; want [github gitlab]", cfg.SyncRepos) + } + + // Non-overridden fields must keep their defaults. + home := homeDir(t) + if cfg.EncAlg != "AES-256-CBC" { + t.Errorf("EncAlg = %q; want AES-256-CBC", cfg.EncAlg) + } + if cfg.DataDir != filepath.Join(home, "git", "geheimlager") { + t.Errorf("DataDir = %q; want default", cfg.DataDir) + } +} + +// TestLoad_pathOverride verifies that tilde paths supplied via JSON are +// expanded to absolute paths after loading. +func TestLoad_pathOverride(t *testing.T) { + home := homeDir(t) + jsonContent := `{"data_dir":"~/custom/vault"}` + path := writeConfigFile(t, jsonContent) + + cfg := loadFromPath(path) + + want := filepath.Join(home, "custom", "vault") + if cfg.DataDir != want { + t.Errorf("DataDir = %q; want %q", cfg.DataDir, want) + } +} + +// TestLoad_invalid_json verifies that invalid JSON causes Load() to return +// defaults and print a warning to stderr. +func TestLoad_invalid_json(t *testing.T) { + // Write invalid JSON to the expected config location inside a temp HOME. + dir := t.TempDir() + cfgDir := filepath.Join(dir, ".config") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + badJSON := filepath.Join(cfgDir, "geheim.json") + if err := os.WriteFile(badJSON, []byte("{invalid json}"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + // Capture stderr to verify the warning message is emitted. + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + t.Setenv("HOME", dir) + + cfg := Load() + + w.Close() + os.Stderr = origStderr + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + stderrOutput := buf.String() + + if !strings.Contains(stderrOutput, "Unable to read") { + t.Errorf("expected warning on stderr, got: %q", stderrOutput) + } + + // Returned config must equal defaults (with the redirected HOME). + if cfg.EditCmd != "hx" { + t.Errorf("EditCmd = %q; want hx (default)", cfg.EditCmd) + } + if cfg.KeyLength != 32 { + t.Errorf("KeyLength = %d; want 32 (default)", cfg.KeyLength) + } +} + +// TestLoad_missing_file_no_warning verifies that a missing config file does +// NOT produce a warning on stderr — absence of the file is a normal condition +// (first run or unconfigured installation). +func TestLoad_missing_file_no_warning(t *testing.T) { + dir := t.TempDir() // no geheim.json inside + + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + t.Setenv("HOME", dir) + + _ = Load() + + w.Close() + os.Stderr = origStderr + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + + if buf.Len() != 0 { + t.Errorf("expected no stderr output for missing file, got: %q", buf.String()) + } +} diff --git a/internal/version/version.go b/internal/version/version.go index c7defb0..9297d1b 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -2,4 +2,4 @@ package version // Version is the current release version of geheim. -const Version = "0.0.0" +const Version = "v0.4.0" |
