summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-22 09:34:08 +0200
committerPaul Buetow <paul@buetow.org>2026-02-22 09:34:08 +0200
commitef8c0385ca64918f5a52be1f0780cc5e6261cc70 (patch)
treebd6b8b4ae5b70861f6a8da6c260ea8be65d9cb68
parent0a877f7dfb975c51c9d57f095ab6f313682315cd (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.go101
-rw-r--r--internal/config/config_test.go228
-rw-r--r--internal/version/version.go2
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"